diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c4073ed1af..0d75b669d2 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,16 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.15.7-nightly.2
+ - 3.15.7-nightly.1
+ - 3.15.6
+ - 3.15.6-nightly.3
+ - 3.15.6-nightly.2
+ - 3.15.6-nightly.1
+ - 3.15.5
+ - 3.15.5-nightly.2
+ - 3.15.5-nightly.1
+ - 3.15.4
- 3.15.4-nightly.3
- 3.15.4-nightly.2
- 3.15.4-nightly.1
@@ -125,16 +135,6 @@ body:
- 3.14.1-nightly.3
- 3.14.1-nightly.2
- 3.14.1-nightly.1
- - 3.14.0
- - 3.14.0-nightly.1
- - 3.13.1-nightly.3
- - 3.13.1-nightly.2
- - 3.13.1-nightly.1
- - 3.13.0
- - 3.13.0-nightly.1
- - 3.12.3-nightly.3
- - 3.12.3-nightly.2
- - 3.12.3-nightly.1
validations:
required: true
- type: dropdown
@@ -166,8 +166,8 @@ body:
label: Are there any labels you wish to add?
description: Please search labels and identify those related to your bug.
options:
- - label: I have added the relevant labels to the bug report.
- required: true
+ - label: I have added the relevant labels to the bug report.
+ required: true
- type: textarea
id: logs
attributes:
diff --git a/.github/workflows/nightly_merge.yml b/.github/workflows/nightly_merge.yml
index f1850762d9..3f8c75dce3 100644
--- a/.github/workflows/nightly_merge.yml
+++ b/.github/workflows/nightly_merge.yml
@@ -25,5 +25,5 @@ jobs:
- name: Invoke pre-release workflow
uses: benc-uk/workflow-dispatch@v1
with:
- workflow: Nightly Prerelease
+ workflow: prerelease.yml
token: ${{ secrets.YNPUT_BOT_TOKEN }}
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index e8c619c6eb..8c5c733c08 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -65,3 +65,9 @@ jobs:
source_ref: 'main'
target_branch: 'develop'
commit_message_template: '[Automated] Merged {source_ref} into {target_branch}'
+
+ - name: Invoke Update bug report workflow
+ uses: benc-uk/workflow-dispatch@v1
+ with:
+ workflow: update_bug_report.yml
+ token: ${{ secrets.YNPUT_BOT_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/update_bug_report.yml b/.github/workflows/update_bug_report.yml
index 9f44d7c7a6..1e5da414bb 100644
--- a/.github/workflows/update_bug_report.yml
+++ b/.github/workflows/update_bug_report.yml
@@ -23,3 +23,11 @@ jobs:
limit_to: 100
form: .github/ISSUE_TEMPLATE/bug_report.yml
commit_message: 'chore(): update bug report / version'
+ dry_run: no-push
+
+ - name: Push to protected develop branch
+ uses: CasperWA/push-protected@v2.10.0
+ with:
+ token: ${{ secrets.YNPUT_BOT_TOKEN }}
+ branch: develop
+ unprotect_reviews: true
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aeb546c14..07c1e7d5fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,557 @@
# Changelog
+## [3.15.6](https://github.com/ynput/OpenPype/tree/3.15.6)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.5...3.15.6)
+
+### **🆕 New features**
+
+
+
+Substance Painter Integration #4283
+
+This implements a part of #4205 by implementing a Substance Painter integration
+
+Status:
+- [x] Implement Host
+- [x] start substance with last workfile using `AddLastWorkfileToLaunchArgs` prelaunch hook
+- [x] Implement Qt tools
+- [x] Implement loaders
+- [x] Implemented a Set project mesh loader (this is relatively special case because a Project will always have exactly one mesh - a Substance Painter project cannot exist without a mesh).
+- [x] Implement project open callback
+- [x] On project open it notifies the user if the loaded model is outdated
+- [x] Implement publishing logic
+- [x] Workfile publishing
+- [x] Export Texture Sets
+- [x] Support OCIO using #4195 (draft brach is set up - see comment)
+- [ ] Likely needs more testing on the OCIO front
+- [x] Validate all outputs of the Export template are exported/generated
+- [x] Allow validation to be optional **(issue: there's no API method to detect what maps will be exported without doing an actual export to disk)**
+- [x] Support extracting/integration if not all outputs are generated
+- [x] Support multiple materials/texture sets per instance
+- [ ] Add validator that can enforce only a single texture set output if studio prefers that.
+- [ ] Implement Export File Format (extensions) override in Creator
+- [ ] Add settings so Admin can choose which extensions are available.
+
+
+___
+
+
+
+
+
+Data Exchange: Geometry in 3dsMax #4555
+
+Introduces and updates a creator, extractors and loaders for model family
+
+Introduces new creator, extractors and loaders for model family while adding model families into the existing max scene loader and extractor
+- [x] creators
+- [x] adding model family into max scene loader and extractor
+- [x] fbx loader
+- [x] fbx extractor
+- [x] usd loader
+- [x] usd extractor
+- [x] validator for model family
+- [x] obj loader(update function)
+- [x] fix the update function of the loader as #4675
+- [x] Add documentation
+
+
+___
+
+
+
+
+
+AfterEffects: add review flag to each instance #4884
+
+Adds `mark_for_review` flag to the Creator to allow artists to disable review if necessary.Exposed this flag in Settings, by default set to True (eg. same behavior as previously).
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Houdini: Fix Validate Output Node (VDB) #4819
+
+- Removes plug-in that was a duplicate of this plug-in.
+- Optimize logging of many prims slightly
+- Fix error reporting like https://github.com/ynput/OpenPype/pull/4818 did
+
+
+___
+
+
+
+
+
+Houdini: Add null node as output indicator when using TAB search #4834
+
+
+___
+
+
+
+
+
+Houdini: Don't error in collect review if camera is not set correctly #4874
+
+Do not raise an error in collector when invalid path is set as camera path. Allow camera path to not be set correctly in review instance until validation so it's nicely shown in a validation report.
+
+
+___
+
+
+
+
+
+Project packager: Backup and restore can store only database #4879
+
+Pack project functionality have option to zip only project database without project files. Unpack project can skip project copy if the folder is not found.Added helper functions to `openpype.client.mongo` that can be also used for tests as replacement of mongo dump.
+
+
+___
+
+
+
+
+
+Houdini: ExtractOpenGL for Review instance not optional #4881
+
+Don't make ExtractOpenGL optional for review instance optional.
+
+
+___
+
+
+
+
+
+Publisher: Small style changes #4894
+
+Small changes in styles and form of publisher UI.
+
+
+___
+
+
+
+
+
+Houdini: Workfile icon in new publisher #4898
+
+Fix icon for the workfile instance in new publisher
+
+
+___
+
+
+
+
+
+Fusion: Simplify creator icons code #4899
+
+Simplify code for setting the icons for the Fusion creators
+
+
+___
+
+
+
+
+
+Enhancement: Fix PySide 6.5 support for loader #4900
+
+Fixes PySide 6.5 support in Loader.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Validate Attributes #4917
+
+This plugin was broken due to bad fetching of data and wrong repair action.
+
+
+___
+
+
+
+
+
+Fix: Locally copied version of last published workfile is not incremented #4722
+
+### Fix 1
+When copied, the local workfile version keeps the published version number, when it must be +1 to follow OP's naming convention.
+
+### Fix 2
+Local workfile version's name is built from anatomy. This avoids to get workfiles with their publish template naming.
+
+### Fix 3
+In the case a subset has at least two tasks with published workfiles, for example `Modeling` and `Rigging`, launching `Rigging` was getting the first one with the `next` and trying to find representations, therefore `workfileModeling` and trying to match the current `task_name` (`Rigging`) with the `representation["context"]["task"]["name"]` of a Modeling representation, which was ending up to a `workfile_representation` to `None`, and exiting the process.
+
+Trying to find the `task_name` in the `subset['name']` fixes it.
+
+### Fix 4
+Fetch input dependencies of workfile.
+
+Replacing https://github.com/ynput/OpenPype/pull/4102 for changes to bring this home.
+___
+
+
+
+
+
+Maya: soft-fail when pan/zoom locked on camera when playblasting #4929
+
+When pan/zoom enabled attribute on camera is locked, playblasting with pan/zoom fails because it is trying to restore it. This is fixing it by skipping over with warning.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Maya Load References - Add Display Handle Setting #4904
+
+When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings.
+
+
+___
+
+
+
+
+
+Photoshop: add autocreators for review and flat image #4871
+
+Review and flatten image (produced when no instance of `image` family was created) were created somehow magically. This PRintroduces two new auto creators which allow artists to disable review or flatten image.For all `image` instances `Review` flag was added to provide functionality to create separate review per `image` instance. Previously was possible only to have separate instance of `review` family.Review is not enabled on `image` family by default. (Eg. follows original behavior)Review auto creator is enabled by default as it was before.Flatten image creator must be set in Settings in `project_settings/photoshop/create/AutoImageCreator`.
+
+
+___
+
+
+
+
+
+
+## [3.15.5](https://github.com/ynput/OpenPype/tree/3.15.5)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.4...3.15.5)
+
+### **🚀 Enhancements**
+
+
+
+Maya: Playblast profiles #4777
+
+Support playblast profiles.This enables studios to customize what playblast settings should be on a per task and/or subset basis. For example `modeling` should have `Wireframe On Shaded` enabled, while all other tasks should have it disabled.
+
+
+___
+
+
+
+
+
+Maya: Support .abc files directly for Arnold standin look assignment #4856
+
+If `.abc` file is loaded into arnold standin support look assignment through the `cbId` attributes in the alembic file.
+
+
+___
+
+
+
+
+
+Maya: Hide animation instance in creator #4872
+
+- Hide animation instance in creator
+- Add inventory action to recreate animation publish instance for loaded rigs
+
+
+___
+
+
+
+
+
+Unreal: Render Creator enhancements #4477
+
+Improvements to the creator for render family
+
+This PR introduces some enhancements to the creator for the render family in Unreal Engine:
+- Added the option to create a new, empty sequence for the render.
+- Added the option to not include the whole hierarchy for the selected sequence.
+- Improvements of the error messages.
+
+
+___
+
+
+
+
+
+Unreal: Added settings for rendering #4575
+
+Added settings for rendering in Unreal Engine.
+
+Two settings has been added:
+- Pre roll frames, to set how many frames are used to load the scene before starting the actual rendering.
+- Configuration path, to allow to save a preset of settings from Unreal, and use it for rendering.
+
+
+___
+
+
+
+
+
+Global: Optimize anatomy formatting by only formatting used templates instead #4784
+
+Optimization to not format full anatomy when only a single template is used. Instead format only the single template instead.
+
+
+___
+
+
+
+
+
+Patchelf version locked #4853
+
+For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails.
+
+___
+
+
+
+
+
+Houdini: Implement `switch` method on loaders #4866
+
+Implement `switch` method on loaders
+
+
+___
+
+
+
+
+
+Code: Tweak docstrings and return type hints #4875
+
+Tweak docstrings and return type hints for functions in `openpype.client.entities`.
+
+
+___
+
+
+
+
+
+Publisher: Clear comment on successful publish and on window close #4885
+
+Clear comment text field on successful publish and on window close.
+
+
+___
+
+
+
+
+
+Publisher: Make sure to reset asset widget when hidden and reshown #4886
+
+Make sure to reset asset widget when hidden and reshown. Without this the asset list would never refresh in the set asset widget when changing context on an existing instance and thus would not show new assets from after the first time launching that widget.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Fix nested model instances. #4852
+
+Fix nested model instance under review instance, where data collection was not including "Display Lights" and "Focal Length".
+
+
+___
+
+
+
+
+
+Maya: Make default namespace naming backwards compatible #4873
+
+Namespaces of loaded references are now _by default_ back to what they were before #4511
+
+
+___
+
+
+
+
+
+Nuke: Legacy convertor skips deprecation warnings #4846
+
+Nuke legacy convertor was triggering deprecated function which is causing a lot of logs which slows down whole process. Changed the convertor to skip all nodes without `AVALON_TAB` to avoid the warnings.
+
+
+___
+
+
+
+
+
+3dsmax: move startup script logic to hook #4849
+
+Startup script for OpenPype was interfering with Open Last Workfile feature. Moving this loggic from simple command line argument in the Settings to pre-launch hook is solving the order of command line arguments and making both features work.
+
+
+___
+
+
+
+
+
+Maya: Don't change time slider ranges in `get_frame_range` #4858
+
+Don't change time slider ranges in `get_frame_range`
+
+
+___
+
+
+
+
+
+Maya: Looks - calculate hash for tx texture #4878
+
+Texture hash is calculated for textures used in published look and it is used as key in dictionary. In recent changes, this hash is not calculated for TX files, resulting in `None` value as key in dictionary, crashing publishing. This PR is adding texture hash for TX files to solve that issue.
+
+
+___
+
+
+
+
+
+Houdini: Collect `currentFile` context data separate from workfile instance #4883
+
+Fix publishing without an active workfile instance due to missing `currentFile` data.Now collect `currentFile` into context in houdini through context plugin no matter the active instances.
+
+
+___
+
+
+
+
+
+Nuke: fixed broken slate workflow once published on deadline #4887
+
+Slate workflow is now working as expected and Validate Sequence Frames is not raising the once slate frame is included.
+
+
+___
+
+
+
+
+
+Add fps as instance.data in collect review in Houdini. #4888
+
+fix the bug of failing to publish extract review in HoudiniOriginal error:
+```python
+ File "OpenPype\build\exe.win-amd64-3.9\openpype\plugins\publish\extract_review.py", line 516, in prepare_temp_data
+ "fps": float(instance.data["fps"]),
+KeyError: 'fps'
+```
+
+
+___
+
+
+
+
+
+TrayPublisher: Fill missing data for instances with review #4891
+
+Fill required data to instance in traypublisher if instance has review family. The data are required by ExtractReview and it would be complicated to do proper fix at this moment! The collector does for review instances what did https://github.com/ynput/OpenPype/pull/4383
+
+
+___
+
+
+
+
+
+Publisher: Keep track about current context and fix context selection widget #4892
+
+Change selected context to current context on reset. Fix bug when context widget is re-enabled.
+
+
+___
+
+
+
+
+
+Scene inventory: Model refresh fix with cherry picking #4895
+
+Fix cherry pick issue in scene inventory.
+
+
+___
+
+
+
+
+
+Nuke: Pre-render and missing review flag on instance causing crash #4897
+
+If instance created in nuke was missing `review` flag, collector crashed.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+After Effects: fix handles KeyError #4727
+
+Sometimes when publishing with AE (we only saw this error on AE 2023), we got a KeyError for the handles in the "Collect Workfile" step. So I did get the handles from the context if ther's no handles in the asset entity.
+
+
+___
+
+
+
+
+
+
## [3.15.4](https://github.com/ynput/OpenPype/tree/3.15.4)
diff --git a/openpype/cli.py b/openpype/cli.py
index a650a9fdcc..54af42920d 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -415,11 +415,12 @@ def repack_version(directory):
@main.command()
@click.option("--project", help="Project name")
@click.option(
- "--dirpath", help="Directory where package is stored", default=None
-)
-def pack_project(project, dirpath):
+ "--dirpath", help="Directory where package is stored", default=None)
+@click.option(
+ "--dbonly", help="Store only Database data", default=False, is_flag=True)
+def pack_project(project, dirpath, dbonly):
"""Create a package of project with all files and database dump."""
- PypeCommands().pack_project(project, dirpath)
+ PypeCommands().pack_project(project, dirpath, dbonly)
@main.command()
@@ -427,9 +428,11 @@ def pack_project(project, dirpath):
@click.option(
"--root", help="Replace root which was stored in project", default=None
)
-def unpack_project(zipfile, root):
+@click.option(
+ "--dbonly", help="Store only Database data", default=False, is_flag=True)
+def unpack_project(zipfile, root, dbonly):
"""Create a package of project with all files and database dump."""
- PypeCommands().unpack_project(zipfile, root)
+ PypeCommands().unpack_project(zipfile, root, dbonly)
@main.command()
diff --git a/openpype/client/entities.py b/openpype/client/entities.py
index 376157d210..8004dc3019 100644
--- a/openpype/client/entities.py
+++ b/openpype/client/entities.py
@@ -69,6 +69,19 @@ def convert_ids(in_ids):
def get_projects(active=True, inactive=False, fields=None):
+ """Yield all project entity documents.
+
+ Args:
+ active (Optional[bool]): Include active projects. Defaults to True.
+ inactive (Optional[bool]): Include inactive projects.
+ Defaults to False.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
+
+ Yields:
+ dict: Project entity data which can be reduced to specified 'fields'.
+ None is returned if project with specified filters was not found.
+ """
mongodb = get_project_database()
for project_name in mongodb.collection_names():
if project_name in ("system.indexes",):
@@ -81,6 +94,20 @@ def get_projects(active=True, inactive=False, fields=None):
def get_project(project_name, active=True, inactive=True, fields=None):
+ """Return project entity document by project name.
+
+ Args:
+ project_name (str): Name of project.
+ active (Optional[bool]): Allow active project. Defaults to True.
+ inactive (Optional[bool]): Allow inactive project. Defaults to True.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
+
+ Returns:
+ Union[Dict, None]: Project entity data which can be reduced to
+ specified 'fields'. None is returned if project with specified
+ filters was not found.
+ """
# Skip if both are disabled
if not active and not inactive:
return None
@@ -124,17 +151,18 @@ def get_whole_project(project_name):
def get_asset_by_id(project_name, asset_id, fields=None):
- """Receive asset data by it's id.
+ """Receive asset data by its id.
Args:
project_name (str): Name of project where to look for queried entities.
asset_id (Union[str, ObjectId]): Asset's id.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- dict: Asset entity data.
- None: Asset was not found by id.
+ Union[Dict, None]: Asset entity data which can be reduced to
+ specified 'fields'. None is returned if asset with specified
+ filters was not found.
"""
asset_id = convert_id(asset_id)
@@ -147,17 +175,18 @@ def get_asset_by_id(project_name, asset_id, fields=None):
def get_asset_by_name(project_name, asset_name, fields=None):
- """Receive asset data by it's name.
+ """Receive asset data by its name.
Args:
project_name (str): Name of project where to look for queried entities.
asset_name (str): Asset's name.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- dict: Asset entity data.
- None: Asset was not found by name.
+ Union[Dict, None]: Asset entity data which can be reduced to
+ specified 'fields'. None is returned if asset with specified
+ filters was not found.
"""
if not asset_name:
@@ -195,8 +224,8 @@ def _get_assets(
parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids.
standard (bool): Query standard assets (type 'asset').
archived (bool): Query archived assets (type 'archived_asset').
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Query cursor as iterable which returns asset documents matching
@@ -261,8 +290,8 @@ def get_assets(
asset_names (Iterable[str]): Name assets that should be found.
parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids.
archived (bool): Add also archived assets.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Query cursor as iterable which returns asset documents matching
@@ -300,8 +329,8 @@ def get_archived_assets(
be found.
asset_names (Iterable[str]): Name assets that should be found.
parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Query cursor as iterable which returns asset documents matching
@@ -356,17 +385,18 @@ def get_asset_ids_with_subsets(project_name, asset_ids=None):
def get_subset_by_id(project_name, subset_id, fields=None):
- """Single subset entity data by it's id.
+ """Single subset entity data by its id.
Args:
project_name (str): Name of project where to look for queried entities.
subset_id (Union[str, ObjectId]): Id of subset which should be found.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If subset with specified filters was not found.
- Dict: Subset document which can be reduced to specified 'fields'.
+ Union[Dict, None]: Subset entity data which can be reduced to
+ specified 'fields'. None is returned if subset with specified
+ filters was not found.
"""
subset_id = convert_id(subset_id)
@@ -379,20 +409,19 @@ def get_subset_by_id(project_name, subset_id, fields=None):
def get_subset_by_name(project_name, subset_name, asset_id, fields=None):
- """Single subset entity data by it's name and it's version id.
+ """Single subset entity data by its name and its version id.
Args:
project_name (str): Name of project where to look for queried entities.
subset_name (str): Name of subset.
asset_id (Union[str, ObjectId]): Id of parent asset.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- Union[None, Dict[str, Any]]: None if subset with specified filters was
- not found or dict subset document which can be reduced to
- specified 'fields'.
-
+ Union[Dict, None]: Subset entity data which can be reduced to
+ specified 'fields'. None is returned if subset with specified
+ filters was not found.
"""
if not subset_name:
return None
@@ -434,8 +463,8 @@ def get_subsets(
names_by_asset_ids (dict[ObjectId, List[str]]): Complex filtering
using asset ids and list of subset names under the asset.
archived (bool): Look for archived subsets too.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Iterable cursor yielding all matching subsets.
@@ -520,17 +549,18 @@ def get_subset_families(project_name, subset_ids=None):
def get_version_by_id(project_name, version_id, fields=None):
- """Single version entity data by it's id.
+ """Single version entity data by its id.
Args:
project_name (str): Name of project where to look for queried entities.
version_id (Union[str, ObjectId]): Id of version which should be found.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If version with specified filters was not found.
- Dict: Version document which can be reduced to specified 'fields'.
+ Union[Dict, None]: Version entity data which can be reduced to
+ specified 'fields'. None is returned if version with specified
+ filters was not found.
"""
version_id = convert_id(version_id)
@@ -546,18 +576,19 @@ def get_version_by_id(project_name, version_id, fields=None):
def get_version_by_name(project_name, version, subset_id, fields=None):
- """Single version entity data by it's name and subset id.
+ """Single version entity data by its name and subset id.
Args:
project_name (str): Name of project where to look for queried entities.
- version (int): name of version entity (it's version).
+ version (int): name of version entity (its version).
subset_id (Union[str, ObjectId]): Id of version which should be found.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If version with specified filters was not found.
- Dict: Version document which can be reduced to specified 'fields'.
+ Union[Dict, None]: Version entity data which can be reduced to
+ specified 'fields'. None is returned if version with specified
+ filters was not found.
"""
subset_id = convert_id(subset_id)
@@ -574,7 +605,7 @@ def get_version_by_name(project_name, version, subset_id, fields=None):
def version_is_latest(project_name, version_id):
- """Is version the latest from it's subset.
+ """Is version the latest from its subset.
Note:
Hero versions are considered as latest.
@@ -680,8 +711,8 @@ def get_versions(
versions (Iterable[int]): Version names (as integers).
Filter ignored if 'None' is passed.
hero (bool): Look also for hero versions.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Iterable cursor yielding all matching versions.
@@ -705,12 +736,13 @@ def get_hero_version_by_subset_id(project_name, subset_id, fields=None):
project_name (str): Name of project where to look for queried entities.
subset_id (Union[str, ObjectId]): Subset id under which
is hero version.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If hero version for passed subset id does not exists.
- Dict: Hero version entity data.
+ Union[Dict, None]: Hero version entity data which can be reduced to
+ specified 'fields'. None is returned if hero version with specified
+ filters was not found.
"""
subset_id = convert_id(subset_id)
@@ -730,17 +762,18 @@ def get_hero_version_by_subset_id(project_name, subset_id, fields=None):
def get_hero_version_by_id(project_name, version_id, fields=None):
- """Hero version by it's id.
+ """Hero version by its id.
Args:
project_name (str): Name of project where to look for queried entities.
version_id (Union[str, ObjectId]): Hero version id.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If hero version with passed id was not found.
- Dict: Hero version entity data.
+ Union[Dict, None]: Hero version entity data which can be reduced to
+ specified 'fields'. None is returned if hero version with specified
+ filters was not found.
"""
version_id = convert_id(version_id)
@@ -773,8 +806,8 @@ def get_hero_versions(
should look for hero versions. Filter ignored if 'None' is passed.
version_ids (Iterable[Union[str, ObjectId]]): Hero version ids. Filter
ignored if 'None' is passed.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor|list: Iterable yielding hero versions matching passed filters.
@@ -801,8 +834,8 @@ def get_output_link_versions(project_name, version_id, fields=None):
project_name (str): Name of project where to look for queried entities.
version_id (Union[str, ObjectId]): Version id which can be used
as input link for other versions.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Iterable: Iterable cursor yielding versions that are used as input
@@ -828,8 +861,8 @@ def get_last_versions(project_name, subset_ids, fields=None):
Args:
project_name (str): Name of project where to look for queried entities.
subset_ids (Iterable[Union[str, ObjectId]]): List of subset ids.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
dict[ObjectId, int]: Key is subset id and value is last version name.
@@ -913,12 +946,13 @@ def get_last_version_by_subset_id(project_name, subset_id, fields=None):
Args:
project_name (str): Name of project where to look for queried entities.
subset_id (Union[str, ObjectId]): Id of version which should be found.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If version with specified filters was not found.
- Dict: Version document which can be reduced to specified 'fields'.
+ Union[Dict, None]: Version entity data which can be reduced to
+ specified 'fields'. None is returned if version with specified
+ filters was not found.
"""
subset_id = convert_id(subset_id)
@@ -945,12 +979,13 @@ def get_last_version_by_subset_name(
asset_id (Union[str, ObjectId]): Asset id which is parent of passed
subset name.
asset_name (str): Asset name which is parent of passed subset name.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If version with specified filters was not found.
- Dict: Version document which can be reduced to specified 'fields'.
+ Union[Dict, None]: Version entity data which can be reduced to
+ specified 'fields'. None is returned if version with specified
+ filters was not found.
"""
if not asset_id and not asset_name:
@@ -972,18 +1007,18 @@ def get_last_version_by_subset_name(
def get_representation_by_id(project_name, representation_id, fields=None):
- """Representation entity data by it's id.
+ """Representation entity data by its id.
Args:
project_name (str): Name of project where to look for queried entities.
representation_id (Union[str, ObjectId]): Representation id.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If representation with specified filters was not found.
- Dict: Representation entity data which can be reduced
- to specified 'fields'.
+ Union[Dict, None]: Representation entity data which can be reduced to
+ specified 'fields'. None is returned if representation with
+ specified filters was not found.
"""
if not representation_id:
@@ -1004,19 +1039,19 @@ def get_representation_by_id(project_name, representation_id, fields=None):
def get_representation_by_name(
project_name, representation_name, version_id, fields=None
):
- """Representation entity data by it's name and it's version id.
+ """Representation entity data by its name and its version id.
Args:
project_name (str): Name of project where to look for queried entities.
representation_name (str): Representation name.
version_id (Union[str, ObjectId]): Id of parent version entity.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If representation with specified filters was not found.
- Dict: Representation entity data which can be reduced
- to specified 'fields'.
+ Union[dict[str, Any], None]: Representation entity data which can be
+ reduced to specified 'fields'. None is returned if representation
+ with specified filters was not found.
"""
version_id = convert_id(version_id)
@@ -1202,8 +1237,8 @@ def get_representations(
names_by_version_ids (dict[ObjectId, list[str]]): Complex filtering
using version ids and list of names under the version.
archived (bool): Output will also contain archived representations.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Iterable cursor yielding all matching representations.
@@ -1247,8 +1282,8 @@ def get_archived_representations(
representation context fields.
names_by_version_ids (dict[ObjectId, List[str]]): Complex filtering
using version ids and list of names under the version.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
Cursor: Iterable cursor yielding all matching representations.
@@ -1377,8 +1412,8 @@ def get_thumbnail_id_from_source(project_name, src_type, src_id):
src_id (Union[str, ObjectId]): Id of source entity.
Returns:
- ObjectId: Thumbnail id assigned to entity.
- None: If Source entity does not have any thumbnail id assigned.
+ Union[ObjectId, None]: Thumbnail id assigned to entity. If Source
+ entity does not have any thumbnail id assigned.
"""
if not src_type or not src_id:
@@ -1397,14 +1432,14 @@ def get_thumbnails(project_name, thumbnail_ids, fields=None):
"""Receive thumbnails entity data.
Thumbnail entity can be used to receive binary content of thumbnail based
- on it's content and ThumbnailResolvers.
+ on its content and ThumbnailResolvers.
Args:
project_name (str): Name of project where to look for queried entities.
thumbnail_ids (Iterable[Union[str, ObjectId]]): Ids of thumbnail
entities.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
cursor: Cursor of queried documents.
@@ -1429,12 +1464,13 @@ def get_thumbnail(project_name, thumbnail_id, fields=None):
Args:
project_name (str): Name of project where to look for queried entities.
thumbnail_id (Union[str, ObjectId]): Id of thumbnail entity.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
Returns:
- None: If thumbnail with specified id was not found.
- Dict: Thumbnail entity data which can be reduced to specified 'fields'.
+ Union[Dict, None]: Thumbnail entity data which can be reduced to
+ specified 'fields'.None is returned if thumbnail with specified
+ filters was not found.
"""
if not thumbnail_id:
@@ -1458,8 +1494,13 @@ def get_workfile_info(
project_name (str): Name of project where to look for queried entities.
asset_id (Union[str, ObjectId]): Id of asset entity.
task_name (str): Task name on asset.
- fields (Iterable[str]): Fields that should be returned. All fields are
- returned if 'None' is passed.
+ fields (Optional[Iterable[str]]): Fields that should be returned. All
+ fields are returned if 'None' is passed.
+
+ Returns:
+ Union[Dict, None]: Workfile entity data which can be reduced to
+ specified 'fields'.None is returned if workfile with specified
+ filters was not found.
"""
if not asset_id or not task_name or not filename:
diff --git a/openpype/client/mongo.py b/openpype/client/mongo.py
index 72acbc5476..251041c028 100644
--- a/openpype/client/mongo.py
+++ b/openpype/client/mongo.py
@@ -5,6 +5,12 @@ import logging
import pymongo
import certifi
+from bson.json_util import (
+ loads,
+ dumps,
+ CANONICAL_JSON_OPTIONS
+)
+
if sys.version_info[0] == 2:
from urlparse import urlparse, parse_qs
else:
@@ -15,6 +21,49 @@ class MongoEnvNotSet(Exception):
pass
+def documents_to_json(docs):
+ """Convert documents to json string.
+
+ Args:
+ Union[list[dict[str, Any]], dict[str, Any]]: Document/s to convert to
+ json string.
+
+ Returns:
+ str: Json string with mongo documents.
+ """
+
+ return dumps(docs, json_options=CANONICAL_JSON_OPTIONS)
+
+
+def load_json_file(filepath):
+ """Load mongo documents from a json file.
+
+ Args:
+ filepath (str): Path to a json file.
+
+ Returns:
+ Union[dict[str, Any], list[dict[str, Any]]]: Loaded content from a
+ json file.
+ """
+
+ if not os.path.exists(filepath):
+ raise ValueError("Path {} was not found".format(filepath))
+
+ with open(filepath, "r") as stream:
+ content = stream.read()
+ return loads("".join(content))
+
+
+def get_project_database_name():
+ """Name of database name where projects are available.
+
+ Returns:
+ str: Name of database name where projects are.
+ """
+
+ return os.environ.get("AVALON_DB") or "avalon"
+
+
def _decompose_url(url):
"""Decompose mongo url to basic components.
@@ -210,12 +259,102 @@ class OpenPypeMongoConnection:
return mongo_client
-def get_project_database():
- db_name = os.environ.get("AVALON_DB") or "avalon"
- return OpenPypeMongoConnection.get_mongo_client()[db_name]
+# ------ Helper Mongo functions ------
+# Functions can be helpful with custom tools to backup/restore mongo state.
+# Not meant as API functionality that should be used in production codebase!
+def get_collection_documents(database_name, collection_name, as_json=False):
+ """Query all documents from a collection.
+
+ Args:
+ database_name (str): Name of database where to look for collection.
+ collection_name (str): Name of collection where to look for collection.
+ as_json (Optional[bool]): Output should be a json string.
+ Default: 'False'
+
+ Returns:
+ Union[list[dict[str, Any]], str]: Queried documents.
+ """
+
+ client = OpenPypeMongoConnection.get_mongo_client()
+ output = list(client[database_name][collection_name].find({}))
+ if as_json:
+ output = documents_to_json(output)
+ return output
-def get_project_connection(project_name):
+def store_collection(filepath, database_name, collection_name):
+ """Store collection documents to a json file.
+
+ Args:
+ filepath (str): Path to a json file where documents will be stored.
+ database_name (str): Name of database where to look for collection.
+ collection_name (str): Name of collection to store.
+ """
+
+ # Make sure directory for output file exists
+ dirpath = os.path.dirname(filepath)
+ if not os.path.isdir(dirpath):
+ os.makedirs(dirpath)
+
+ content = get_collection_documents(database_name, collection_name, True)
+ with open(filepath, "w") as stream:
+ stream.write(content)
+
+
+def replace_collection_documents(docs, database_name, collection_name):
+ """Replace all documents in a collection with passed documents.
+
+ Warnings:
+ All existing documents in collection will be removed if there are any.
+
+ Args:
+ docs (list[dict[str, Any]]): New documents.
+ database_name (str): Name of database where to look for collection.
+ collection_name (str): Name of collection where new documents are
+ uploaded.
+ """
+
+ client = OpenPypeMongoConnection.get_mongo_client()
+ database = client[database_name]
+ if collection_name in database.list_collection_names():
+ database.drop_collection(collection_name)
+ col = database[collection_name]
+ col.insert_many(docs)
+
+
+def restore_collection(filepath, database_name, collection_name):
+ """Restore/replace collection from a json filepath.
+
+ Warnings:
+ All existing documents in collection will be removed if there are any.
+
+ Args:
+ filepath (str): Path to a json with documents.
+ database_name (str): Name of database where to look for collection.
+ collection_name (str): Name of collection where new documents are
+ uploaded.
+ """
+
+ docs = load_json_file(filepath)
+ replace_collection_documents(docs, database_name, collection_name)
+
+
+def get_project_database(database_name=None):
+ """Database object where project collections are.
+
+ Args:
+ database_name (Optional[str]): Custom name of database.
+
+ Returns:
+ pymongo.database.Database: Collection related to passed project.
+ """
+
+ if not database_name:
+ database_name = get_project_database_name()
+ return OpenPypeMongoConnection.get_mongo_client()[database_name]
+
+
+def get_project_connection(project_name, database_name=None):
"""Direct access to mongo collection.
We're trying to avoid using direct access to mongo. This should be used
@@ -223,13 +362,83 @@ def get_project_connection(project_name):
api calls for that.
Args:
- project_name(str): Project name for which collection should be
+ project_name (str): Project name for which collection should be
returned.
+ database_name (Optional[str]): Custom name of database.
Returns:
- pymongo.Collection: Collection realated to passed project.
+ pymongo.collection.Collection: Collection related to passed project.
"""
if not project_name:
raise ValueError("Invalid project name {}".format(str(project_name)))
- return get_project_database()[project_name]
+ return get_project_database(database_name)[project_name]
+
+
+def get_project_documents(project_name, database_name=None):
+ """Query all documents from project collection.
+
+ Args:
+ project_name (str): Name of project.
+ database_name (Optional[str]): Name of mongo database where to look for
+ project.
+
+ Returns:
+ list[dict[str, Any]]: Documents in project collection.
+ """
+
+ if not database_name:
+ database_name = get_project_database_name()
+ return get_collection_documents(database_name, project_name)
+
+
+def store_project_documents(project_name, filepath, database_name=None):
+ """Store project documents to a file as json string.
+
+ Args:
+ project_name (str): Name of project to store.
+ filepath (str): Path to a json file where output will be stored.
+ database_name (Optional[str]): Name of mongo database where to look for
+ project.
+ """
+
+ if not database_name:
+ database_name = get_project_database_name()
+
+ store_collection(filepath, database_name, project_name)
+
+
+def replace_project_documents(project_name, docs, database_name=None):
+ """Replace documents in mongo with passed documents.
+
+ Warnings:
+ Existing project collection is removed if exists in mongo.
+
+ Args:
+ project_name (str): Name of project.
+ docs (list[dict[str, Any]]): Documents to restore.
+ database_name (Optional[str]): Name of mongo database where project
+ collection will be created.
+ """
+
+ if not database_name:
+ database_name = get_project_database_name()
+ replace_collection_documents(docs, database_name, project_name)
+
+
+def restore_project_documents(project_name, filepath, database_name=None):
+ """Replace documents in mongo with passed documents.
+
+ Warnings:
+ Existing project collection is removed if exists in mongo.
+
+ Args:
+ project_name (str): Name of project.
+ filepath (str): File to json file with project documents.
+ database_name (Optional[str]): Name of mongo database where project
+ collection will be created.
+ """
+
+ if not database_name:
+ database_name = get_project_database_name()
+ restore_collection(filepath, database_name, project_name)
diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py
index 2a35db869a..c54acbc203 100644
--- a/openpype/hooks/pre_add_last_workfile_arg.py
+++ b/openpype/hooks/pre_add_last_workfile_arg.py
@@ -25,6 +25,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"blender",
"photoshop",
"tvpaint",
+ "substancepainter",
"aftereffects"
]
diff --git a/openpype/hooks/pre_host_set_ocio.py b/openpype/hooks/pre_host_set_ocio.py
new file mode 100644
index 0000000000..3620d88db6
--- /dev/null
+++ b/openpype/hooks/pre_host_set_ocio.py
@@ -0,0 +1,37 @@
+from openpype.lib import PreLaunchHook
+
+from openpype.pipeline.colorspace import get_imageio_config
+from openpype.pipeline.template_data import get_template_data
+
+
+class PreLaunchHostSetOCIO(PreLaunchHook):
+ """Set OCIO environment for the host"""
+
+ order = 0
+ app_groups = ["substancepainter"]
+
+ def execute(self):
+ """Hook entry method."""
+
+ anatomy_data = get_template_data(
+ project_doc=self.data["project_doc"],
+ asset_doc=self.data["asset_doc"],
+ task_name=self.data["task_name"],
+ host_name=self.host_name,
+ system_settings=self.data["system_settings"]
+ )
+
+ ocio_config = get_imageio_config(
+ project_name=self.data["project_doc"]["name"],
+ host_name=self.host_name,
+ project_settings=self.data["project_settings"],
+ anatomy_data=anatomy_data,
+ anatomy=self.data["anatomy"]
+ )
+
+ if ocio_config:
+ ocio_path = ocio_config["path"]
+ self.log.info(f"Setting OCIO config path: {ocio_path}")
+ self.launch_context.env["OCIO"] = ocio_path
+ else:
+ self.log.debug("OCIO not set or enabled")
diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py
index c20b0ec51b..171d7053ce 100644
--- a/openpype/hosts/aftereffects/plugins/create/create_render.py
+++ b/openpype/hosts/aftereffects/plugins/create/create_render.py
@@ -26,12 +26,9 @@ class RenderCreator(Creator):
create_allow_context_change = True
- def __init__(self, project_settings, *args, **kwargs):
- super(RenderCreator, self).__init__(project_settings, *args, **kwargs)
- self._default_variants = (project_settings["aftereffects"]
- ["create"]
- ["RenderCreator"]
- ["defaults"])
+ # Settings
+ default_variants = []
+ mark_for_review = True
def create(self, subset_name_from_ui, data, pre_create_data):
stub = api.get_stub() # only after After Effects is up
@@ -82,28 +79,40 @@ class RenderCreator(Creator):
use_farm = pre_create_data["farm"]
new_instance.creator_attributes["farm"] = use_farm
+ review = pre_create_data["mark_for_review"]
+ new_instance.creator_attributes["mark_for_review"] = review
+
api.get_stub().imprint(new_instance.id,
new_instance.data_to_store())
self._add_instance_to_context(new_instance)
stub.rename_item(comp.id, subset_name)
- def get_default_variants(self):
- return self._default_variants
-
- def get_instance_attr_defs(self):
- return [BoolDef("farm", label="Render on farm")]
-
def get_pre_create_attr_defs(self):
output = [
BoolDef("use_selection", default=True, label="Use selection"),
BoolDef("use_composition_name",
label="Use composition name in subset"),
UISeparatorDef(),
- BoolDef("farm", label="Render on farm")
+ BoolDef("farm", label="Render on farm"),
+ BoolDef(
+ "mark_for_review",
+ label="Review",
+ default=self.mark_for_review
+ )
]
return output
+ def get_instance_attr_defs(self):
+ return [
+ BoolDef("farm", label="Render on farm"),
+ BoolDef(
+ "mark_for_review",
+ label="Review",
+ default=False
+ )
+ ]
+
def get_icon(self):
return resources.get_openpype_splash_filepath()
@@ -143,6 +152,13 @@ class RenderCreator(Creator):
api.get_stub().rename_item(comp_id,
new_comp_name)
+ def apply_settings(self, project_settings, system_settings):
+ plugin_settings = (
+ project_settings["aftereffects"]["create"]["RenderCreator"]
+ )
+
+ self.mark_for_review = plugin_settings["mark_for_review"]
+
def get_detail_description(self):
return """Creator for Render instances
@@ -201,4 +217,7 @@ class RenderCreator(Creator):
instance_data["creator_attributes"] = {"farm": is_old_farm}
instance_data["family"] = self.family
+ if instance_data["creator_attributes"].get("mark_for_review") is None:
+ instance_data["creator_attributes"]["mark_for_review"] = True
+
return instance_data
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
index 6153a426cf..b01b707246 100644
--- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
@@ -88,10 +88,11 @@ class CollectAERender(publish.AbstractCollectRender):
raise ValueError("No file extension set in Render Queue")
render_item = render_q[0]
+ instance_families = inst.data.get("families", [])
subset_name = inst.data["subset"]
instance = AERenderInstance(
family="render",
- families=inst.data.get("families", []),
+ families=instance_families,
version=version,
time="",
source=current_file,
@@ -109,6 +110,7 @@ class CollectAERender(publish.AbstractCollectRender):
tileRendering=False,
tilesX=0,
tilesY=0,
+ review="review" in instance_families,
frameStart=frame_start,
frameEnd=frame_end,
frameStep=1,
@@ -139,6 +141,9 @@ class CollectAERender(publish.AbstractCollectRender):
instance.toBeRenderedOn = "deadline"
instance.renderer = "aerender"
instance.farm = True # to skip integrate
+ if "review" in instance.families:
+ # to skip ExtractReview locally
+ instance.families.remove("review")
instances.append(instance)
instances_to_remove.append(inst)
@@ -218,15 +223,4 @@ class CollectAERender(publish.AbstractCollectRender):
if fam not in instance.families:
instance.families.append(fam)
- settings = get_project_settings(os.getenv("AVALON_PROJECT"))
- reviewable_subset_filter = (settings["deadline"]
- ["publish"]
- ["ProcessSubmittedJobOnFarm"]
- ["aov_filter"].get(self.hosts[0]))
- for aov_pattern in reviewable_subset_filter:
- if re.match(aov_pattern, instance.subset):
- instance.families.append("review")
- instance.review = True
- break
-
return instance
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_review.py b/openpype/hosts/aftereffects/plugins/publish/collect_review.py
new file mode 100644
index 0000000000..a933b9fed2
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_review.py
@@ -0,0 +1,25 @@
+"""
+Requires:
+ None
+
+Provides:
+ instance -> family ("review")
+"""
+import pyblish.api
+
+
+class CollectReview(pyblish.api.ContextPlugin):
+ """Add review to families if instance created with 'mark_for_review' flag
+ """
+ label = "Collect Review"
+ hosts = ["aftereffects"]
+ order = pyblish.api.CollectorOrder + 0.1
+
+ def process(self, context):
+ for instance in context:
+ creator_attributes = instance.data.get("creator_attributes") or {}
+ if (
+ creator_attributes.get("mark_for_review")
+ and "review" not in instance.data["families"]
+ ):
+ instance.data["families"].append("review")
diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
index d535329eb4..c70aa41dbe 100644
--- a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py
@@ -66,33 +66,9 @@ class ExtractLocalRender(publish.Extractor):
first_repre = not representations
if instance.data["review"] and first_repre:
repre_data["tags"] = ["review"]
+ thumbnail_path = os.path.join(staging_dir, files[0])
+ instance.data["thumbnailSource"] = thumbnail_path
representations.append(repre_data)
instance.data["representations"] = representations
-
- ffmpeg_path = get_ffmpeg_tool_path("ffmpeg")
- # Generate thumbnail.
- thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg")
-
- args = [
- ffmpeg_path, "-y",
- "-i", first_file_path,
- "-vf", "scale=300:-1",
- "-vframes", "1",
- thumbnail_path
- ]
- self.log.debug("Thumbnail args:: {}".format(args))
- try:
- output = run_subprocess(args)
- except TypeError:
- self.log.warning("Error in creating thumbnail")
- six.reraise(*sys.exc_info())
-
- instance.data["representations"].append({
- "name": "thumbnail",
- "ext": "jpg",
- "files": os.path.basename(thumbnail_path),
- "stagingDir": staging_dir,
- "tags": ["thumbnail"]
- })
diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py
index 56085b0a06..cedc4029fa 100644
--- a/openpype/hosts/fusion/plugins/create/create_saver.py
+++ b/openpype/hosts/fusion/plugins/create/create_saver.py
@@ -1,7 +1,5 @@
import os
-import qtawesome
-
from openpype.hosts.fusion.api import (
get_current_comp,
comp_lock_and_undo_chunk,
@@ -28,6 +26,7 @@ class CreateSaver(Creator):
family = "render"
default_variants = ["Main", "Mask"]
description = "Fusion Saver to generate image sequence"
+ icon = "fa5.eye"
instance_attributes = ["reviewable"]
@@ -89,9 +88,6 @@ class CreateSaver(Creator):
self._add_instance_to_context(created_instance)
- def get_icon(self):
- return qtawesome.icon("fa.eye", color="white")
-
def update_instances(self, update_list):
for created_inst, _changes in update_list:
new_data = created_inst.data_to_store()
diff --git a/openpype/hosts/fusion/plugins/create/create_workfile.py b/openpype/hosts/fusion/plugins/create/create_workfile.py
index 0bb3a0d3d4..40721ea88a 100644
--- a/openpype/hosts/fusion/plugins/create/create_workfile.py
+++ b/openpype/hosts/fusion/plugins/create/create_workfile.py
@@ -1,5 +1,3 @@
-import qtawesome
-
from openpype.hosts.fusion.api import (
get_current_comp
)
@@ -15,6 +13,7 @@ class FusionWorkfileCreator(AutoCreator):
identifier = "workfile"
family = "workfile"
label = "Workfile"
+ icon = "fa5.file"
default_variant = "Main"
@@ -104,6 +103,3 @@ class FusionWorkfileCreator(AutoCreator):
existing_instance["asset"] = asset_name
existing_instance["task"] = task_name
existing_instance["subset"] = subset_name
-
- def get_icon(self):
- return qtawesome.icon("fa.file-o", color="white")
diff --git a/openpype/hosts/fusion/plugins/publish/increment_current_file.py b/openpype/hosts/fusion/plugins/publish/increment_current_file.py
index 42891446f7..08a65bf52d 100644
--- a/openpype/hosts/fusion/plugins/publish/increment_current_file.py
+++ b/openpype/hosts/fusion/plugins/publish/increment_current_file.py
@@ -1,29 +1,39 @@
import pyblish.api
+from openpype.pipeline import OptionalPyblishPluginMixin
+from openpype.pipeline import KnownPublishError
-class FusionIncrementCurrentFile(pyblish.api.ContextPlugin):
+
+class FusionIncrementCurrentFile(
+ pyblish.api.ContextPlugin, OptionalPyblishPluginMixin
+):
"""Increment the current file.
Saves the current file with an increased version number.
"""
- label = "Increment current file"
+ label = "Increment workfile version"
order = pyblish.api.IntegratorOrder + 9.0
hosts = ["fusion"]
- families = ["workfile"]
optional = True
def process(self, context):
+ if not self.is_active(context.data):
+ return
from openpype.lib import version_up
from openpype.pipeline.publish import get_errored_plugins_from_context
errored_plugins = get_errored_plugins_from_context(context)
- if any(plugin.__name__ == "FusionSubmitDeadline"
- for plugin in errored_plugins):
- raise RuntimeError("Skipping incrementing current file because "
- "submission to render farm failed.")
+ if any(
+ plugin.__name__ == "FusionSubmitDeadline"
+ for plugin in errored_plugins
+ ):
+ raise KnownPublishError(
+ "Skipping incrementing current file because "
+ "submission to render farm failed."
+ )
comp = context.data.get("currentComp")
assert comp, "Must have comp"
diff --git a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
index db2c4f0dd9..6908889eb4 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
@@ -1,12 +1,17 @@
import pyblish.api
-from openpype.pipeline.publish import RepairAction
-from openpype.pipeline import PublishValidationError
+from openpype.pipeline import (
+ publish,
+ OptionalPyblishPluginMixin,
+ PublishValidationError,
+)
from openpype.hosts.fusion.api.action import SelectInvalidAction
-class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
+class ValidateBackgroundDepth(
+ pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
+):
"""Validate if all Background tool are set to float32 bit"""
order = pyblish.api.ValidatorOrder
@@ -15,11 +20,10 @@ class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
families = ["render"]
optional = True
- actions = [SelectInvalidAction, RepairAction]
+ actions = [SelectInvalidAction, publish.RepairAction]
@classmethod
def get_invalid(cls, instance):
-
context = instance.context
comp = context.data.get("currentComp")
assert comp, "Must have Comp object"
@@ -31,12 +35,16 @@ class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
return [i for i in backgrounds if i.GetInput("Depth") != 4.0]
def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
"Found {} Backgrounds tools which"
" are not set to float32".format(len(invalid)),
- title=self.label)
+ title=self.label,
+ )
@classmethod
def repair(cls, instance):
diff --git a/openpype/hosts/houdini/api/action.py b/openpype/hosts/houdini/api/action.py
new file mode 100644
index 0000000000..27e8ce55bb
--- /dev/null
+++ b/openpype/hosts/houdini/api/action.py
@@ -0,0 +1,46 @@
+import pyblish.api
+import hou
+
+from openpype.pipeline.publish import get_errored_instances_from_context
+
+
+class SelectInvalidAction(pyblish.api.Action):
+ """Select invalid nodes in Maya when plug-in failed.
+
+ To retrieve the invalid nodes this assumes a static `get_invalid()`
+ method is available on the plugin.
+
+ """
+ label = "Select invalid"
+ on = "failed" # This action is only available on a failed plug-in
+ icon = "search" # Icon from Awesome Icon
+
+ def process(self, context, plugin):
+
+ errored_instances = get_errored_instances_from_context(context)
+
+ # Apply pyblish.logic to get the instances for the plug-in
+ instances = pyblish.api.instances_by_plugin(errored_instances, plugin)
+
+ # Get the invalid nodes for the plug-ins
+ self.log.info("Finding invalid nodes..")
+ invalid = list()
+ for instance in instances:
+ invalid_nodes = plugin.get_invalid(instance)
+ if invalid_nodes:
+ if isinstance(invalid_nodes, (list, tuple)):
+ invalid.extend(invalid_nodes)
+ else:
+ self.log.warning("Plug-in returned to be invalid, "
+ "but has no selectable nodes.")
+
+ hou.clearAllSelected()
+ if invalid:
+ self.log.info("Selecting invalid nodes: {}".format(
+ ", ".join(node.path() for node in invalid)
+ ))
+ for node in invalid:
+ node.setSelected(True)
+ node.setCurrent(True)
+ else:
+ self.log.info("No invalid nodes found.")
diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py
index 3638e14296..7c6122cffe 100644
--- a/openpype/hosts/houdini/api/creator_node_shelves.py
+++ b/openpype/hosts/houdini/api/creator_node_shelves.py
@@ -12,26 +12,43 @@ import tempfile
import logging
import os
+from openpype.client import get_asset_by_name
from openpype.pipeline import registered_host
from openpype.pipeline.create import CreateContext
from openpype.resources import get_openpype_icon_filepath
import hou
+import stateutils
+import soptoolutils
+import loptoolutils
+import cop2toolutils
+
log = logging.getLogger(__name__)
+CATEGORY_GENERIC_TOOL = {
+ hou.sopNodeTypeCategory(): soptoolutils.genericTool,
+ hou.cop2NodeTypeCategory(): cop2toolutils.genericTool,
+ hou.lopNodeTypeCategory(): loptoolutils.genericTool
+}
+
+
CREATE_SCRIPT = """
from openpype.hosts.houdini.api.creator_node_shelves import create_interactive
-create_interactive("{identifier}")
+create_interactive("{identifier}", **kwargs)
"""
-def create_interactive(creator_identifier):
+def create_interactive(creator_identifier, **kwargs):
"""Create a Creator using its identifier interactively.
This is used by the generated shelf tools as callback when a user selects
the creator from the node tab search menu.
+ The `kwargs` should be what Houdini passes to the tool create scripts
+ context. For more information see:
+ https://www.sidefx.com/docs/houdini/hom/tool_script.html#arguments
+
Args:
creator_identifier (str): The creator identifier of the Creator plugin
to create.
@@ -58,6 +75,33 @@ def create_interactive(creator_identifier):
host = registered_host()
context = CreateContext(host)
+ creator = context.manual_creators.get(creator_identifier)
+ if not creator:
+ raise RuntimeError("Invalid creator identifier: "
+ "{}".format(creator_identifier))
+
+ # TODO: Once more elaborate unique create behavior should exist per Creator
+ # instead of per network editor area then we should move this from here
+ # to a method on the Creators for which this could be the default
+ # implementation.
+ pane = stateutils.activePane(kwargs)
+ if isinstance(pane, hou.NetworkEditor):
+ pwd = pane.pwd()
+ subset_name = creator.get_subset_name(
+ variant=variant,
+ task_name=context.get_current_task_name(),
+ asset_doc=get_asset_by_name(
+ project_name=context.get_current_project_name(),
+ asset_name=context.get_current_asset_name()
+ ),
+ project_name=context.get_current_project_name(),
+ host_name=context.host_name
+ )
+
+ tool_fn = CATEGORY_GENERIC_TOOL.get(pwd.childTypeCategory())
+ if tool_fn is not None:
+ out_null = tool_fn(kwargs, "null")
+ out_null.setName("OUT_{}".format(subset_name), unique_name=True)
before = context.instances_by_id.copy()
@@ -135,12 +179,20 @@ def install():
log.debug("Writing OpenPype Creator nodes to shelf: {}".format(filepath))
tools = []
+
with shelves_change_block():
for identifier, creator in create_context.manual_creators.items():
- # TODO: Allow the creator plug-in itself to override the categories
- # for where they are shown, by e.g. defining
- # `Creator.get_network_categories()`
+ # Allow the creator plug-in itself to override the categories
+ # for where they are shown with `Creator.get_network_categories()`
+ if not hasattr(creator, "get_network_categories"):
+ log.debug("Creator {} has no `get_network_categories` method "
+ "and will not be added to TAB search.")
+ continue
+
+ network_categories = creator.get_network_categories()
+ if not network_categories:
+ continue
key = "openpype_create.{}".format(identifier)
log.debug(f"Registering {key}")
@@ -153,17 +205,13 @@ def install():
creator.label
),
"help_url": None,
- "network_categories": [
- hou.ropNodeTypeCategory(),
- hou.sopNodeTypeCategory()
- ],
+ "network_categories": network_categories,
"viewer_categories": [],
"cop_viewer_categories": [],
"network_op_type": None,
"viewer_op_type": None,
"locations": ["OpenPype"]
}
-
label = "Create {}".format(creator.label)
tool = hou.shelves.tool(key)
if tool:
diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py
index 61274e6028..b8b8fefb52 100644
--- a/openpype/hosts/houdini/api/pipeline.py
+++ b/openpype/hosts/houdini/api/pipeline.py
@@ -81,7 +81,13 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
# TODO: make sure this doesn't trigger when
# opening with last workfile.
_set_context_settings()
- shelves.generate_shelves()
+
+ if not IS_HEADLESS:
+ import hdefereval # noqa, hdefereval is only available in ui mode
+ # Defer generation of shelves due to issue on Windows where shelf
+ # initialization during start up delays Houdini UI by minutes
+ # making it extremely slow to launch.
+ hdefereval.executeDeferred(shelves.generate_shelves)
if not IS_HEADLESS:
import hdefereval # noqa, hdefereval is only available in ui mode
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index 340a7f0770..1e7eaa7e22 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -276,3 +276,19 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
color = hou.Color((0.616, 0.871, 0.769))
node.setUserData('nodeshape', shape)
node.setColor(color)
+
+ def get_network_categories(self):
+ """Return in which network view type this creator should show.
+
+ The node type categories returned here will be used to define where
+ the creator will show up in the TAB search for nodes in Houdini's
+ Network View.
+
+ This can be overridden in inherited classes to define where that
+ particular Creator should be visible in the TAB search.
+
+ Returns:
+ list: List of houdini node type categories
+
+ """
+ return [hou.ropNodeTypeCategory()]
diff --git a/openpype/hosts/houdini/plugins/create/create_alembic_camera.py b/openpype/hosts/houdini/plugins/create/create_alembic_camera.py
index fec64eb4a1..8c8a5e9eed 100644
--- a/openpype/hosts/houdini/plugins/create/create_alembic_camera.py
+++ b/openpype/hosts/houdini/plugins/create/create_alembic_camera.py
@@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance, CreatorError
+import hou
+
class CreateAlembicCamera(plugin.HoudiniCreator):
"""Single baked camera from Alembic ROP."""
@@ -47,3 +49,9 @@ class CreateAlembicCamera(plugin.HoudiniCreator):
self.lock_parameters(instance_node, to_lock)
instance_node.parm("trange").set(1)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.objNodeTypeCategory()
+ ]
diff --git a/openpype/hosts/houdini/plugins/create/create_composite.py b/openpype/hosts/houdini/plugins/create/create_composite.py
index 45af2b0630..9d4f7969bb 100644
--- a/openpype/hosts/houdini/plugins/create/create_composite.py
+++ b/openpype/hosts/houdini/plugins/create/create_composite.py
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating composite sequences."""
from openpype.hosts.houdini.api import plugin
-from openpype.pipeline import CreatedInstance
+from openpype.pipeline import CreatedInstance, CreatorError
+
+import hou
class CreateCompositeSequence(plugin.HoudiniCreator):
@@ -35,8 +37,20 @@ class CreateCompositeSequence(plugin.HoudiniCreator):
"copoutput": filepath
}
+ if self.selected_nodes:
+ if len(self.selected_nodes) > 1:
+ raise CreatorError("More than one item selected.")
+ path = self.selected_nodes[0].path()
+ parms["coppath"] = path
+
instance_node.setParms(parms)
# Lock any parameters in this list
to_lock = ["prim_to_detail_pattern"]
self.lock_parameters(instance_node, to_lock)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.cop2NodeTypeCategory()
+ ]
diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py
index 6b6b277422..df74070fee 100644
--- a/openpype/hosts/houdini/plugins/create/create_pointcache.py
+++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py
@@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
+import hou
+
class CreatePointCache(plugin.HoudiniCreator):
"""Alembic ROP to pointcache"""
@@ -49,3 +51,9 @@ class CreatePointCache(plugin.HoudiniCreator):
# Lock any parameters in this list
to_lock = ["prim_to_detail_pattern"]
self.lock_parameters(instance_node, to_lock)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.sopNodeTypeCategory()
+ ]
diff --git a/openpype/hosts/houdini/plugins/create/create_usd.py b/openpype/hosts/houdini/plugins/create/create_usd.py
index 51ed8237c5..e05d254863 100644
--- a/openpype/hosts/houdini/plugins/create/create_usd.py
+++ b/openpype/hosts/houdini/plugins/create/create_usd.py
@@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
+import hou
+
class CreateUSD(plugin.HoudiniCreator):
"""Universal Scene Description"""
@@ -13,7 +15,6 @@ class CreateUSD(plugin.HoudiniCreator):
enabled = False
def create(self, subset_name, instance_data, pre_create_data):
- import hou # noqa
instance_data.pop("active", None)
instance_data.update({"node_type": "usd"})
@@ -43,3 +44,9 @@ class CreateUSD(plugin.HoudiniCreator):
"id",
]
self.lock_parameters(instance_node, to_lock)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.lopNodeTypeCategory()
+ ]
diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py
index 1a5011745f..c015cebd49 100644
--- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py
+++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py
@@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
+import hou
+
class CreateVDBCache(plugin.HoudiniCreator):
"""OpenVDB from Geometry ROP"""
@@ -34,3 +36,9 @@ class CreateVDBCache(plugin.HoudiniCreator):
parms["soppath"] = self.selected_nodes[0].path()
instance_node.setParms(parms)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.sopNodeTypeCategory()
+ ]
diff --git a/openpype/hosts/houdini/plugins/create/create_workfile.py b/openpype/hosts/houdini/plugins/create/create_workfile.py
index 0c6d840810..1a8537adcd 100644
--- a/openpype/hosts/houdini/plugins/create/create_workfile.py
+++ b/openpype/hosts/houdini/plugins/create/create_workfile.py
@@ -14,7 +14,7 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator):
identifier = "io.openpype.creators.houdini.workfile"
label = "Workfile"
family = "workfile"
- icon = "document"
+ icon = "fa5.file"
default_variant = "Main"
diff --git a/openpype/hosts/houdini/plugins/publish/collect_current_file.py b/openpype/hosts/houdini/plugins/publish/collect_current_file.py
index caf679f98b..7b55778803 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_current_file.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_current_file.py
@@ -4,15 +4,14 @@ import hou
import pyblish.api
-class CollectHoudiniCurrentFile(pyblish.api.InstancePlugin):
+class CollectHoudiniCurrentFile(pyblish.api.ContextPlugin):
"""Inject the current working file into context"""
- order = pyblish.api.CollectorOrder - 0.01
+ order = pyblish.api.CollectorOrder - 0.1
label = "Houdini Current File"
hosts = ["houdini"]
- families = ["workfile"]
- def process(self, instance):
+ def process(self, context):
"""Inject the current working file"""
current_file = hou.hipFile.path()
@@ -34,26 +33,5 @@ class CollectHoudiniCurrentFile(pyblish.api.InstancePlugin):
"saved correctly."
)
- instance.context.data["currentFile"] = current_file
-
- folder, file = os.path.split(current_file)
- filename, ext = os.path.splitext(file)
-
- instance.data.update({
- "setMembers": [current_file],
- "frameStart": instance.context.data['frameStart'],
- "frameEnd": instance.context.data['frameEnd'],
- "handleStart": instance.context.data['handleStart'],
- "handleEnd": instance.context.data['handleEnd']
- })
-
- instance.data['representations'] = [{
- 'name': ext.lstrip("."),
- 'ext': ext.lstrip("."),
- 'files': file,
- "stagingDir": folder,
- }]
-
- self.log.info('Collected instance: {}'.format(file))
- self.log.info('Scene path: {}'.format(current_file))
- self.log.info('staging Dir: {}'.format(folder))
+ context.data["currentFile"] = current_file
+ self.log.info('Current workfile path: {}'.format(current_file))
diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py
index e321dcb2fa..3efb75e66c 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_review_data.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py
@@ -17,6 +17,10 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
# which isn't the actual frame range that this instance renders.
instance.data["handleStart"] = 0
instance.data["handleEnd"] = 0
+ instance.data["fps"] = instance.context.data["fps"]
+
+ # Enable ftrack functionality
+ instance.data.setdefault("families", []).append('ftrack')
# Get the camera from the rop node to collect the focal length
ropnode_path = instance.data["instance_node"]
@@ -25,8 +29,9 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
camera_path = ropnode.parm("camera").eval()
camera_node = hou.node(camera_path)
if not camera_node:
- raise RuntimeError("No valid camera node found on review node: "
- "{}".format(camera_path))
+ self.log.warning("No valid camera node found on review node: "
+ "{}".format(camera_path))
+ return
# Collect focal length.
focal_length_parm = camera_node.parm("focal")
@@ -48,5 +53,3 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
# Store focal length in `burninDataMembers`
burnin_members = instance.data.setdefault("burninDataMembers", {})
burnin_members["focalLength"] = focal_length
-
- instance.data.setdefault("families", []).append('ftrack')
diff --git a/openpype/hosts/houdini/plugins/publish/collect_workfile.py b/openpype/hosts/houdini/plugins/publish/collect_workfile.py
new file mode 100644
index 0000000000..a6e94ec29e
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/collect_workfile.py
@@ -0,0 +1,36 @@
+import os
+
+import pyblish.api
+
+
+class CollectWorkfile(pyblish.api.InstancePlugin):
+ """Inject workfile representation into instance"""
+
+ order = pyblish.api.CollectorOrder - 0.01
+ label = "Houdini Workfile Data"
+ hosts = ["houdini"]
+ families = ["workfile"]
+
+ def process(self, instance):
+
+ current_file = instance.context.data["currentFile"]
+ folder, file = os.path.split(current_file)
+ filename, ext = os.path.splitext(file)
+
+ instance.data.update({
+ "setMembers": [current_file],
+ "frameStart": instance.context.data['frameStart'],
+ "frameEnd": instance.context.data['frameEnd'],
+ "handleStart": instance.context.data['handleStart'],
+ "handleEnd": instance.context.data['handleEnd']
+ })
+
+ instance.data['representations'] = [{
+ 'name': ext.lstrip("."),
+ 'ext': ext.lstrip("."),
+ 'files': file,
+ "stagingDir": folder,
+ }]
+
+ self.log.info('Collected instance: {}'.format(file))
+ self.log.info('staging Dir: {}'.format(folder))
diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py
index c26d0813a6..6c36dec5f5 100644
--- a/openpype/hosts/houdini/plugins/publish/extract_opengl.py
+++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py
@@ -2,27 +2,20 @@ import os
import pyblish.api
-from openpype.pipeline import (
- publish,
- OptionalPyblishPluginMixin
-)
+from openpype.pipeline import publish
from openpype.hosts.houdini.api.lib import render_rop
import hou
-class ExtractOpenGL(publish.Extractor,
- OptionalPyblishPluginMixin):
+class ExtractOpenGL(publish.Extractor):
order = pyblish.api.ExtractorOrder - 0.01
label = "Extract OpenGL"
families = ["review"]
hosts = ["houdini"]
- optional = True
def process(self, instance):
- if not self.is_active(instance.data):
- return
ropnode = hou.node(instance.data.get("instance_node"))
output = ropnode.evalParm("picture")
diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml
deleted file mode 100644
index 0f92560bf7..0000000000
--- a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-Scene setting
-
-## Invalid input node
-
-VDB input must have the same number of VDBs, points, primitives and vertices as output.
-
-
-
-### __Detailed Info__ (optional)
-
-A VDB is an inherited type of Prim, holds the following data:
- - Primitives: 1
- - Points: 1
- - Vertices: 1
- - VDBs: 1
-
-
-
\ No newline at end of file
diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml
new file mode 100644
index 0000000000..eb83bfffe3
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml
@@ -0,0 +1,28 @@
+
+
+
+Invalid VDB
+
+## Invalid VDB output
+
+All primitives of the output geometry must be VDBs, no other primitive
+types are allowed. That means that regardless of the amount of VDBs in the
+geometry it will have an equal amount of VDBs, points, primitives and
+vertices since each VDB primitive is one point, one vertex and one VDB.
+
+This validation only checks the geometry on the first frame of the export
+frame range.
+
+
+
+
+
+### Detailed Info
+
+ROP node `{rop_path}` is set to export SOP path `{sop_path}`.
+
+{message}
+
+
+
+
\ No newline at end of file
diff --git a/openpype/hosts/houdini/plugins/publish/validate_scene_review.py b/openpype/hosts/houdini/plugins/publish/validate_scene_review.py
index ade01d4b90..a44b7e1597 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_scene_review.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_scene_review.py
@@ -16,15 +16,19 @@ class ValidateSceneReview(pyblish.api.InstancePlugin):
label = "Scene Setting for review"
def process(self, instance):
- invalid = self.get_invalid_scene_path(instance)
report = []
- if invalid:
- report.append(
- "Scene path does not exist: '%s'" % invalid[0],
- )
+ instance_node = hou.node(instance.data.get("instance_node"))
- invalid = self.get_invalid_resolution(instance)
+ invalid = self.get_invalid_scene_path(instance_node)
+ if invalid:
+ report.append(invalid)
+
+ invalid = self.get_invalid_camera_path(instance_node)
+ if invalid:
+ report.append(invalid)
+
+ invalid = self.get_invalid_resolution(instance_node)
if invalid:
report.extend(invalid)
@@ -33,26 +37,36 @@ class ValidateSceneReview(pyblish.api.InstancePlugin):
"\n\n".join(report),
title=self.label)
- def get_invalid_scene_path(self, instance):
-
- node = hou.node(instance.data.get("instance_node"))
- scene_path_parm = node.parm("scenepath")
+ def get_invalid_scene_path(self, rop_node):
+ scene_path_parm = rop_node.parm("scenepath")
scene_path_node = scene_path_parm.evalAsNode()
if not scene_path_node:
- return [scene_path_parm.evalAsString()]
+ path = scene_path_parm.evalAsString()
+ return "Scene path does not exist: '{}'".format(path)
- def get_invalid_resolution(self, instance):
- node = hou.node(instance.data.get("instance_node"))
+ def get_invalid_camera_path(self, rop_node):
+ camera_path_parm = rop_node.parm("camera")
+ camera_node = camera_path_parm.evalAsNode()
+ path = camera_path_parm.evalAsString()
+ if not camera_node:
+ return "Camera path does not exist: '{}'".format(path)
+ type_name = camera_node.type().name()
+ if type_name != "cam":
+ return "Camera path is not a camera: '{}' (type: {})".format(
+ path, type_name
+ )
+
+ def get_invalid_resolution(self, rop_node):
# The resolution setting is only used when Override Camera Resolution
# is enabled. So we skip validation if it is disabled.
- override = node.parm("tres").eval()
+ override = rop_node.parm("tres").eval()
if not override:
return
invalid = []
- res_width = node.parm("res1").eval()
- res_height = node.parm("res2").eval()
+ res_width = rop_node.parm("res1").eval()
+ res_height = rop_node.parm("res2").eval()
if res_width == 0:
invalid.append("Override Resolution width is set to zero.")
if res_height == 0:
diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py
deleted file mode 100644
index 1f9ccc9c42..0000000000
--- a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# -*- coding: utf-8 -*-
-import pyblish.api
-from openpype.pipeline import (
- PublishValidationError
-)
-
-
-class ValidateVDBInputNode(pyblish.api.InstancePlugin):
- """Validate that the node connected to the output node is of type VDB.
-
- Regardless of the amount of VDBs create the output will need to have an
- equal amount of VDBs, points, primitives and vertices
-
- A VDB is an inherited type of Prim, holds the following data:
- - Primitives: 1
- - Points: 1
- - Vertices: 1
- - VDBs: 1
-
- """
-
- order = pyblish.api.ValidatorOrder + 0.1
- families = ["vdbcache"]
- hosts = ["houdini"]
- label = "Validate Input Node (VDB)"
-
- def process(self, instance):
- invalid = self.get_invalid(instance)
- if invalid:
- raise PublishValidationError(
- self,
- "Node connected to the output node is not of type VDB",
- title=self.label
- )
-
- @classmethod
- def get_invalid(cls, instance):
-
- node = instance.data["output_node"]
-
- prims = node.geometry().prims()
- nr_of_prims = len(prims)
-
- nr_of_points = len(node.geometry().points())
- if nr_of_points != nr_of_prims:
- cls.log.error("The number of primitives and points do not match")
- return [instance]
-
- for prim in prims:
- if prim.numVertices() != 1:
- cls.log.error("Found primitive with more than 1 vertex!")
- return [instance]
diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
index f9f88b3bf9..674782179c 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py
@@ -1,14 +1,73 @@
# -*- coding: utf-8 -*-
+import contextlib
+
import pyblish.api
import hou
-from openpype.pipeline import PublishValidationError
+
+from openpype.pipeline import PublishXmlValidationError
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+
+
+def group_consecutive_numbers(nums):
+ """
+ Args:
+ nums (list): List of sorted integer numbers.
+
+ Yields:
+ str: Group ranges as {start}-{end} if more than one number in the range
+ else it yields {end}
+
+ """
+ start = None
+ end = None
+
+ def _result(a, b):
+ if a == b:
+ return "{}".format(a)
+ else:
+ return "{}-{}".format(a, b)
+
+ for num in nums:
+ if start is None:
+ start = num
+ end = num
+ elif num == end + 1:
+ end = num
+ else:
+ yield _result(start, end)
+ start = num
+ end = num
+ if start is not None:
+ yield _result(start, end)
+
+
+@contextlib.contextmanager
+def update_mode_context(mode):
+ original = hou.updateModeSetting()
+ try:
+ hou.setUpdateMode(mode)
+ yield
+ finally:
+ hou.setUpdateMode(original)
+
+
+def get_geometry_at_frame(sop_node, frame, force=True):
+ """Return geometry at frame but force a cooked value."""
+ with update_mode_context(hou.updateMode.AutoUpdate):
+ sop_node.cook(force=force, frame_range=(frame, frame))
+ return sop_node.geometryAtFrame(frame)
class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
"""Validate that the node connected to the output node is of type VDB.
- Regardless of the amount of VDBs create the output will need to have an
- equal amount of VDBs, points, primitives and vertices
+ All primitives of the output geometry must be VDBs, no other primitive
+ types are allowed. That means that regardless of the amount of VDBs in the
+ geometry it will have an equal amount of VDBs, points, primitives and
+ vertices since each VDB primitive is one point, one vertex and one VDB.
+
+ This validation only checks the geometry on the first frame of the export
+ frame range for optimization purposes.
A VDB is an inherited type of Prim, holds the following data:
- Primitives: 1
@@ -22,54 +81,95 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
families = ["vdbcache"]
hosts = ["houdini"]
label = "Validate Output Node (VDB)"
+ actions = [SelectInvalidAction]
def process(self, instance):
- invalid = self.get_invalid(instance)
- if invalid:
- raise PublishValidationError(
- "Node connected to the output node is not" " of type VDB!",
- title=self.label
+ invalid_nodes, message = self.get_invalid_with_message(instance)
+ if invalid_nodes:
+
+ # instance_node is str, but output_node is hou.Node so we convert
+ output = instance.data.get("output_node")
+ output_path = output.path() if output else None
+
+ raise PublishXmlValidationError(
+ self,
+ "Invalid VDB content: {}".format(message),
+ formatting_data={
+ "message": message,
+ "rop_path": instance.data.get("instance_node"),
+ "sop_path": output_path
+ }
)
@classmethod
- def get_invalid(cls, instance):
+ def get_invalid_with_message(cls, instance):
- node = instance.data["output_node"]
+ node = instance.data.get("output_node")
if node is None:
- cls.log.error(
+ instance_node = instance.data.get("instance_node")
+ error = (
"SOP path is not correctly set on "
- "ROP node '%s'." % instance.data.get("instance_node")
+ "ROP node `{}`.".format(instance_node)
)
- return [instance]
+ return [hou.node(instance_node), error]
frame = instance.data.get("frameStart", 0)
- geometry = node.geometryAtFrame(frame)
+ geometry = get_geometry_at_frame(node, frame)
if geometry is None:
# No geometry data on this node, maybe the node hasn't cooked?
- cls.log.error(
- "SOP node has no geometry data. "
- "Is it cooked? %s" % node.path()
+ error = (
+ "SOP node `{}` has no geometry data. "
+ "Was it unable to cook?".format(node.path())
)
- return [node]
+ return [node, error]
- prims = geometry.prims()
- nr_of_prims = len(prims)
+ num_prims = geometry.intrinsicValue("primitivecount")
+ num_points = geometry.intrinsicValue("pointcount")
+ if num_prims == 0 and num_points == 0:
+ # Since we are only checking the first frame it doesn't mean there
+ # won't be VDB prims in a few frames. As such we'll assume for now
+ # the user knows what he or she is doing
+ cls.log.warning(
+ "SOP node `{}` has no primitives on start frame {}. "
+ "Validation is skipped and it is assumed elsewhere in the "
+ "frame range VDB prims and only VDB prims will exist."
+ "".format(node.path(), int(frame))
+ )
+ return [None, None]
- # All primitives must be hou.VDB
- invalid_prim = False
- for prim in prims:
- if not isinstance(prim, hou.VDB):
- cls.log.error("Found non-VDB primitive: %s" % prim)
- invalid_prim = True
- if invalid_prim:
- return [instance]
+ num_vdb_prims = geometry.countPrimType(hou.primType.VDB)
+ cls.log.debug("Detected {} VDB primitives".format(num_vdb_prims))
+ if num_prims != num_vdb_prims:
+ # There's at least one primitive that is not a VDB.
+ # Search them and report them to the artist.
+ prims = geometry.prims()
+ invalid_prims = [prim for prim in prims
+ if not isinstance(prim, hou.VDB)]
+ if invalid_prims:
+ # Log prim numbers as consecutive ranges so logging isn't very
+ # slow for large number of primitives
+ error = (
+ "Found non-VDB primitives for `{}`. "
+ "Primitive indices {} are not VDB primitives.".format(
+ node.path(),
+ ", ".join(group_consecutive_numbers(
+ prim.number() for prim in invalid_prims
+ ))
+ )
+ )
+ return [node, error]
- nr_of_points = len(geometry.points())
- if nr_of_points != nr_of_prims:
- cls.log.error("The number of primitives and points do not match")
- return [instance]
+ if num_points != num_vdb_prims:
+ # We have points unrelated to the VDB primitives.
+ error = (
+ "The number of primitives and points do not match in '{}'. "
+ "This likely means you have unconnected points, which we do "
+ "not allow in the VDB output.".format(node.path()))
+ return [node, error]
- for prim in prims:
- if prim.numVertices() != 1:
- cls.log.error("Found primitive with more than 1 vertex!")
- return [instance]
+ return [None, None]
+
+ @classmethod
+ def get_invalid(cls, instance):
+ nodes, _ = cls.get_invalid_with_message(instance)
+ return nodes
diff --git a/openpype/hosts/max/plugins/create/create_model.py b/openpype/hosts/max/plugins/create/create_model.py
new file mode 100644
index 0000000000..e7ae3af9db
--- /dev/null
+++ b/openpype/hosts/max/plugins/create/create_model.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+"""Creator plugin for model."""
+from openpype.hosts.max.api import plugin
+from openpype.pipeline import CreatedInstance
+
+
+class CreateModel(plugin.MaxCreator):
+ identifier = "io.openpype.creators.max.model"
+ label = "Model"
+ family = "model"
+ icon = "gear"
+
+ def create(self, subset_name, instance_data, pre_create_data):
+ from pymxs import runtime as rt
+ instance = super(CreateModel, self).create(
+ subset_name,
+ instance_data,
+ pre_create_data) # type: CreatedInstance
+ container = rt.getNodeByName(instance.data.get("instance_node"))
+ # TODO: Disable "Add to Containers?" Panel
+ # parent the selected cameras into the container
+ sel_obj = None
+ if self.selected_nodes:
+ sel_obj = list(self.selected_nodes)
+ for obj in sel_obj:
+ obj.parent = container
+ # for additional work on the node:
+ # instance_node = rt.getNodeByName(instance.get("instance_node"))
diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py
index 460f4822a6..4b19cd671f 100644
--- a/openpype/hosts/max/plugins/load/load_max_scene.py
+++ b/openpype/hosts/max/plugins/load/load_max_scene.py
@@ -10,7 +10,9 @@ class MaxSceneLoader(load.LoaderPlugin):
"""Max Scene Loader"""
families = ["camera",
- "maxScene"]
+ "maxScene",
+ "model"]
+
representations = ["max"]
order = -8
icon = "code-fork"
diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py
new file mode 100644
index 0000000000..95ee014e07
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_model.py
@@ -0,0 +1,109 @@
+
+import os
+from openpype.pipeline import (
+ load, get_representation_path
+)
+from openpype.hosts.max.api.pipeline import containerise
+from openpype.hosts.max.api import lib
+from openpype.hosts.max.api.lib import maintained_selection
+
+
+class ModelAbcLoader(load.LoaderPlugin):
+ """Loading model with the Alembic loader."""
+
+ families = ["model"]
+ label = "Load Model(Alembic)"
+ representations = ["abc"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ from pymxs import runtime as rt
+
+ file_path = os.path.normpath(self.fname)
+
+ abc_before = {
+ c for c in rt.rootNode.Children
+ if rt.classOf(c) == rt.AlembicContainer
+ }
+
+ abc_import_cmd = (f"""
+AlembicImport.ImportToRoot = false
+AlembicImport.CustomAttributes = true
+AlembicImport.UVs = true
+AlembicImport.VertexColors = true
+
+importFile @"{file_path}" #noPrompt
+ """)
+
+ self.log.debug(f"Executing command: {abc_import_cmd}")
+ rt.execute(abc_import_cmd)
+
+ abc_after = {
+ c for c in rt.rootNode.Children
+ if rt.classOf(c) == rt.AlembicContainer
+ }
+
+ # This should yield new AlembicContainer node
+ abc_containers = abc_after.difference(abc_before)
+
+ if len(abc_containers) != 1:
+ self.log.error("Something failed when loading.")
+
+ abc_container = abc_containers.pop()
+
+ return containerise(
+ name, [abc_container], context, loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ from pymxs import runtime as rt
+ path = get_representation_path(representation)
+ node = rt.getNodeByName(container["instance_node"])
+ rt.select(node.Children)
+
+ for alembic in rt.selection:
+ abc = rt.getNodeByName(alembic.name)
+ rt.select(abc.Children)
+ for abc_con in rt.selection:
+ container = rt.getNodeByName(abc_con.name)
+ container.source = path
+ rt.select(container.Children)
+ for abc_obj in rt.selection:
+ alembic_obj = rt.getNodeByName(abc_obj.name)
+ alembic_obj.source = path
+
+ with maintained_selection():
+ rt.select(node)
+
+ lib.imprint(container["instance_node"], {
+ "representation": str(representation["_id"])
+ })
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ from pymxs import runtime as rt
+
+ node = rt.getNodeByName(container["instance_node"])
+ rt.delete(node)
+
+ @staticmethod
+ def get_container_children(parent, type_name):
+ from pymxs import runtime as rt
+
+ def list_children(node):
+ children = []
+ for c in node.Children:
+ children.append(c)
+ children += list_children(c)
+ return children
+
+ filtered = []
+ for child in list_children(parent):
+ class_type = str(rt.classOf(child.baseObject))
+ if class_type == type_name:
+ filtered.append(child)
+
+ return filtered
diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py
new file mode 100644
index 0000000000..88b8f1ed89
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_model_fbx.py
@@ -0,0 +1,77 @@
+import os
+from openpype.pipeline import (
+ load,
+ get_representation_path
+)
+from openpype.hosts.max.api.pipeline import containerise
+from openpype.hosts.max.api import lib
+from openpype.hosts.max.api.lib import maintained_selection
+
+
+class FbxModelLoader(load.LoaderPlugin):
+ """Fbx Model Loader"""
+
+ families = ["model"]
+ representations = ["fbx"]
+ order = -9
+ icon = "code-fork"
+ color = "white"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ from pymxs import runtime as rt
+
+ filepath = os.path.normpath(self.fname)
+
+ fbx_import_cmd = (
+ f"""
+
+FBXImporterSetParam "Animation" false
+FBXImporterSetParam "Cameras" false
+FBXImporterSetParam "AxisConversionMethod" true
+FbxExporterSetParam "UpAxis" "Y"
+FbxExporterSetParam "Preserveinstances" true
+
+importFile @"{filepath}" #noPrompt using:FBXIMP
+ """)
+
+ self.log.debug(f"Executing command: {fbx_import_cmd}")
+ rt.execute(fbx_import_cmd)
+
+ asset = rt.getNodeByName(f"{name}")
+
+ return containerise(
+ name, [asset], context, loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ from pymxs import runtime as rt
+
+ path = get_representation_path(representation)
+ node = rt.getNodeByName(container["instance_node"])
+ rt.select(node.Children)
+ fbx_reimport_cmd = (
+ f"""
+FBXImporterSetParam "Animation" false
+FBXImporterSetParam "Cameras" false
+FBXImporterSetParam "AxisConversionMethod" true
+FbxExporterSetParam "UpAxis" "Y"
+FbxExporterSetParam "Preserveinstances" true
+
+importFile @"{path}" #noPrompt using:FBXIMP
+ """)
+ rt.execute(fbx_reimport_cmd)
+
+ with maintained_selection():
+ rt.select(node)
+
+ lib.imprint(container["instance_node"], {
+ "representation": str(representation["_id"])
+ })
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ from pymxs import runtime as rt
+
+ node = rt.getNodeByName(container["instance_node"])
+ rt.delete(node)
diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py
new file mode 100644
index 0000000000..c55e462111
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_model_obj.py
@@ -0,0 +1,68 @@
+import os
+from openpype.pipeline import (
+ load,
+ get_representation_path
+)
+from openpype.hosts.max.api.pipeline import containerise
+from openpype.hosts.max.api import lib
+from openpype.hosts.max.api.lib import maintained_selection
+
+
+class ObjLoader(load.LoaderPlugin):
+ """Obj Loader"""
+
+ families = ["model"]
+ representations = ["obj"]
+ order = -9
+ icon = "code-fork"
+ color = "white"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ from pymxs import runtime as rt
+
+ filepath = os.path.normpath(self.fname)
+ self.log.debug(f"Executing command to import..")
+
+ rt.execute(f'importFile @"{filepath}" #noPrompt using:ObjImp')
+ # create "missing" container for obj import
+ container = rt.container()
+ container.name = f"{name}"
+
+ # get current selection
+ for selection in rt.getCurrentSelection():
+ selection.Parent = container
+
+ asset = rt.getNodeByName(f"{name}")
+
+ return containerise(
+ name, [asset], context, loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ from pymxs import runtime as rt
+
+ path = get_representation_path(representation)
+ node_name = container["instance_node"]
+ node = rt.getNodeByName(node_name)
+
+ instance_name, _ = node_name.split("_")
+ container = rt.getNodeByName(instance_name)
+ for n in container.Children:
+ rt.delete(n)
+
+ rt.execute(f'importFile @"{path}" #noPrompt using:ObjImp')
+ # get current selection
+ for selection in rt.getCurrentSelection():
+ selection.Parent = container
+
+ with maintained_selection():
+ rt.select(node)
+
+ lib.imprint(node_name, {
+ "representation": str(representation["_id"])
+ })
+
+ def remove(self, container):
+ from pymxs import runtime as rt
+
+ node = rt.getNodeByName(container["instance_node"])
+ rt.delete(node)
diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py
new file mode 100644
index 0000000000..143f91f40b
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_model_usd.py
@@ -0,0 +1,78 @@
+import os
+from openpype.pipeline import (
+ load, get_representation_path
+)
+from openpype.hosts.max.api.pipeline import containerise
+from openpype.hosts.max.api import lib
+from openpype.hosts.max.api.lib import maintained_selection
+
+
+class ModelUSDLoader(load.LoaderPlugin):
+ """Loading model with the USD loader."""
+
+ families = ["model"]
+ label = "Load Model(USD)"
+ representations = ["usda"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ from pymxs import runtime as rt
+ # asset_filepath
+ filepath = os.path.normpath(self.fname)
+ import_options = rt.USDImporter.CreateOptions()
+ base_filename = os.path.basename(filepath)
+ filename, ext = os.path.splitext(base_filename)
+ log_filepath = filepath.replace(ext, "txt")
+
+ rt.LogPath = log_filepath
+ rt.LogLevel = rt.name('info')
+ rt.USDImporter.importFile(filepath,
+ importOptions=import_options)
+
+ asset = rt.getNodeByName(f"{name}")
+
+ return containerise(
+ name, [asset], context, loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ from pymxs import runtime as rt
+
+ path = get_representation_path(representation)
+ node_name = container["instance_node"]
+ node = rt.getNodeByName(node_name)
+ for n in node.Children:
+ for r in n.Children:
+ rt.delete(r)
+ rt.delete(n)
+ instance_name, _ = node_name.split("_")
+
+ import_options = rt.USDImporter.CreateOptions()
+ base_filename = os.path.basename(path)
+ _, ext = os.path.splitext(base_filename)
+ log_filepath = path.replace(ext, "txt")
+
+ rt.LogPath = log_filepath
+ rt.LogLevel = rt.name('info')
+ rt.USDImporter.importFile(path,
+ importOptions=import_options)
+
+ asset = rt.getNodeByName(f"{instance_name}")
+ asset.Parent = node
+
+ with maintained_selection():
+ rt.select(node)
+
+ lib.imprint(node_name, {
+ "representation": str(representation["_id"])
+ })
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ from pymxs import runtime as rt
+
+ node = rt.getNodeByName(container["instance_node"])
+ rt.delete(node)
diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py
index f7a72ece25..b3e12adc7b 100644
--- a/openpype/hosts/max/plugins/load/load_pointcache.py
+++ b/openpype/hosts/max/plugins/load/load_pointcache.py
@@ -15,8 +15,7 @@ from openpype.hosts.max.api import lib
class AbcLoader(load.LoaderPlugin):
"""Alembic loader."""
- families = ["model",
- "camera",
+ families = ["camera",
"animation",
"pointcache"]
label = "Load Alembic"
diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
index 969f87be48..c14fcdbd0b 100644
--- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
+++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py
@@ -21,7 +21,8 @@ class ExtractMaxSceneRaw(publish.Extractor,
label = "Extract Max Scene (Raw)"
hosts = ["max"]
families = ["camera",
- "maxScene"]
+ "maxScene",
+ "model"]
optional = True
def process(self, instance):
diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py
new file mode 100644
index 0000000000..710ad5f97d
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/extract_model.py
@@ -0,0 +1,74 @@
+import os
+import pyblish.api
+from openpype.pipeline import (
+ publish,
+ OptionalPyblishPluginMixin
+)
+from pymxs import runtime as rt
+from openpype.hosts.max.api import (
+ maintained_selection,
+ get_all_children
+)
+
+
+class ExtractModel(publish.Extractor,
+ OptionalPyblishPluginMixin):
+ """
+ Extract Geometry in Alembic Format
+ """
+
+ order = pyblish.api.ExtractorOrder - 0.1
+ label = "Extract Geometry (Alembic)"
+ hosts = ["max"]
+ families = ["model"]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
+ container = instance.data["instance_node"]
+
+ self.log.info("Extracting Geometry ...")
+
+ stagingdir = self.staging_dir(instance)
+ filename = "{name}.abc".format(**instance.data)
+ filepath = os.path.join(stagingdir, filename)
+
+ # We run the render
+ self.log.info("Writing alembic '%s' to '%s'" % (filename,
+ stagingdir))
+
+ export_cmd = (
+ f"""
+AlembicExport.ArchiveType = #ogawa
+AlembicExport.CoordinateSystem = #maya
+AlembicExport.CustomAttributes = true
+AlembicExport.UVs = true
+AlembicExport.VertexColors = true
+AlembicExport.PreserveInstances = true
+
+exportFile @"{filepath}" #noPrompt selectedOnly:on using:AlembicExport
+
+ """)
+
+ self.log.debug(f"Executing command: {export_cmd}")
+
+ with maintained_selection():
+ # select and export
+ rt.select(get_all_children(rt.getNodeByName(container)))
+ rt.execute(export_cmd)
+
+ self.log.info("Performing Extraction ...")
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'abc',
+ 'ext': 'abc',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+ self.log.info("Extracted instance '%s' to: %s" % (instance.name,
+ filepath))
diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py
new file mode 100644
index 0000000000..ce58e8cc17
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py
@@ -0,0 +1,74 @@
+import os
+import pyblish.api
+from openpype.pipeline import (
+ publish,
+ OptionalPyblishPluginMixin
+)
+from pymxs import runtime as rt
+from openpype.hosts.max.api import (
+ maintained_selection,
+ get_all_children
+)
+
+
+class ExtractModelFbx(publish.Extractor,
+ OptionalPyblishPluginMixin):
+ """
+ Extract Geometry in FBX Format
+ """
+
+ order = pyblish.api.ExtractorOrder - 0.05
+ label = "Extract FBX"
+ hosts = ["max"]
+ families = ["model"]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
+ container = instance.data["instance_node"]
+
+ self.log.info("Extracting Geometry ...")
+
+ stagingdir = self.staging_dir(instance)
+ filename = "{name}.fbx".format(**instance.data)
+ filepath = os.path.join(stagingdir,
+ filename)
+ self.log.info("Writing FBX '%s' to '%s'" % (filepath,
+ stagingdir))
+
+ export_fbx_cmd = (
+ f"""
+FBXExporterSetParam "Animation" false
+FBXExporterSetParam "Cameras" false
+FBXExporterSetParam "Lights" false
+FBXExporterSetParam "PointCache" false
+FBXExporterSetParam "AxisConversionMethod" "Animation"
+FbxExporterSetParam "UpAxis" "Y"
+FbxExporterSetParam "Preserveinstances" true
+
+exportFile @"{filepath}" #noPrompt selectedOnly:true using:FBXEXP
+
+ """)
+
+ self.log.debug(f"Executing command: {export_fbx_cmd}")
+
+ with maintained_selection():
+ # select and export
+ rt.select(get_all_children(rt.getNodeByName(container)))
+ rt.execute(export_fbx_cmd)
+
+ self.log.info("Performing Extraction ...")
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'fbx',
+ 'ext': 'fbx',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+ self.log.info("Extracted instance '%s' to: %s" % (instance.name,
+ filepath))
diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py
new file mode 100644
index 0000000000..7bda237880
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py
@@ -0,0 +1,59 @@
+import os
+import pyblish.api
+from openpype.pipeline import (
+ publish,
+ OptionalPyblishPluginMixin
+)
+from pymxs import runtime as rt
+from openpype.hosts.max.api import (
+ maintained_selection,
+ get_all_children
+)
+
+
+class ExtractModelObj(publish.Extractor,
+ OptionalPyblishPluginMixin):
+ """
+ Extract Geometry in OBJ Format
+ """
+
+ order = pyblish.api.ExtractorOrder - 0.05
+ label = "Extract OBJ"
+ hosts = ["max"]
+ families = ["model"]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
+ container = instance.data["instance_node"]
+
+ self.log.info("Extracting Geometry ...")
+
+ stagingdir = self.staging_dir(instance)
+ filename = "{name}.obj".format(**instance.data)
+ filepath = os.path.join(stagingdir,
+ filename)
+ self.log.info("Writing OBJ '%s' to '%s'" % (filepath,
+ stagingdir))
+
+ with maintained_selection():
+ # select and export
+ rt.select(get_all_children(rt.getNodeByName(container)))
+ rt.execute(f'exportFile @"{filepath}" #noPrompt selectedOnly:true using:ObjExp') # noqa
+
+ self.log.info("Performing Extraction ...")
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'obj',
+ 'ext': 'obj',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+
+ instance.data["representations"].append(representation)
+ self.log.info("Extracted instance '%s' to: %s" % (instance.name,
+ filepath))
diff --git a/openpype/hosts/max/plugins/publish/extract_model_usd.py b/openpype/hosts/max/plugins/publish/extract_model_usd.py
new file mode 100644
index 0000000000..0bed2d855e
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/extract_model_usd.py
@@ -0,0 +1,114 @@
+import os
+import pyblish.api
+from openpype.pipeline import (
+ publish,
+ OptionalPyblishPluginMixin
+)
+from pymxs import runtime as rt
+from openpype.hosts.max.api import (
+ maintained_selection
+)
+
+
+class ExtractModelUSD(publish.Extractor,
+ OptionalPyblishPluginMixin):
+ """
+ Extract Geometry in USDA Format
+ """
+
+ order = pyblish.api.ExtractorOrder - 0.05
+ label = "Extract Geometry (USD)"
+ hosts = ["max"]
+ families = ["model"]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
+ container = instance.data["instance_node"]
+
+ self.log.info("Extracting Geometry ...")
+
+ stagingdir = self.staging_dir(instance)
+ asset_filename = "{name}.usda".format(**instance.data)
+ asset_filepath = os.path.join(stagingdir,
+ asset_filename)
+ self.log.info("Writing USD '%s' to '%s'" % (asset_filepath,
+ stagingdir))
+
+ log_filename = "{name}.txt".format(**instance.data)
+ log_filepath = os.path.join(stagingdir,
+ log_filename)
+ self.log.info("Writing log '%s' to '%s'" % (log_filepath,
+ stagingdir))
+
+ # get the nodes which need to be exported
+ export_options = self.get_export_options(log_filepath)
+ with maintained_selection():
+ # select and export
+ node_list = self.get_node_list(container)
+ rt.USDExporter.ExportFile(asset_filepath,
+ exportOptions=export_options,
+ contentSource=rt.name("selected"),
+ nodeList=node_list)
+
+ self.log.info("Performing Extraction ...")
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'usda',
+ 'ext': 'usda',
+ 'files': asset_filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+
+ log_representation = {
+ 'name': 'txt',
+ 'ext': 'txt',
+ 'files': log_filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(log_representation)
+
+ self.log.info("Extracted instance '%s' to: %s" % (instance.name,
+ asset_filepath))
+
+ def get_node_list(self, container):
+ """
+ Get the target nodes which are
+ the children of the container
+ """
+ node_list = []
+
+ container_node = rt.getNodeByName(container)
+ target_node = container_node.Children
+ rt.select(target_node)
+ for sel in rt.selection:
+ node_list.append(sel)
+
+ return node_list
+
+ def get_export_options(self, log_path):
+ """Set Export Options for USD Exporter"""
+
+ export_options = rt.USDExporter.createOptions()
+
+ export_options.Meshes = True
+ export_options.Shapes = False
+ export_options.Lights = False
+ export_options.Cameras = False
+ export_options.Materials = False
+ export_options.MeshFormat = rt.name('fromScene')
+ export_options.FileFormat = rt.name('ascii')
+ export_options.UpAxis = rt.name('y')
+ export_options.LogLevel = rt.name('info')
+ export_options.LogPath = log_path
+ export_options.PreserveEdgeOrientation = True
+ export_options.TimeMode = rt.name('current')
+
+ rt.USDexporter.UIOptions = export_options
+
+ return export_options
diff --git a/openpype/hosts/max/plugins/publish/validate_model_contents.py b/openpype/hosts/max/plugins/publish/validate_model_contents.py
new file mode 100644
index 0000000000..dd782674ff
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/validate_model_contents.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+from pymxs import runtime as rt
+
+
+class ValidateModelContent(pyblish.api.InstancePlugin):
+ """Validates Model instance contents.
+
+ A model instance may only hold either geometry-related
+ object(excluding Shapes) or editable meshes.
+ """
+
+ order = pyblish.api.ValidatorOrder
+ families = ["model"]
+ hosts = ["max"]
+ label = "Model Contents"
+
+ def process(self, instance):
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise PublishValidationError("Model instance must only include"
+ "Geometry and Editable Mesh")
+
+ def get_invalid(self, instance):
+ """
+ Get invalid nodes if the instance is not camera
+ """
+ invalid = list()
+ container = instance.data["instance_node"]
+ self.log.info("Validating look content for "
+ "{}".format(container))
+
+ con = rt.getNodeByName(container)
+ selection_list = list(con.Children) or rt.getCurrentSelection()
+ for sel in selection_list:
+ if rt.classOf(sel) in rt.Camera.classes:
+ invalid.append(sel)
+ if rt.classOf(sel) in rt.Light.classes:
+ invalid.append(sel)
+ if rt.classOf(sel) in rt.Shape.classes:
+ invalid.append(sel)
+
+ return invalid
diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py
new file mode 100644
index 0000000000..747147020a
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+from pymxs import runtime as rt
+
+
+class ValidateUSDPlugin(pyblish.api.InstancePlugin):
+ """Validates if USD plugin is installed or loaded in Max
+ """
+
+ order = pyblish.api.ValidatorOrder - 0.01
+ families = ["model"]
+ hosts = ["max"]
+ label = "USD Plugin"
+
+ def process(self, instance):
+ plugin_mgr = rt.pluginManager
+ plugin_count = plugin_mgr.pluginDllCount
+ plugin_info = self.get_plugins(plugin_mgr,
+ plugin_count)
+ usd_import = "usdimport.dli"
+ if usd_import not in plugin_info:
+ raise PublishValidationError("USD Plugin {}"
+ " not found".format(usd_import))
+ usd_export = "usdexport.dle"
+ if usd_export not in plugin_info:
+ raise PublishValidationError("USD Plugin {}"
+ " not found".format(usd_export))
+
+ def get_plugins(self, manager, count):
+ plugin_info_list = list()
+ for p in range(1, count + 1):
+ plugin_info = manager.pluginDllName(p)
+ plugin_info_list.append(plugin_info)
+
+ return plugin_info_list
diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py
index d392ceebec..4471fc2b3b 100644
--- a/openpype/hosts/maya/api/workfile_template_builder.py
+++ b/openpype/hosts/maya/api/workfile_template_builder.py
@@ -43,7 +43,24 @@ class MayaTemplateBuilder(AbstractTemplateBuilder):
))
cmds.sets(name=PLACEHOLDER_SET, empty=True)
- new_nodes = cmds.file(path, i=True, returnNewNodes=True)
+ new_nodes = cmds.file(
+ path,
+ i=True,
+ returnNewNodes=True,
+ preserveReferences=True,
+ loadReferenceDepth="all",
+ )
+
+ # make default cameras non-renderable
+ default_cameras = [cam for cam in cmds.ls(cameras=True)
+ if cmds.camera(cam, query=True, startupCamera=True)]
+ for cam in default_cameras:
+ if not cmds.attributeQuery("renderable", node=cam, exists=True):
+ self.log.debug(
+ "Camera {} has no attribute 'renderable'".format(cam)
+ )
+ continue
+ cmds.setAttr("{}.renderable".format(cam), 0)
cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True)
diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py
index 0dbdb03bb7..7d717dcd44 100644
--- a/openpype/hosts/maya/plugins/load/load_reference.py
+++ b/openpype/hosts/maya/plugins/load/load_reference.py
@@ -162,9 +162,15 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
with parent_nodes(roots, parent=None):
cmds.xform(group_name, zeroTransformPivots=True)
- cmds.setAttr("{}.displayHandle".format(group_name), 1)
-
settings = get_project_settings(os.environ['AVALON_PROJECT'])
+
+ display_handle = settings['maya']['load'].get(
+ 'reference_loader', {}
+ ).get('display_handle', True)
+ cmds.setAttr(
+ "{}.displayHandle".format(group_name), display_handle
+ )
+
colors = settings['maya']['load']['colors']
c = colors.get(family)
if c is not None:
@@ -174,7 +180,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
(float(c[1]) / 255),
(float(c[2]) / 255))
- cmds.setAttr("{}.displayHandle".format(group_name), 1)
+ cmds.setAttr(
+ "{}.displayHandle".format(group_name), display_handle
+ )
# get bounding box
bbox = cmds.exactWorldBoundingBox(group_name)
# get pivot position on world space
diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py
index 520951a5e6..3cc95a0b2e 100644
--- a/openpype/hosts/maya/plugins/publish/extract_look.py
+++ b/openpype/hosts/maya/plugins/publish/extract_look.py
@@ -280,7 +280,7 @@ class MakeTX(TextureProcessor):
# Do nothing if the source file is already a .tx file.
return TextureResult(
path=source,
- file_hash=None, # todo: unknown texture hash?
+ file_hash=source_hash(source),
colorspace=colorspace,
transfer_mode=COPY
)
diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py
index 825a8d38c7..3ceef6f3d3 100644
--- a/openpype/hosts/maya/plugins/publish/extract_playblast.py
+++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py
@@ -217,7 +217,11 @@ class ExtractPlayblast(publish.Extractor):
instance.data["panel"], edit=True, **viewport_defaults
)
- cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), pan_zoom)
+ try:
+ cmds.setAttr(
+ "{}.panZoomEnabled".format(preset["camera"]), pan_zoom)
+ except RuntimeError:
+ self.log.warning("Cannot restore Pan/Zoom settings.")
collected_files = os.listdir(stagingdir)
patterns = [clique.PATTERNS["frames"]]
diff --git a/openpype/hosts/maya/plugins/publish/validate_attributes.py b/openpype/hosts/maya/plugins/publish/validate_attributes.py
index 6ca9afb9a4..7ebd9d7d03 100644
--- a/openpype/hosts/maya/plugins/publish/validate_attributes.py
+++ b/openpype/hosts/maya/plugins/publish/validate_attributes.py
@@ -6,7 +6,7 @@ import pyblish.api
from openpype.hosts.maya.api.lib import set_attribute
from openpype.pipeline.publish import (
- RepairContextAction,
+ RepairAction,
ValidateContentsOrder,
)
@@ -26,7 +26,7 @@ class ValidateAttributes(pyblish.api.InstancePlugin):
order = ValidateContentsOrder
label = "Attributes"
hosts = ["maya"]
- actions = [RepairContextAction]
+ actions = [RepairAction]
optional = True
attributes = None
@@ -81,7 +81,7 @@ class ValidateAttributes(pyblish.api.InstancePlugin):
if node_name not in attributes:
continue
- for attr_name, expected in attributes.items():
+ for attr_name, expected in attributes[node_name].items():
# Skip if attribute does not exist
if not cmds.attributeQuery(attr_name, node=node, exists=True):
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index fe3a2d2bd1..64fa32a383 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -495,17 +495,17 @@ def get_avalon_knob_data(node, prefix="avalon:", create=True):
data (dict)
"""
+ data = {}
+ if AVALON_TAB not in node.knobs():
+ return data
+
# check if lists
if not isinstance(prefix, list):
- prefix = list([prefix])
-
- data = dict()
+ prefix = [prefix]
# loop prefix
for p in prefix:
# check if the node is avalon tracked
- if AVALON_TAB not in node.knobs():
- continue
try:
# check if data available on the node
test = node[AVALON_DATA_GROUP].value()
@@ -516,8 +516,7 @@ def get_avalon_knob_data(node, prefix="avalon:", create=True):
if create:
node = set_avalon_knob_data(node)
return get_avalon_knob_data(node)
- else:
- return {}
+ return {}
# get data from filtered knobs
data.update({k.replace(p, ''): node[k].value()
diff --git a/openpype/hosts/nuke/plugins/create/convert_legacy.py b/openpype/hosts/nuke/plugins/create/convert_legacy.py
index c143e4cb27..377e9f78f6 100644
--- a/openpype/hosts/nuke/plugins/create/convert_legacy.py
+++ b/openpype/hosts/nuke/plugins/create/convert_legacy.py
@@ -2,7 +2,8 @@ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin
from openpype.hosts.nuke.api.lib import (
INSTANCE_DATA_KNOB,
get_node_data,
- get_avalon_knob_data
+ get_avalon_knob_data,
+ AVALON_TAB,
)
from openpype.hosts.nuke.api.plugin import convert_to_valid_instaces
@@ -17,13 +18,15 @@ class LegacyConverted(SubsetConvertorPlugin):
legacy_found = False
# search for first available legacy item
for node in nuke.allNodes(recurseGroups=True):
-
if node.Class() in ["Viewer", "Dot"]:
continue
if get_node_data(node, INSTANCE_DATA_KNOB):
continue
+ if AVALON_TAB not in node.knobs():
+ continue
+
# get data from avalon knob
avalon_knob_data = get_avalon_knob_data(
node, ["avalon:", "ak:"], create=False)
diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py
index 536a0698f3..6697a1e59a 100644
--- a/openpype/hosts/nuke/plugins/publish/collect_writes.py
+++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py
@@ -190,7 +190,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin,
# make sure rendered sequence on farm will
# be used for extract review
- if not instance.data["review"]:
+ if not instance.data.get("review"):
instance.data["useSequenceForReview"] = False
self.log.debug("instance.data: {}".format(pformat(instance.data)))
diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/lib.py
similarity index 83%
rename from openpype/hosts/photoshop/plugins/create/workfile_creator.py
rename to openpype/hosts/photoshop/lib.py
index f5d56adcbc..ae7a33b7b6 100644
--- a/openpype/hosts/photoshop/plugins/create/workfile_creator.py
+++ b/openpype/hosts/photoshop/lib.py
@@ -7,28 +7,26 @@ from openpype.pipeline import (
from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances
-class PSWorkfileCreator(AutoCreator):
- identifier = "workfile"
- family = "workfile"
-
- default_variant = "Main"
-
+class PSAutoCreator(AutoCreator):
+ """Generic autocreator to extend."""
def get_instance_attr_defs(self):
return []
def collect_instances(self):
for instance_data in cache_and_get_instances(self):
creator_id = instance_data.get("creator_identifier")
+
if creator_id == self.identifier:
- subset_name = instance_data["subset"]
- instance = CreatedInstance(
- self.family, subset_name, instance_data, self
+ instance = CreatedInstance.from_existing(
+ instance_data, self
)
self._add_instance_to_context(instance)
def update_instances(self, update_list):
- # nothing to change on workfiles
- pass
+ self.log.debug("update_list:: {}".format(update_list))
+ for created_inst, _changes in update_list:
+ api.stub().imprint(created_inst.get("instance_id"),
+ created_inst.data_to_store())
def create(self, options=None):
existing_instance = None
@@ -58,6 +56,9 @@ class PSWorkfileCreator(AutoCreator):
project_name, host_name, None
))
+ if not self.active_on_create:
+ data["active"] = False
+
new_instance = CreatedInstance(
self.family, subset_name, data, self
)
diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py
new file mode 100644
index 0000000000..3bc61c8184
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py
@@ -0,0 +1,120 @@
+from openpype.pipeline import CreatedInstance
+
+from openpype.lib import BoolDef
+import openpype.hosts.photoshop.api as api
+from openpype.hosts.photoshop.lib import PSAutoCreator
+from openpype.pipeline.create import get_subset_name
+from openpype.client import get_asset_by_name
+
+
+class AutoImageCreator(PSAutoCreator):
+ """Creates flatten image from all visible layers.
+
+ Used in simplified publishing as auto created instance.
+ Must be enabled in Setting and template for subset name provided
+ """
+ identifier = "auto_image"
+ family = "image"
+
+ # Settings
+ default_variant = ""
+ # - Mark by default instance for review
+ mark_for_review = True
+ active_on_create = True
+
+ def create(self, options=None):
+ existing_instance = None
+ for instance in self.create_context.instances:
+ if instance.creator_identifier == self.identifier:
+ existing_instance = instance
+ break
+
+ context = self.create_context
+ project_name = context.get_current_project_name()
+ asset_name = context.get_current_asset_name()
+ task_name = context.get_current_task_name()
+ host_name = context.host_name
+ asset_doc = get_asset_by_name(project_name, asset_name)
+
+ if existing_instance is None:
+ subset_name = get_subset_name(
+ self.family, self.default_variant, task_name, asset_doc,
+ project_name, host_name
+ )
+
+ publishable_ids = [layer.id for layer in api.stub().get_layers()
+ if layer.visible]
+ data = {
+ "asset": asset_name,
+ "task": task_name,
+ # ids are "virtual" layers, won't get grouped as 'members' do
+ # same difference in color coded layers in WP
+ "ids": publishable_ids
+ }
+
+ if not self.active_on_create:
+ data["active"] = False
+
+ creator_attributes = {"mark_for_review": self.mark_for_review}
+ data.update({"creator_attributes": creator_attributes})
+
+ new_instance = CreatedInstance(
+ self.family, subset_name, data, self
+ )
+ self._add_instance_to_context(new_instance)
+ api.stub().imprint(new_instance.get("instance_id"),
+ new_instance.data_to_store())
+
+ elif ( # existing instance from different context
+ existing_instance["asset"] != asset_name
+ or existing_instance["task"] != task_name
+ ):
+ subset_name = get_subset_name(
+ self.family, self.default_variant, task_name, asset_doc,
+ project_name, host_name
+ )
+
+ existing_instance["asset"] = asset_name
+ existing_instance["task"] = task_name
+ existing_instance["subset"] = subset_name
+
+ api.stub().imprint(existing_instance.get("instance_id"),
+ existing_instance.data_to_store())
+
+ def get_pre_create_attr_defs(self):
+ return [
+ BoolDef(
+ "mark_for_review",
+ label="Review",
+ default=self.mark_for_review
+ )
+ ]
+
+ def get_instance_attr_defs(self):
+ return [
+ BoolDef(
+ "mark_for_review",
+ label="Review"
+ )
+ ]
+
+ def apply_settings(self, project_settings, system_settings):
+ plugin_settings = (
+ project_settings["photoshop"]["create"]["AutoImageCreator"]
+ )
+
+ self.active_on_create = plugin_settings["active_on_create"]
+ self.default_variant = plugin_settings["default_variant"]
+ self.mark_for_review = plugin_settings["mark_for_review"]
+ self.enabled = plugin_settings["enabled"]
+
+ def get_detail_description(self):
+ return """Creator for flatten image.
+
+ Studio might configure simple publishing workflow. In that case
+ `image` instance is automatically created which will publish flat
+ image from all visible layers.
+
+ Artist might disable this instance from publishing or from creating
+ review for it though.
+ """
diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py
index 3d82d6b6f0..f3165fca57 100644
--- a/openpype/hosts/photoshop/plugins/create/create_image.py
+++ b/openpype/hosts/photoshop/plugins/create/create_image.py
@@ -23,6 +23,11 @@ class ImageCreator(Creator):
family = "image"
description = "Image creator"
+ # Settings
+ default_variants = ""
+ mark_for_review = False
+ active_on_create = True
+
def create(self, subset_name_from_ui, data, pre_create_data):
groups_to_create = []
top_layers_to_wrap = []
@@ -94,6 +99,12 @@ class ImageCreator(Creator):
data.update({"layer_name": layer_name})
data.update({"long_name": "_".join(layer_names_in_hierarchy)})
+ creator_attributes = {"mark_for_review": self.mark_for_review}
+ data.update({"creator_attributes": creator_attributes})
+
+ if not self.active_on_create:
+ data["active"] = False
+
new_instance = CreatedInstance(self.family, subset_name, data,
self)
@@ -134,11 +145,6 @@ class ImageCreator(Creator):
self.host.remove_instance(instance)
self._remove_instance_from_context(instance)
- def get_default_variants(self):
- return [
- "Main"
- ]
-
def get_pre_create_attr_defs(self):
output = [
BoolDef("use_selection", default=True,
@@ -148,10 +154,34 @@ class ImageCreator(Creator):
label="Create separate instance for each selected"),
BoolDef("use_layer_name",
default=False,
- label="Use layer name in subset")
+ label="Use layer name in subset"),
+ BoolDef(
+ "mark_for_review",
+ label="Create separate review",
+ default=False
+ )
]
return output
+ def get_instance_attr_defs(self):
+ return [
+ BoolDef(
+ "mark_for_review",
+ label="Review"
+ )
+ ]
+
+ def apply_settings(self, project_settings, system_settings):
+ plugin_settings = (
+ project_settings["photoshop"]["create"]["ImageCreator"]
+ )
+
+ self.active_on_create = plugin_settings["active_on_create"]
+ self.default_variants = plugin_settings["default_variants"]
+ self.mark_for_review = plugin_settings["mark_for_review"]
+ self.enabled = plugin_settings["enabled"]
+
+
def get_detail_description(self):
return """Creator for Image instances
@@ -180,6 +210,11 @@ class ImageCreator(Creator):
but layer name should be used (set explicitly in UI or implicitly if
multiple images should be created), it is added in capitalized form
as a suffix to subset name.
+
+ Each image could have its separate review created if necessary via
+ `Create separate review` toggle.
+ But more use case is to use separate `review` instance to create review
+ from all published items.
"""
def _handle_legacy(self, instance_data):
diff --git a/openpype/hosts/photoshop/plugins/create/create_review.py b/openpype/hosts/photoshop/plugins/create/create_review.py
new file mode 100644
index 0000000000..064485d465
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/create/create_review.py
@@ -0,0 +1,28 @@
+from openpype.hosts.photoshop.lib import PSAutoCreator
+
+
+class ReviewCreator(PSAutoCreator):
+ """Creates review instance which might be disabled from publishing."""
+ identifier = "review"
+ family = "review"
+
+ default_variant = "Main"
+
+ def get_detail_description(self):
+ return """Auto creator for review.
+
+ Photoshop review is created from all published images or from all
+ visible layers if no `image` instances got created.
+
+ Review might be disabled by an artist (instance shouldn't be deleted as
+ it will get recreated in next publish either way).
+ """
+
+ def apply_settings(self, project_settings, system_settings):
+ plugin_settings = (
+ project_settings["photoshop"]["create"]["ReviewCreator"]
+ )
+
+ self.default_variant = plugin_settings["default_variant"]
+ self.active_on_create = plugin_settings["active_on_create"]
+ self.enabled = plugin_settings["enabled"]
diff --git a/openpype/hosts/photoshop/plugins/create/create_workfile.py b/openpype/hosts/photoshop/plugins/create/create_workfile.py
new file mode 100644
index 0000000000..d498f0549c
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/create/create_workfile.py
@@ -0,0 +1,28 @@
+from openpype.hosts.photoshop.lib import PSAutoCreator
+
+
+class WorkfileCreator(PSAutoCreator):
+ identifier = "workfile"
+ family = "workfile"
+
+ default_variant = "Main"
+
+ def get_detail_description(self):
+ return """Auto creator for workfile.
+
+ It is expected that each publish will also publish its source workfile
+ for safekeeping. This creator triggers automatically without need for
+ an artist to remember and trigger it explicitly.
+
+ Workfile instance could be disabled if it is not required to publish
+ workfile. (Instance shouldn't be deleted though as it will be recreated
+ in next publish automatically).
+ """
+
+ def apply_settings(self, project_settings, system_settings):
+ plugin_settings = (
+ project_settings["photoshop"]["create"]["WorkfileCreator"]
+ )
+
+ self.active_on_create = plugin_settings["active_on_create"]
+ self.enabled = plugin_settings["enabled"]
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py
new file mode 100644
index 0000000000..ce408f8d01
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py
@@ -0,0 +1,101 @@
+import pyblish.api
+
+from openpype.hosts.photoshop import api as photoshop
+from openpype.pipeline.create import get_subset_name
+
+
+class CollectAutoImage(pyblish.api.ContextPlugin):
+ """Creates auto image in non artist based publishes (Webpublisher).
+
+ 'remotepublish' should be renamed to 'autopublish' or similar in the future
+ """
+
+ label = "Collect Auto Image"
+ order = pyblish.api.CollectorOrder
+ hosts = ["photoshop"]
+ order = pyblish.api.CollectorOrder + 0.2
+
+ targets = ["remotepublish"]
+
+ def process(self, context):
+ family = "image"
+ for instance in context:
+ creator_identifier = instance.data.get("creator_identifier")
+ if creator_identifier and creator_identifier == "auto_image":
+ self.log.debug("Auto image instance found, won't create new")
+ return
+
+ project_name = context.data["anatomyData"]["project"]["name"]
+ proj_settings = context.data["project_settings"]
+ task_name = context.data["anatomyData"]["task"]["name"]
+ host_name = context.data["hostName"]
+ asset_doc = context.data["assetEntity"]
+ asset_name = asset_doc["name"]
+
+ auto_creator = proj_settings.get(
+ "photoshop", {}).get(
+ "create", {}).get(
+ "AutoImageCreator", {})
+
+ if not auto_creator or not auto_creator["enabled"]:
+ self.log.debug("Auto image creator disabled, won't create new")
+ return
+
+ stub = photoshop.stub()
+ stored_items = stub.get_layers_metadata()
+ for item in stored_items:
+ if item.get("creator_identifier") == "auto_image":
+ if not item.get("active"):
+ self.log.debug("Auto_image instance disabled")
+ return
+
+ layer_items = stub.get_layers()
+
+ publishable_ids = [layer.id for layer in layer_items
+ if layer.visible]
+
+ # collect stored image instances
+ instance_names = []
+ for layer_item in layer_items:
+ layer_meta_data = stub.read(layer_item, stored_items)
+
+ # Skip layers without metadata.
+ if layer_meta_data is None:
+ continue
+
+ # Skip containers.
+ if "container" in layer_meta_data["id"]:
+ continue
+
+ # active might not be in legacy meta
+ if layer_meta_data.get("active", True) and layer_item.visible:
+ instance_names.append(layer_meta_data["subset"])
+
+ if len(instance_names) == 0:
+ variants = proj_settings.get(
+ "photoshop", {}).get(
+ "create", {}).get(
+ "CreateImage", {}).get(
+ "default_variants", [''])
+ family = "image"
+
+ variant = context.data.get("variant") or variants[0]
+
+ subset_name = get_subset_name(
+ family, variant, task_name, asset_doc,
+ project_name, host_name
+ )
+
+ instance = context.create_instance(subset_name)
+ instance.data["family"] = family
+ instance.data["asset"] = asset_name
+ instance.data["subset"] = subset_name
+ instance.data["ids"] = publishable_ids
+ instance.data["publish"] = True
+ instance.data["creator_identifier"] = "auto_image"
+
+ if auto_creator["mark_for_review"]:
+ instance.data["creator_attributes"] = {"mark_for_review": True}
+ instance.data["families"] = ["review"]
+
+ self.log.info("auto image instance: {} ".format(instance.data))
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py
new file mode 100644
index 0000000000..7de4adcaf4
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py
@@ -0,0 +1,92 @@
+"""
+Requires:
+ None
+
+Provides:
+ instance -> family ("review")
+"""
+import pyblish.api
+
+from openpype.hosts.photoshop import api as photoshop
+from openpype.pipeline.create import get_subset_name
+
+
+class CollectAutoReview(pyblish.api.ContextPlugin):
+ """Create review instance in non artist based workflow.
+
+ Called only if PS is triggered in Webpublisher or in tests.
+ """
+
+ label = "Collect Auto Review"
+ hosts = ["photoshop"]
+ order = pyblish.api.CollectorOrder + 0.2
+ targets = ["remotepublish"]
+
+ publish = True
+
+ def process(self, context):
+ family = "review"
+ has_review = False
+ for instance in context:
+ if instance.data["family"] == family:
+ self.log.debug("Review instance found, won't create new")
+ has_review = True
+
+ creator_attributes = instance.data.get("creator_attributes", {})
+ if (creator_attributes.get("mark_for_review") and
+ "review" not in instance.data["families"]):
+ instance.data["families"].append("review")
+
+ if has_review:
+ return
+
+ stub = photoshop.stub()
+ stored_items = stub.get_layers_metadata()
+ for item in stored_items:
+ if item.get("creator_identifier") == family:
+ if not item.get("active"):
+ self.log.debug("Review instance disabled")
+ return
+
+ auto_creator = context.data["project_settings"].get(
+ "photoshop", {}).get(
+ "create", {}).get(
+ "ReviewCreator", {})
+
+ if not auto_creator or not auto_creator["enabled"]:
+ self.log.debug("Review creator disabled, won't create new")
+ return
+
+ variant = (context.data.get("variant") or
+ auto_creator["default_variant"])
+
+ project_name = context.data["anatomyData"]["project"]["name"]
+ proj_settings = context.data["project_settings"]
+ task_name = context.data["anatomyData"]["task"]["name"]
+ host_name = context.data["hostName"]
+ asset_doc = context.data["assetEntity"]
+ asset_name = asset_doc["name"]
+
+ subset_name = get_subset_name(
+ family,
+ variant,
+ task_name,
+ asset_doc,
+ project_name,
+ host_name=host_name,
+ project_settings=proj_settings
+ )
+
+ instance = context.create_instance(subset_name)
+ instance.data.update({
+ "subset": subset_name,
+ "label": subset_name,
+ "name": subset_name,
+ "family": family,
+ "families": [],
+ "representations": [],
+ "asset": asset_name,
+ "publish": self.publish
+ })
+
+ self.log.debug("auto review created::{}".format(instance.data))
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py
new file mode 100644
index 0000000000..d10cf62c67
--- /dev/null
+++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py
@@ -0,0 +1,99 @@
+import os
+import pyblish.api
+
+from openpype.hosts.photoshop import api as photoshop
+from openpype.pipeline.create import get_subset_name
+
+
+class CollectAutoWorkfile(pyblish.api.ContextPlugin):
+ """Collect current script for publish."""
+
+ order = pyblish.api.CollectorOrder + 0.2
+ label = "Collect Workfile"
+ hosts = ["photoshop"]
+
+ targets = ["remotepublish"]
+
+ def process(self, context):
+ family = "workfile"
+ file_path = context.data["currentFile"]
+ _, ext = os.path.splitext(file_path)
+ staging_dir = os.path.dirname(file_path)
+ base_name = os.path.basename(file_path)
+ workfile_representation = {
+ "name": ext[1:],
+ "ext": ext[1:],
+ "files": base_name,
+ "stagingDir": staging_dir,
+ }
+
+ for instance in context:
+ if instance.data["family"] == family:
+ self.log.debug("Workfile instance found, won't create new")
+ instance.data.update({
+ "label": base_name,
+ "name": base_name,
+ "representations": [],
+ })
+
+ # creating representation
+ _, ext = os.path.splitext(file_path)
+ instance.data["representations"].append(
+ workfile_representation)
+
+ return
+
+ stub = photoshop.stub()
+ stored_items = stub.get_layers_metadata()
+ for item in stored_items:
+ if item.get("creator_identifier") == family:
+ if not item.get("active"):
+ self.log.debug("Workfile instance disabled")
+ return
+
+ project_name = context.data["anatomyData"]["project"]["name"]
+ proj_settings = context.data["project_settings"]
+ auto_creator = proj_settings.get(
+ "photoshop", {}).get(
+ "create", {}).get(
+ "WorkfileCreator", {})
+
+ if not auto_creator or not auto_creator["enabled"]:
+ self.log.debug("Workfile creator disabled, won't create new")
+ return
+
+ # context.data["variant"] might come only from collect_batch_data
+ variant = (context.data.get("variant") or
+ auto_creator["default_variant"])
+
+ task_name = context.data["anatomyData"]["task"]["name"]
+ host_name = context.data["hostName"]
+ asset_doc = context.data["assetEntity"]
+ asset_name = asset_doc["name"]
+
+ subset_name = get_subset_name(
+ family,
+ variant,
+ task_name,
+ asset_doc,
+ project_name,
+ host_name=host_name,
+ project_settings=proj_settings
+ )
+
+ # Create instance
+ instance = context.create_instance(subset_name)
+ instance.data.update({
+ "subset": subset_name,
+ "label": base_name,
+ "name": base_name,
+ "family": family,
+ "families": [],
+ "representations": [],
+ "asset": asset_name
+ })
+
+ # creating representation
+ instance.data["representations"].append(workfile_representation)
+
+ self.log.debug("auto workfile review created:{}".format(instance.data))
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py
deleted file mode 100644
index 5bf12379b1..0000000000
--- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py
+++ /dev/null
@@ -1,116 +0,0 @@
-import pprint
-
-import pyblish.api
-
-from openpype.settings import get_project_settings
-from openpype.hosts.photoshop import api as photoshop
-from openpype.lib import prepare_template_data
-from openpype.pipeline import legacy_io
-
-
-class CollectInstances(pyblish.api.ContextPlugin):
- """Gather instances by LayerSet and file metadata
-
- Collects publishable instances from file metadata or enhance
- already collected by creator (family == "image").
-
- If no image instances are explicitly created, it looks if there is value
- in `flatten_subset_template` (configurable in Settings), in that case it
- produces flatten image with all visible layers.
-
- Identifier:
- id (str): "pyblish.avalon.instance"
- """
-
- label = "Collect Instances"
- order = pyblish.api.CollectorOrder
- hosts = ["photoshop"]
- families_mapping = {
- "image": []
- }
- # configurable in Settings
- flatten_subset_template = ""
-
- def process(self, context):
- instance_by_layer_id = {}
- for instance in context:
- if (
- instance.data["family"] == "image" and
- instance.data.get("members")):
- layer_id = str(instance.data["members"][0])
- instance_by_layer_id[layer_id] = instance
-
- stub = photoshop.stub()
- layer_items = stub.get_layers()
- layers_meta = stub.get_layers_metadata()
- instance_names = []
-
- all_layer_ids = []
- for layer_item in layer_items:
- layer_meta_data = stub.read(layer_item, layers_meta)
- all_layer_ids.append(layer_item.id)
-
- # Skip layers without metadata.
- if layer_meta_data is None:
- continue
-
- # Skip containers.
- if "container" in layer_meta_data["id"]:
- continue
-
- # active might not be in legacy meta
- if not layer_meta_data.get("active", True):
- continue
-
- instance = instance_by_layer_id.get(str(layer_item.id))
- if instance is None:
- instance = context.create_instance(layer_meta_data["subset"])
-
- instance.data["layer"] = layer_item
- instance.data.update(layer_meta_data)
- instance.data["families"] = self.families_mapping[
- layer_meta_data["family"]
- ]
- instance.data["publish"] = layer_item.visible
- instance_names.append(layer_meta_data["subset"])
-
- # Produce diagnostic message for any graphical
- # user interface interested in visualising it.
- self.log.info("Found: \"%s\" " % instance.data["name"])
- self.log.info("instance: {} ".format(
- pprint.pformat(instance.data, indent=4)))
-
- if len(instance_names) != len(set(instance_names)):
- self.log.warning("Duplicate instances found. " +
- "Remove unwanted via Publisher")
-
- if len(instance_names) == 0 and self.flatten_subset_template:
- project_name = context.data["projectEntity"]["name"]
- variants = get_project_settings(project_name).get(
- "photoshop", {}).get(
- "create", {}).get(
- "CreateImage", {}).get(
- "defaults", [''])
- family = "image"
- task_name = legacy_io.Session["AVALON_TASK"]
- asset_name = context.data["assetEntity"]["name"]
-
- variant = context.data.get("variant") or variants[0]
- fill_pairs = {
- "variant": variant,
- "family": family,
- "task": task_name
- }
-
- subset = self.flatten_subset_template.format(
- **prepare_template_data(fill_pairs))
-
- instance = context.create_instance(subset)
- instance.data["family"] = family
- instance.data["asset"] = asset_name
- instance.data["subset"] = subset
- instance.data["ids"] = all_layer_ids
- instance.data["families"] = self.families_mapping[family]
- instance.data["publish"] = True
-
- self.log.info("flatten instance: {} ".format(instance.data))
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py
index 7e598a8250..87ec4ee3f1 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_review.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py
@@ -14,10 +14,7 @@ from openpype.pipeline.create import get_subset_name
class CollectReview(pyblish.api.ContextPlugin):
- """Gather the active document as review instance.
-
- Triggers once even if no 'image' is published as by defaults it creates
- flatten image from a workfile.
+ """Adds review to families for instances marked to be reviewable.
"""
label = "Collect Review"
@@ -28,25 +25,8 @@ class CollectReview(pyblish.api.ContextPlugin):
publish = True
def process(self, context):
- family = "review"
- subset = get_subset_name(
- family,
- context.data.get("variant", ''),
- context.data["anatomyData"]["task"]["name"],
- context.data["assetEntity"],
- context.data["anatomyData"]["project"]["name"],
- host_name=context.data["hostName"],
- project_settings=context.data["project_settings"]
- )
-
- instance = context.create_instance(subset)
- instance.data.update({
- "subset": subset,
- "label": subset,
- "name": subset,
- "family": family,
- "families": [],
- "representations": [],
- "asset": os.environ["AVALON_ASSET"],
- "publish": self.publish
- })
+ for instance in context:
+ creator_attributes = instance.data["creator_attributes"]
+ if (creator_attributes.get("mark_for_review") and
+ "review" not in instance.data["families"]):
+ instance.data["families"].append("review")
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py
index 9a5aad5569..9625464499 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py
@@ -14,50 +14,19 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
default_variant = "Main"
def process(self, context):
- existing_instance = None
for instance in context:
if instance.data["family"] == "workfile":
- self.log.debug("Workfile instance found, won't create new")
- existing_instance = instance
- break
+ file_path = context.data["currentFile"]
+ _, ext = os.path.splitext(file_path)
+ staging_dir = os.path.dirname(file_path)
+ base_name = os.path.basename(file_path)
- family = "workfile"
- # context.data["variant"] might come only from collect_batch_data
- variant = context.data.get("variant") or self.default_variant
- subset = get_subset_name(
- family,
- variant,
- context.data["anatomyData"]["task"]["name"],
- context.data["assetEntity"],
- context.data["anatomyData"]["project"]["name"],
- host_name=context.data["hostName"],
- project_settings=context.data["project_settings"]
- )
-
- file_path = context.data["currentFile"]
- staging_dir = os.path.dirname(file_path)
- base_name = os.path.basename(file_path)
-
- # Create instance
- if existing_instance is None:
- instance = context.create_instance(subset)
- instance.data.update({
- "subset": subset,
- "label": base_name,
- "name": base_name,
- "family": family,
- "families": [],
- "representations": [],
- "asset": os.environ["AVALON_ASSET"]
- })
- else:
- instance = existing_instance
-
- # creating representation
- _, ext = os.path.splitext(file_path)
- instance.data["representations"].append({
- "name": ext[1:],
- "ext": ext[1:],
- "files": base_name,
- "stagingDir": staging_dir,
- })
+ # creating representation
+ _, ext = os.path.splitext(file_path)
+ instance.data["representations"].append({
+ "name": ext[1:],
+ "ext": ext[1:],
+ "files": base_name,
+ "stagingDir": staging_dir,
+ })
+ return
diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py
index 9d7eff0211..d5416a389d 100644
--- a/openpype/hosts/photoshop/plugins/publish/extract_review.py
+++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py
@@ -47,32 +47,42 @@ class ExtractReview(publish.Extractor):
layers = self._get_layers_from_image_instances(instance)
self.log.info("Layers image instance found: {}".format(layers))
+ repre_name = "jpg"
+ repre_skeleton = {
+ "name": repre_name,
+ "ext": "jpg",
+ "stagingDir": staging_dir,
+ "tags": self.jpg_options['tags'],
+ }
+
+ if instance.data["family"] != "review":
+ # enable creation of review, without this jpg review would clash
+ # with jpg of the image family
+ output_name = repre_name
+ repre_name = "{}_{}".format(repre_name, output_name)
+ repre_skeleton.update({"name": repre_name,
+ "outputName": output_name})
+
if self.make_image_sequence and len(layers) > 1:
self.log.info("Extract layers to image sequence.")
img_list = self._save_sequence_images(staging_dir, layers)
- instance.data["representations"].append({
- "name": "jpg",
- "ext": "jpg",
- "files": img_list,
+ repre_skeleton.update({
"frameStart": 0,
"frameEnd": len(img_list),
"fps": fps,
- "stagingDir": staging_dir,
- "tags": self.jpg_options['tags'],
+ "files": img_list,
})
+ instance.data["representations"].append(repre_skeleton)
processed_img_names = img_list
else:
self.log.info("Extract layers to flatten image.")
img_list = self._save_flatten_image(staging_dir, layers)
- instance.data["representations"].append({
- "name": "jpg",
- "ext": "jpg",
- "files": img_list, # cannot be [] for single frame
- "stagingDir": staging_dir,
- "tags": self.jpg_options['tags']
+ repre_skeleton.update({
+ "files": img_list,
})
+ instance.data["representations"].append(repre_skeleton)
processed_img_names = [img_list]
ffmpeg_path = get_ffmpeg_tool_path("ffmpeg")
diff --git a/openpype/hosts/substancepainter/__init__.py b/openpype/hosts/substancepainter/__init__.py
new file mode 100644
index 0000000000..4c33b9f507
--- /dev/null
+++ b/openpype/hosts/substancepainter/__init__.py
@@ -0,0 +1,10 @@
+from .addon import (
+ SubstanceAddon,
+ SUBSTANCE_HOST_DIR,
+)
+
+
+__all__ = (
+ "SubstanceAddon",
+ "SUBSTANCE_HOST_DIR"
+)
diff --git a/openpype/hosts/substancepainter/addon.py b/openpype/hosts/substancepainter/addon.py
new file mode 100644
index 0000000000..2fbea139c5
--- /dev/null
+++ b/openpype/hosts/substancepainter/addon.py
@@ -0,0 +1,34 @@
+import os
+from openpype.modules import OpenPypeModule, IHostAddon
+
+SUBSTANCE_HOST_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+class SubstanceAddon(OpenPypeModule, IHostAddon):
+ name = "substancepainter"
+ host_name = "substancepainter"
+
+ def initialize(self, module_settings):
+ self.enabled = True
+
+ def add_implementation_envs(self, env, _app):
+ # Add requirements to SUBSTANCE_PAINTER_PLUGINS_PATH
+ plugin_path = os.path.join(SUBSTANCE_HOST_DIR, "deploy")
+ plugin_path = plugin_path.replace("\\", "/")
+ if env.get("SUBSTANCE_PAINTER_PLUGINS_PATH"):
+ plugin_path += os.pathsep + env["SUBSTANCE_PAINTER_PLUGINS_PATH"]
+
+ env["SUBSTANCE_PAINTER_PLUGINS_PATH"] = plugin_path
+
+ # Log in Substance Painter doesn't support custom terminal colors
+ env["OPENPYPE_LOG_NO_COLORS"] = "Yes"
+
+ def get_launch_hook_paths(self, app):
+ if app.host_name != self.host_name:
+ return []
+ return [
+ os.path.join(SUBSTANCE_HOST_DIR, "hooks")
+ ]
+
+ def get_workfile_extensions(self):
+ return [".spp", ".toc"]
diff --git a/openpype/hosts/substancepainter/api/__init__.py b/openpype/hosts/substancepainter/api/__init__.py
new file mode 100644
index 0000000000..937d0c429e
--- /dev/null
+++ b/openpype/hosts/substancepainter/api/__init__.py
@@ -0,0 +1,8 @@
+from .pipeline import (
+ SubstanceHost,
+
+)
+
+__all__ = [
+ "SubstanceHost",
+]
diff --git a/openpype/hosts/substancepainter/api/colorspace.py b/openpype/hosts/substancepainter/api/colorspace.py
new file mode 100644
index 0000000000..375b61b39b
--- /dev/null
+++ b/openpype/hosts/substancepainter/api/colorspace.py
@@ -0,0 +1,157 @@
+"""Substance Painter OCIO management
+
+Adobe Substance 3D Painter supports OCIO color management using a per project
+configuration. Output color spaces are defined at the project level
+
+More information see:
+ - https://substance3d.adobe.com/documentation/spdoc/color-management-223053233.html # noqa
+ - https://substance3d.adobe.com/documentation/spdoc/color-management-with-opencolorio-225969419.html # noqa
+
+"""
+import substance_painter.export
+import substance_painter.js
+import json
+
+from .lib import (
+ get_document_structure,
+ get_channel_format
+)
+
+
+def _iter_document_stack_channels():
+ """Yield all stack paths and channels project"""
+
+ for material in get_document_structure()["materials"]:
+ material_name = material["name"]
+ for stack in material["stacks"]:
+ stack_name = stack["name"]
+ if stack_name:
+ stack_path = [material_name, stack_name]
+ else:
+ stack_path = material_name
+ for channel in stack["channels"]:
+ yield stack_path, channel
+
+
+def _get_first_color_and_data_stack_and_channel():
+ """Return first found color channel and data channel."""
+ color_channel = None
+ data_channel = None
+ for stack_path, channel in _iter_document_stack_channels():
+ channel_format = get_channel_format(stack_path, channel)
+ if channel_format["color"]:
+ color_channel = (stack_path, channel)
+ else:
+ data_channel = (stack_path, channel)
+
+ if color_channel and data_channel:
+ return color_channel, data_channel
+
+ return color_channel, data_channel
+
+
+def get_project_channel_data():
+ """Return colorSpace settings for the current substance painter project.
+
+ In Substance Painter only color channels have Color Management enabled
+ whereas data channels have no color management applied. This can't be
+ changed. The artist can only customize the export color space for color
+ channels per bit-depth for 8 bpc, 16 bpc and 32 bpc.
+
+ As such this returns the color space for 'data' and for per bit-depth
+ for color channels.
+
+ Example output:
+ {
+ "data": {'colorSpace': 'Utility - Raw'},
+ "8": {"colorSpace": "ACES - AcesCG"},
+ "16": {"colorSpace": "ACES - AcesCG"},
+ "16f": {"colorSpace": "ACES - AcesCG"},
+ "32f": {"colorSpace": "ACES - AcesCG"}
+ }
+
+ """
+
+ keys = ["colorSpace"]
+ query = {key: f"${key}" for key in keys}
+
+ config = {
+ "exportPath": "/",
+ "exportShaderParams": False,
+ "defaultExportPreset": "query_preset",
+
+ "exportPresets": [{
+ "name": "query_preset",
+
+ # List of maps making up this export preset.
+ "maps": [{
+ "fileName": json.dumps(query),
+ # List of source/destination defining which channels will
+ # make up the texture file.
+ "channels": [],
+ "parameters": {
+ "fileFormat": "exr",
+ "bitDepth": "32f",
+ "dithering": False,
+ "sizeLog2": 4,
+ "paddingAlgorithm": "passthrough",
+ "dilationDistance": 16
+ }
+ }]
+ }],
+ }
+
+ def _get_query_output(config):
+ # Return the basename of the single output path we defined
+ result = substance_painter.export.list_project_textures(config)
+ path = next(iter(result.values()))[0]
+ # strip extension and slash since we know relevant json data starts
+ # and ends with { and } characters
+ path = path.strip("/\\.exr")
+ return json.loads(path)
+
+ # Query for each type of channel (color and data)
+ color_channel, data_channel = _get_first_color_and_data_stack_and_channel()
+ colorspaces = {}
+ for key, channel_data in {
+ "data": data_channel,
+ "color": color_channel
+ }.items():
+ if channel_data is None:
+ # No channel of that datatype anywhere in the Stack. We're
+ # unable to identify the output color space of the project
+ colorspaces[key] = None
+ continue
+
+ stack, channel = channel_data
+
+ # Stack must be a string
+ if not isinstance(stack, str):
+ # Assume iterable
+ stack = "/".join(stack)
+
+ # Define the temp output config
+ config["exportList"] = [{"rootPath": stack}]
+ config_map = config["exportPresets"][0]["maps"][0]
+ config_map["channels"] = [
+ {
+ "destChannel": x,
+ "srcChannel": x,
+ "srcMapType": "documentMap",
+ "srcMapName": channel
+ } for x in "RGB"
+ ]
+
+ if key == "color":
+ # Query for each bit depth
+ # Color space definition can have a different OCIO config set
+ # for 8-bit, 16-bit and 32-bit outputs so we need to check each
+ # bit depth
+ for depth in ["8", "16", "16f", "32f"]:
+ config_map["parameters"]["bitDepth"] = depth # noqa
+ colorspaces[key + depth] = _get_query_output(config)
+ else:
+ # Data channel (not color managed)
+ colorspaces[key] = _get_query_output(config)
+
+ return colorspaces
diff --git a/openpype/hosts/substancepainter/api/lib.py b/openpype/hosts/substancepainter/api/lib.py
new file mode 100644
index 0000000000..2cd08f862e
--- /dev/null
+++ b/openpype/hosts/substancepainter/api/lib.py
@@ -0,0 +1,649 @@
+import os
+import re
+import json
+from collections import defaultdict
+
+import substance_painter.project
+import substance_painter.resource
+import substance_painter.js
+import substance_painter.export
+
+from qtpy import QtGui, QtWidgets, QtCore
+
+
+def get_export_presets():
+ """Return Export Preset resource URLs for all available Export Presets.
+
+ Returns:
+ dict: {Resource url: GUI Label}
+
+ """
+ # TODO: Find more optimal way to find all export templates
+
+ preset_resources = {}
+ for shelf in substance_painter.resource.Shelves.all():
+ shelf_path = os.path.normpath(shelf.path())
+
+ presets_path = os.path.join(shelf_path, "export-presets")
+ if not os.path.exists(presets_path):
+ continue
+
+ for filename in os.listdir(presets_path):
+ if filename.endswith(".spexp"):
+ template_name = os.path.splitext(filename)[0]
+
+ resource = substance_painter.resource.ResourceID(
+ context=shelf.name(),
+ name=template_name
+ )
+ resource_url = resource.url()
+
+ preset_resources[resource_url] = template_name
+
+ # Sort by template name
+ export_templates = dict(sorted(preset_resources.items(),
+ key=lambda x: x[1]))
+
+ # Add default built-ins at the start
+ # TODO: find the built-ins automatically; scraped with https://gist.github.com/BigRoy/97150c7c6f0a0c916418207b9a2bc8f1 # noqa
+ result = {
+ "export-preset-generator://viewport2d": "2D View", # noqa
+ "export-preset-generator://doc-channel-normal-no-alpha": "Document channels + Normal + AO (No Alpha)", # noqa
+ "export-preset-generator://doc-channel-normal-with-alpha": "Document channels + Normal + AO (With Alpha)", # noqa
+ "export-preset-generator://sketchfab": "Sketchfab", # noqa
+ "export-preset-generator://adobe-standard-material": "Substance 3D Stager", # noqa
+ "export-preset-generator://usd": "USD PBR Metal Roughness", # noqa
+ "export-preset-generator://gltf": "glTF PBR Metal Roughness", # noqa
+ "export-preset-generator://gltf-displacement": "glTF PBR Metal Roughness + Displacement texture (experimental)" # noqa
+ }
+ result.update(export_templates)
+ return result
+
+
+def _convert_stack_path_to_cmd_str(stack_path):
+ """Convert stack path `str` or `[str, str]` for javascript query
+
+ Example usage:
+ >>> stack_path = _convert_stack_path_to_cmd_str(stack_path)
+ >>> cmd = f"alg.mapexport.channelIdentifiers({stack_path})"
+ >>> substance_painter.js.evaluate(cmd)
+
+ Args:
+ stack_path (list or str): Path to the stack, could be
+ "Texture set name" or ["Texture set name", "Stack name"]
+
+ Returns:
+ str: Stack path usable as argument in javascript query.
+
+ """
+ return json.dumps(stack_path)
+
+
+def get_channel_identifiers(stack_path=None):
+ """Return the list of channel identifiers.
+
+ If a context is passed (texture set/stack),
+ return only used channels with resolved user channels.
+
+ Channel identifiers are:
+ basecolor, height, specular, opacity, emissive, displacement,
+ glossiness, roughness, anisotropylevel, anisotropyangle, transmissive,
+ scattering, reflection, ior, metallic, normal, ambientOcclusion,
+ diffuse, specularlevel, blendingmask, [custom user names].
+
+ Args:
+ stack_path (list or str, Optional): Path to the stack, could be
+ "Texture set name" or ["Texture set name", "Stack name"]
+
+ Returns:
+ list: List of channel identifiers.
+
+ """
+ if stack_path is None:
+ stack_path = ""
+ else:
+ stack_path = _convert_stack_path_to_cmd_str(stack_path)
+ cmd = f"alg.mapexport.channelIdentifiers({stack_path})"
+ return substance_painter.js.evaluate(cmd)
+
+
+def get_channel_format(stack_path, channel):
+ """Retrieve the channel format of a specific stack channel.
+
+ See `alg.mapexport.channelFormat` (javascript API) for more details.
+
+ The channel format data is:
+ "label" (str): The channel format label: could be one of
+ [sRGB8, L8, RGB8, L16, RGB16, L16F, RGB16F, L32F, RGB32F]
+ "color" (bool): True if the format is in color, False is grayscale
+ "floating" (bool): True if the format uses floating point
+ representation, false otherwise
+ "bitDepth" (int): Bit per color channel (could be 8, 16 or 32 bpc)
+
+ Arguments:
+ stack_path (list or str): Path to the stack, could be
+ "Texture set name" or ["Texture set name", "Stack name"]
+ channel (str): Identifier of the channel to export
+ (see `get_channel_identifiers`)
+
+ Returns:
+ dict: The channel format data.
+
+ """
+ stack_path = _convert_stack_path_to_cmd_str(stack_path)
+ cmd = f"alg.mapexport.channelFormat({stack_path}, '{channel}')"
+ return substance_painter.js.evaluate(cmd)
+
+
+def get_document_structure():
+ """Dump the document structure.
+
+ See `alg.mapexport.documentStructure` (javascript API) for more details.
+
+ Returns:
+ dict: Document structure or None when no project is open
+
+ """
+ return substance_painter.js.evaluate("alg.mapexport.documentStructure()")
+
+
+def get_export_templates(config, format="png", strip_folder=True):
+ """Return export config outputs.
+
+ This use the Javascript API `alg.mapexport.getPathsExportDocumentMaps`
+ which returns a different output than using the Python equivalent
+ `substance_painter.export.list_project_textures(config)`.
+
+ The nice thing about the Javascript API version is that it returns the
+ output textures grouped by filename template.
+
+ A downside is that it doesn't return all the UDIM tiles but per template
+ always returns a single file.
+
+ Note:
+ The file format needs to be explicitly passed to the Javascript API
+ but upon exporting through the Python API the file format can be based
+ on the output preset. So it's likely the file extension will mismatch
+
+ Warning:
+ Even though the function appears to solely get the expected outputs
+ the Javascript API will actually create the config's texture output
+ folder if it does not exist yet. As such, a valid path must be set.
+
+ Example output:
+ {
+ "DefaultMaterial": {
+ "$textureSet_BaseColor(_$colorSpace)(.$udim)": "DefaultMaterial_BaseColor_ACES - ACEScg.1002.png", # noqa
+ "$textureSet_Emissive(_$colorSpace)(.$udim)": "DefaultMaterial_Emissive_ACES - ACEScg.1002.png", # noqa
+ "$textureSet_Height(_$colorSpace)(.$udim)": "DefaultMaterial_Height_Utility - Raw.1002.png", # noqa
+ "$textureSet_Metallic(_$colorSpace)(.$udim)": "DefaultMaterial_Metallic_Utility - Raw.1002.png", # noqa
+ "$textureSet_Normal(_$colorSpace)(.$udim)": "DefaultMaterial_Normal_Utility - Raw.1002.png", # noqa
+ "$textureSet_Roughness(_$colorSpace)(.$udim)": "DefaultMaterial_Roughness_Utility - Raw.1002.png" # noqa
+ }
+ }
+
+ Arguments:
+ config (dict) Export config
+ format (str, Optional): Output format to write to, defaults to 'png'
+ strip_folder (bool, Optional): Whether to strip the output folder
+ from the output filenames.
+
+ Returns:
+ dict: The expected output maps.
+
+ """
+ folder = config["exportPath"].replace("\\", "/")
+ preset = config["defaultExportPreset"]
+ cmd = f'alg.mapexport.getPathsExportDocumentMaps("{preset}", "{folder}", "{format}")' # noqa
+ result = substance_painter.js.evaluate(cmd)
+
+ if strip_folder:
+ for _stack, maps in result.items():
+ for map_template, map_filepath in maps.items():
+ map_filepath = map_filepath.replace("\\", "/")
+ assert map_filepath.startswith(folder)
+ map_filename = map_filepath[len(folder):].lstrip("/")
+ maps[map_template] = map_filename
+
+ return result
+
+
+def _templates_to_regex(templates,
+ texture_set,
+ colorspaces,
+ project,
+ mesh):
+ """Return regex based on a Substance Painter expot filename template.
+
+ This converts Substance Painter export filename templates like
+ `$mesh_$textureSet_BaseColor(_$colorSpace)(.$udim)` into a regex
+ which can be used to query an output filename to help retrieve:
+
+ - Which template filename the file belongs to.
+ - Which color space the file is written with.
+ - Which udim tile it is exactly.
+
+ This is used by `get_parsed_export_maps` which tries to as explicitly
+ as possible match the filename pattern against the known possible outputs.
+ That's why Texture Set name, Color spaces, Project path and mesh path must
+ be provided. By doing so we get the best shot at correctly matching the
+ right template because otherwise $texture_set could basically be any string
+ and thus match even that of a color space or mesh.
+
+ Arguments:
+ templates (list): List of templates to convert to regex.
+ texture_set (str): The texture set to match against.
+ colorspaces (list): The colorspaces defined in the current project.
+ project (str): Filepath of current substance project.
+ mesh (str): Path to mesh file used in current project.
+
+ Returns:
+ dict: Template: Template regex pattern
+
+ """
+ def _filename_no_ext(path):
+ return os.path.splitext(os.path.basename(path))[0]
+
+ if colorspaces and any(colorspaces):
+ colorspace_match = "|".join(re.escape(c) for c in set(colorspaces))
+ colorspace_match = f"({colorspace_match})"
+ else:
+ # No colorspace support enabled
+ colorspace_match = ""
+
+ # Key to regex valid search values
+ key_matches = {
+ "$project": re.escape(_filename_no_ext(project)),
+ "$mesh": re.escape(_filename_no_ext(mesh)),
+ "$textureSet": re.escape(texture_set),
+ "$colorSpace": colorspace_match,
+ "$udim": "([0-9]{4})"
+ }
+
+ # Turn the templates into regexes
+ regexes = {}
+ for template in templates:
+
+ # We need to tweak a temp
+ search_regex = re.escape(template)
+
+ # Let's assume that any ( and ) character in the file template was
+ # intended as an optional template key and do a simple `str.replace`
+ # Note: we are matching against re.escape(template) so will need to
+ # search for the escaped brackets.
+ search_regex = search_regex.replace(re.escape("("), "(")
+ search_regex = search_regex.replace(re.escape(")"), ")?")
+
+ # Substitute each key into a named group
+ for key, key_expected_regex in key_matches.items():
+
+ # We want to use the template as a regex basis in the end so will
+ # escape the whole thing first. Note that thus we'll need to
+ # search for the escaped versions of the keys too.
+ escaped_key = re.escape(key)
+ key_label = key[1:] # key without $ prefix
+
+ key_expected_grp_regex = f"(?P<{key_label}>{key_expected_regex})"
+ search_regex = search_regex.replace(escaped_key,
+ key_expected_grp_regex)
+
+ # The filename templates don't include the extension so we add it
+ # to be able to match the out filename beginning to end
+ ext_regex = r"(?P\.[A-Za-z][A-Za-z0-9-]*)"
+ search_regex = rf"^{search_regex}{ext_regex}$"
+
+ regexes[template] = search_regex
+
+ return regexes
+
+
+def strip_template(template, strip="._ "):
+ """Return static characters in a substance painter filename template.
+
+ >>> strip_template("$textureSet_HELLO(.$udim)")
+ # HELLO
+ >>> strip_template("$mesh_$textureSet_HELLO_WORLD_$colorSpace(.$udim)")
+ # HELLO_WORLD
+ >>> strip_template("$textureSet_HELLO(.$udim)", strip=None)
+ # _HELLO
+ >>> strip_template("$mesh_$textureSet_$colorSpace(.$udim)", strip=None)
+ # _HELLO_
+ >>> strip_template("$textureSet_HELLO(.$udim)")
+ # _HELLO
+
+ Arguments:
+ template (str): Filename template to strip.
+ strip (str, optional): Characters to strip from beginning and end
+ of the static string in template. Defaults to: `._ `.
+
+ Returns:
+ str: The static string in filename template.
+
+ """
+ # Return only characters that were part of the template that were static.
+ # Remove all keys
+ keys = ["$project", "$mesh", "$textureSet", "$udim", "$colorSpace"]
+ stripped_template = template
+ for key in keys:
+ stripped_template = stripped_template.replace(key, "")
+
+ # Everything inside an optional bracket space is excluded since it's not
+ # static. We keep a counter to track whether we are currently iterating
+ # over parts of the template that are inside an 'optional' group or not.
+ counter = 0
+ result = ""
+ for char in stripped_template:
+ if char == "(":
+ counter += 1
+ elif char == ")":
+ counter -= 1
+ if counter < 0:
+ counter = 0
+ else:
+ if counter == 0:
+ result += char
+
+ if strip:
+ # Strip of any trailing start/end characters. Technically these are
+ # static but usually start and end separators like space or underscore
+ # aren't wanted.
+ result = result.strip(strip)
+
+ return result
+
+
+def get_parsed_export_maps(config):
+ """Return Export Config's expected output textures with parsed data.
+
+ This tries to parse the texture outputs using a Python API export config.
+
+ Parses template keys: $project, $mesh, $textureSet, $colorSpace, $udim
+
+ Example:
+ {("DefaultMaterial", ""): {
+ "$mesh_$textureSet_BaseColor(_$colorSpace)(.$udim)": [
+ {
+ // OUTPUT DATA FOR FILE #1 OF THE TEMPLATE
+ },
+ {
+ // OUTPUT DATA FOR FILE #2 OF THE TEMPLATE
+ },
+ ]
+ },
+ }}
+
+ File output data (all outputs are `str`).
+ 1) Parsed tokens: These are parsed tokens from the template, they will
+ only exist if found in the filename template and output filename.
+
+ project: Workfile filename without extension
+ mesh: Filename of the loaded mesh without extension
+ textureSet: The texture set, e.g. "DefaultMaterial",
+ colorSpace: The color space, e.g. "ACES - ACEScg",
+ udim: The udim tile, e.g. "1001"
+
+ 2) Template output and filepath
+
+ filepath: Full path to the resulting texture map, e.g.
+ "/path/to/mesh_DefaultMaterial_BaseColor_ACES - ACEScg.1002.png",
+ output: "mesh_DefaultMaterial_BaseColor_ACES - ACEScg.1002.png"
+ Note: if template had slashes (folders) then `output` will too.
+ So `output` might include a folder.
+
+ Returns:
+ dict: [texture_set, stack]: {template: [file1_data, file2_data]}
+
+ """
+ # Import is here to avoid recursive lib <-> colorspace imports
+ from .colorspace import get_project_channel_data
+
+ outputs = substance_painter.export.list_project_textures(config)
+ templates = get_export_templates(config, strip_folder=False)
+
+ # Get all color spaces set for the current project
+ project_colorspaces = set(
+ data["colorSpace"] for data in get_project_channel_data().values()
+ )
+
+ # Get current project mesh path and project path to explicitly match
+ # the $mesh and $project tokens
+ project_mesh_path = substance_painter.project.last_imported_mesh_path()
+ project_path = substance_painter.project.file_path()
+
+ # Get the current export path to strip this of the beginning of filepath
+ # results, since filename templates don't have these we'll match without
+ # that part of the filename.
+ export_path = config["exportPath"]
+ export_path = export_path.replace("\\", "/")
+ if not export_path.endswith("/"):
+ export_path += "/"
+
+ # Parse the outputs
+ result = {}
+ for key, filepaths in outputs.items():
+ texture_set, stack = key
+
+ if stack:
+ stack_path = f"{texture_set}/{stack}"
+ else:
+ stack_path = texture_set
+
+ stack_templates = list(templates[stack_path].keys())
+
+ template_regex = _templates_to_regex(stack_templates,
+ texture_set=texture_set,
+ colorspaces=project_colorspaces,
+ mesh=project_mesh_path,
+ project=project_path)
+
+ # Let's precompile the regexes
+ for template, regex in template_regex.items():
+ template_regex[template] = re.compile(regex)
+
+ stack_results = defaultdict(list)
+ for filepath in sorted(filepaths):
+ # We strip explicitly using the full parent export path instead of
+ # using `os.path.basename` because export template is allowed to
+ # have subfolders in its template which we want to match against
+ filepath = filepath.replace("\\", "/")
+ assert filepath.startswith(export_path), (
+ f"Filepath {filepath} must start with folder {export_path}"
+ )
+ filename = filepath[len(export_path):]
+
+ for template, regex in template_regex.items():
+ match = regex.match(filename)
+ if match:
+ parsed = match.groupdict(default={})
+
+ # Include some special outputs for convenience
+ parsed["filepath"] = filepath
+ parsed["output"] = filename
+
+ stack_results[template].append(parsed)
+ break
+ else:
+ raise ValueError(f"Unable to match {filename} against any "
+ f"template in: {list(template_regex.keys())}")
+
+ result[key] = dict(stack_results)
+
+ return result
+
+
+def load_shelf(path, name=None):
+ """Add shelf to substance painter (for current application session)
+
+ This will dynamically add a Shelf for the current session. It's good
+ to note however that these will *not* persist on restart of the host.
+
+ Note:
+ Consider the loaded shelf a static library of resources.
+
+ The shelf will *not* be visible in application preferences in
+ Edit > Settings > Libraries.
+
+ The shelf will *not* show in the Assets browser if it has no existing
+ assets
+
+ The shelf will *not* be a selectable option for selecting it as a
+ destination to import resources too.
+
+ """
+
+ # Ensure expanded path with forward slashes
+ path = os.path.expandvars(path)
+ path = os.path.abspath(path)
+ path = path.replace("\\", "/")
+
+ # Path must exist
+ if not os.path.isdir(path):
+ raise ValueError(f"Path is not an existing folder: {path}")
+
+ # This name must be unique and must only contain lowercase letters,
+ # numbers, underscores or hyphens.
+ if name is None:
+ name = os.path.basename(path)
+
+ name = name.lower()
+ name = re.sub(r"[^a-z0-9_\-]", "_", name) # sanitize to underscores
+
+ if substance_painter.resource.Shelves.exists(name):
+ shelf = next(
+ shelf for shelf in substance_painter.resource.Shelves.all()
+ if shelf.name() == name
+ )
+ if os.path.normpath(shelf.path()) != os.path.normpath(path):
+ raise ValueError(f"Shelf with name '{name}' already exists "
+ f"for a different path: '{shelf.path()}")
+
+ return
+
+ print(f"Adding Shelf '{name}' to path: {path}")
+ substance_painter.resource.Shelves.add(name, path)
+
+ return name
+
+
+def _get_new_project_action():
+ """Return QAction which triggers Substance Painter's new project dialog"""
+
+ main_window = substance_painter.ui.get_main_window()
+
+ # Find the file menu's New file action
+ menubar = main_window.menuBar()
+ new_action = None
+ for action in menubar.actions():
+ menu = action.menu()
+ if not menu:
+ continue
+
+ if menu.objectName() != "file":
+ continue
+
+ # Find the action with the CTRL+N key sequence
+ new_action = next(action for action in menu.actions()
+ if action.shortcut() == QtGui.QKeySequence.New)
+ break
+
+ return new_action
+
+
+def prompt_new_file_with_mesh(mesh_filepath):
+ """Prompts the user for a new file using Substance Painter's own dialog.
+
+ This will set the mesh path to load to the given mesh and disables the
+ dialog box to disallow the user to change the path. This way we can allow
+ user configuration of a project but set the mesh path ourselves.
+
+ Warning:
+ This is very hacky and experimental.
+
+ Note:
+ If a project is currently open using the same mesh filepath it can't
+ accurately detect whether the user had actually accepted the new project
+ dialog or whether the project afterwards is still the original project,
+ for example when the user might have cancelled the operation.
+
+ """
+
+ app = QtWidgets.QApplication.instance()
+ assert os.path.isfile(mesh_filepath), \
+ f"Mesh filepath does not exist: {mesh_filepath}"
+
+ def _setup_file_dialog():
+ """Set filepath in QFileDialog and trigger accept result"""
+ file_dialog = app.activeModalWidget()
+ assert isinstance(file_dialog, QtWidgets.QFileDialog)
+
+ # Quickly hide the dialog
+ file_dialog.hide()
+ app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 1000)
+
+ file_dialog.setDirectory(os.path.dirname(mesh_filepath))
+ url = QtCore.QUrl.fromLocalFile(os.path.basename(mesh_filepath))
+ file_dialog.selectUrl(url)
+
+ # Give the explorer window time to refresh to the folder and select
+ # the file
+ while not file_dialog.selectedFiles():
+ app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 1000)
+ print(f"Selected: {file_dialog.selectedFiles()}")
+
+ # Set it again now we know the path is refreshed - without this
+ # accepting the dialog will often not trigger the correct filepath
+ file_dialog.setDirectory(os.path.dirname(mesh_filepath))
+ url = QtCore.QUrl.fromLocalFile(os.path.basename(mesh_filepath))
+ file_dialog.selectUrl(url)
+
+ file_dialog.done(file_dialog.Accepted)
+ app.processEvents(QtCore.QEventLoop.AllEvents)
+
+ def _setup_prompt():
+ app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents)
+ dialog = app.activeModalWidget()
+ assert dialog.objectName() == "NewProjectDialog"
+
+ # Set the window title
+ mesh = os.path.basename(mesh_filepath)
+ dialog.setWindowTitle(f"New Project with mesh: {mesh}")
+
+ # Get the select mesh file button
+ mesh_select = dialog.findChild(QtWidgets.QPushButton, "meshSelect")
+
+ # Hide the select mesh button to the user to block changing of mesh
+ mesh_select.setVisible(False)
+
+ # Ensure UI is visually up-to-date
+ app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents)
+
+ # Trigger the 'select file' dialog to set the path and have the
+ # new file dialog to use the path.
+ QtCore.QTimer.singleShot(10, _setup_file_dialog)
+ mesh_select.click()
+
+ app.processEvents(QtCore.QEventLoop.AllEvents, 5000)
+
+ mesh_filename = dialog.findChild(QtWidgets.QFrame, "meshFileName")
+ mesh_filename_label = mesh_filename.findChild(QtWidgets.QLabel)
+ if not mesh_filename_label.text():
+ dialog.close()
+ raise RuntimeError(f"Failed to set mesh path: {mesh_filepath}")
+
+ new_action = _get_new_project_action()
+ if not new_action:
+ raise RuntimeError("Unable to detect new file action..")
+
+ QtCore.QTimer.singleShot(0, _setup_prompt)
+ new_action.trigger()
+ app.processEvents(QtCore.QEventLoop.AllEvents, 5000)
+
+ if not substance_painter.project.is_open():
+ return
+
+ # Confirm mesh was set as expected
+ project_mesh = substance_painter.project.last_imported_mesh_path()
+ if os.path.normpath(project_mesh) != os.path.normpath(mesh_filepath):
+ return
+
+ return project_mesh
diff --git a/openpype/hosts/substancepainter/api/pipeline.py b/openpype/hosts/substancepainter/api/pipeline.py
new file mode 100644
index 0000000000..9406fb8edb
--- /dev/null
+++ b/openpype/hosts/substancepainter/api/pipeline.py
@@ -0,0 +1,427 @@
+# -*- coding: utf-8 -*-
+"""Pipeline tools for OpenPype Substance Painter integration."""
+import os
+import logging
+from functools import partial
+
+# Substance 3D Painter modules
+import substance_painter.ui
+import substance_painter.event
+import substance_painter.project
+
+import pyblish.api
+
+from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost
+from openpype.settings import (
+ get_current_project_settings,
+ get_system_settings
+)
+
+from openpype.pipeline.template_data import get_template_data_with_names
+from openpype.pipeline import (
+ register_creator_plugin_path,
+ register_loader_plugin_path,
+ AVALON_CONTAINER_ID,
+ Anatomy
+)
+from openpype.lib import (
+ StringTemplate,
+ register_event_callback,
+ emit_event,
+)
+from openpype.pipeline.load import any_outdated_containers
+from openpype.hosts.substancepainter import SUBSTANCE_HOST_DIR
+
+from . import lib
+
+log = logging.getLogger("openpype.hosts.substance")
+
+PLUGINS_DIR = os.path.join(SUBSTANCE_HOST_DIR, "plugins")
+PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
+LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
+CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
+INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
+
+OPENPYPE_METADATA_KEY = "OpenPype"
+OPENPYPE_METADATA_CONTAINERS_KEY = "containers" # child key
+OPENPYPE_METADATA_CONTEXT_KEY = "context" # child key
+OPENPYPE_METADATA_INSTANCES_KEY = "instances" # child key
+
+
+class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
+ name = "substancepainter"
+
+ def __init__(self):
+ super(SubstanceHost, self).__init__()
+ self._has_been_setup = False
+ self.menu = None
+ self.callbacks = []
+ self.shelves = []
+
+ def install(self):
+ pyblish.api.register_host("substancepainter")
+
+ pyblish.api.register_plugin_path(PUBLISH_PATH)
+ register_loader_plugin_path(LOAD_PATH)
+ register_creator_plugin_path(CREATE_PATH)
+
+ log.info("Installing callbacks ... ")
+ # register_event_callback("init", on_init)
+ self._register_callbacks()
+ # register_event_callback("before.save", before_save)
+ # register_event_callback("save", on_save)
+ register_event_callback("open", on_open)
+ # register_event_callback("new", on_new)
+
+ log.info("Installing menu ... ")
+ self._install_menu()
+
+ project_settings = get_current_project_settings()
+ self._install_shelves(project_settings)
+
+ self._has_been_setup = True
+
+ def uninstall(self):
+ self._uninstall_shelves()
+ self._uninstall_menu()
+ self._deregister_callbacks()
+
+ def has_unsaved_changes(self):
+
+ if not substance_painter.project.is_open():
+ return False
+
+ return substance_painter.project.needs_saving()
+
+ def get_workfile_extensions(self):
+ return [".spp", ".toc"]
+
+ def save_workfile(self, dst_path=None):
+
+ if not substance_painter.project.is_open():
+ return False
+
+ if not dst_path:
+ dst_path = self.get_current_workfile()
+
+ full_save_mode = substance_painter.project.ProjectSaveMode.Full
+ substance_painter.project.save_as(dst_path, full_save_mode)
+
+ return dst_path
+
+ def open_workfile(self, filepath):
+
+ if not os.path.exists(filepath):
+ raise RuntimeError("File does not exist: {}".format(filepath))
+
+ # We must first explicitly close current project before opening another
+ if substance_painter.project.is_open():
+ substance_painter.project.close()
+
+ substance_painter.project.open(filepath)
+ return filepath
+
+ def get_current_workfile(self):
+ if not substance_painter.project.is_open():
+ return None
+
+ filepath = substance_painter.project.file_path()
+ if filepath and filepath.endswith(".spt"):
+ # When currently in a Substance Painter template assume our
+ # scene isn't saved. This can be the case directly after doing
+ # "New project", the path will then be the template used. This
+ # avoids Workfiles tool trying to save as .spt extension if the
+ # file hasn't been saved before.
+ return
+
+ return filepath
+
+ def get_containers(self):
+
+ if not substance_painter.project.is_open():
+ return
+
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY)
+ if containers:
+ for key, container in containers.items():
+ container["objectName"] = key
+ yield container
+
+ def update_context_data(self, data, changes):
+
+ if not substance_painter.project.is_open():
+ return
+
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ metadata.set(OPENPYPE_METADATA_CONTEXT_KEY, data)
+
+ def get_context_data(self):
+
+ if not substance_painter.project.is_open():
+ return
+
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ return metadata.get(OPENPYPE_METADATA_CONTEXT_KEY) or {}
+
+ def _install_menu(self):
+ from PySide2 import QtWidgets
+ from openpype.tools.utils import host_tools
+
+ parent = substance_painter.ui.get_main_window()
+
+ menu = QtWidgets.QMenu("OpenPype")
+
+ action = menu.addAction("Create...")
+ action.triggered.connect(
+ lambda: host_tools.show_publisher(parent=parent,
+ tab="create")
+ )
+
+ action = menu.addAction("Load...")
+ action.triggered.connect(
+ lambda: host_tools.show_loader(parent=parent, use_context=True)
+ )
+
+ action = menu.addAction("Publish...")
+ action.triggered.connect(
+ lambda: host_tools.show_publisher(parent=parent,
+ tab="publish")
+ )
+
+ action = menu.addAction("Manage...")
+ action.triggered.connect(
+ lambda: host_tools.show_scene_inventory(parent=parent)
+ )
+
+ action = menu.addAction("Library...")
+ action.triggered.connect(
+ lambda: host_tools.show_library_loader(parent=parent)
+ )
+
+ menu.addSeparator()
+ action = menu.addAction("Work Files...")
+ action.triggered.connect(
+ lambda: host_tools.show_workfiles(parent=parent)
+ )
+
+ substance_painter.ui.add_menu(menu)
+
+ def on_menu_destroyed():
+ self.menu = None
+
+ menu.destroyed.connect(on_menu_destroyed)
+
+ self.menu = menu
+
+ def _uninstall_menu(self):
+ if self.menu:
+ self.menu.destroy()
+ self.menu = None
+
+ def _register_callbacks(self):
+ # Prepare emit event callbacks
+ open_callback = partial(emit_event, "open")
+
+ # Connect to the Substance Painter events
+ dispatcher = substance_painter.event.DISPATCHER
+ for event, callback in [
+ (substance_painter.event.ProjectOpened, open_callback)
+ ]:
+ dispatcher.connect(event, callback)
+ # Keep a reference so we can deregister if needed
+ self.callbacks.append((event, callback))
+
+ def _deregister_callbacks(self):
+ for event, callback in self.callbacks:
+ substance_painter.event.DISPATCHER.disconnect(event, callback)
+ self.callbacks.clear()
+
+ def _install_shelves(self, project_settings):
+
+ shelves = project_settings["substancepainter"].get("shelves", {})
+ if not shelves:
+ return
+
+ # Prepare formatting data if we detect any path which might have
+ # template tokens like {asset} in there.
+ formatting_data = {}
+ has_formatting_entries = any("{" in path for path in shelves.values())
+ if has_formatting_entries:
+ project_name = self.get_current_project_name()
+ asset_name = self.get_current_asset_name()
+ task_name = self.get_current_asset_name()
+ system_settings = get_system_settings()
+ formatting_data = get_template_data_with_names(project_name,
+ asset_name,
+ task_name,
+ system_settings)
+ anatomy = Anatomy(project_name)
+ formatting_data["root"] = anatomy.roots
+
+ for name, path in shelves.items():
+ shelf_name = None
+
+ # Allow formatting with anatomy for the paths
+ if "{" in path:
+ path = StringTemplate.format_template(path, formatting_data)
+
+ try:
+ shelf_name = lib.load_shelf(path, name=name)
+ except ValueError as exc:
+ print(f"Failed to load shelf -> {exc}")
+
+ if shelf_name:
+ self.shelves.append(shelf_name)
+
+ def _uninstall_shelves(self):
+ for shelf_name in self.shelves:
+ substance_painter.resource.Shelves.remove(shelf_name)
+ self.shelves.clear()
+
+
+def on_open():
+ log.info("Running callback on open..")
+
+ if any_outdated_containers():
+ from openpype.widgets import popup
+
+ log.warning("Scene has outdated content.")
+
+ # Get main window
+ parent = substance_painter.ui.get_main_window()
+ if parent is None:
+ log.info("Skipping outdated content pop-up "
+ "because Substance window can't be found.")
+ else:
+
+ # Show outdated pop-up
+ def _on_show_inventory():
+ from openpype.tools.utils import host_tools
+ host_tools.show_scene_inventory(parent=parent)
+
+ dialog = popup.Popup(parent=parent)
+ dialog.setWindowTitle("Substance scene has outdated content")
+ dialog.setMessage("There are outdated containers in "
+ "your Substance scene.")
+ dialog.on_clicked.connect(_on_show_inventory)
+ dialog.show()
+
+
+def imprint_container(container,
+ name,
+ namespace,
+ context,
+ loader):
+ """Imprint a loaded container with metadata.
+
+ Containerisation enables a tracking of version, author and origin
+ for loaded assets.
+
+ Arguments:
+ container (dict): The (substance metadata) dictionary to imprint into.
+ name (str): Name of resulting assembly
+ namespace (str): Namespace under which to host container
+ context (dict): Asset information
+ loader (load.LoaderPlugin): loader instance used to produce container.
+
+ Returns:
+ None
+
+ """
+
+ data = [
+ ("schema", "openpype:container-2.0"),
+ ("id", AVALON_CONTAINER_ID),
+ ("name", str(name)),
+ ("namespace", str(namespace) if namespace else None),
+ ("loader", str(loader.__class__.__name__)),
+ ("representation", str(context["representation"]["_id"])),
+ ]
+ for key, value in data:
+ container[key] = value
+
+
+def set_container_metadata(object_name, container_data, update=False):
+ """Helper method to directly set the data for a specific container
+
+ Args:
+ object_name (str): The unique object name identifier for the container
+ container_data (dict): The data for the container.
+ Note 'objectName' data is derived from `object_name` and key in
+ `container_data` will be ignored.
+ update (bool): Whether to only update the dict data.
+
+ """
+ # The objectName is derived from the key in the metadata so won't be stored
+ # in the metadata in the container's data.
+ container_data.pop("objectName", None)
+
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY) or {}
+ if update:
+ existing_data = containers.setdefault(object_name, {})
+ existing_data.update(container_data) # mutable dict, in-place update
+ else:
+ containers[object_name] = container_data
+ metadata.set("containers", containers)
+
+
+def remove_container_metadata(object_name):
+ """Helper method to remove the data for a specific container"""
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY)
+ if containers:
+ containers.pop(object_name, None)
+ metadata.set("containers", containers)
+
+
+def set_instance(instance_id, instance_data, update=False):
+ """Helper method to directly set the data for a specific container
+
+ Args:
+ instance_id (str): Unique identifier for the instance
+ instance_data (dict): The instance data to store in the metaadata.
+ """
+ set_instances({instance_id: instance_data}, update=update)
+
+
+def set_instances(instance_data_by_id, update=False):
+ """Store data for multiple instances at the same time.
+
+ This is more optimal than querying and setting them in the metadata one
+ by one.
+ """
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ instances = metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {}
+
+ for instance_id, instance_data in instance_data_by_id.items():
+ if update:
+ existing_data = instances.get(instance_id, {})
+ existing_data.update(instance_data)
+ else:
+ instances[instance_id] = instance_data
+
+ metadata.set("instances", instances)
+
+
+def remove_instance(instance_id):
+ """Helper method to remove the data for a specific container"""
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ instances = metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {}
+ instances.pop(instance_id, None)
+ metadata.set("instances", instances)
+
+
+def get_instances_by_id():
+ """Return all instances stored in the project instances metadata"""
+ if not substance_painter.project.is_open():
+ return {}
+
+ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY)
+ return metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {}
+
+
+def get_instances():
+ """Return all instances stored in the project instances as a list"""
+ return list(get_instances_by_id().values())
diff --git a/openpype/hosts/substancepainter/deploy/plugins/openpype_plugin.py b/openpype/hosts/substancepainter/deploy/plugins/openpype_plugin.py
new file mode 100644
index 0000000000..e7e1849546
--- /dev/null
+++ b/openpype/hosts/substancepainter/deploy/plugins/openpype_plugin.py
@@ -0,0 +1,36 @@
+
+
+def cleanup_openpype_qt_widgets():
+ """
+ Workaround for Substance failing to shut down correctly
+ when a Qt window was still open at the time of shutting down.
+
+ This seems to work sometimes, but not all the time.
+
+ """
+ # TODO: Create a more reliable method to close down all OpenPype Qt widgets
+ from PySide2 import QtWidgets
+ import substance_painter.ui
+
+ # Kill OpenPype Qt widgets
+ print("Killing OpenPype Qt widgets..")
+ for widget in QtWidgets.QApplication.topLevelWidgets():
+ if widget.__module__.startswith("openpype."):
+ print(f"Deleting widget: {widget.__class__.__name__}")
+ substance_painter.ui.delete_ui_element(widget)
+
+
+def start_plugin():
+ from openpype.pipeline import install_host
+ from openpype.hosts.substancepainter.api import SubstanceHost
+ install_host(SubstanceHost())
+
+
+def close_plugin():
+ from openpype.pipeline import uninstall_host
+ cleanup_openpype_qt_widgets()
+ uninstall_host()
+
+
+if __name__ == "__main__":
+ start_plugin()
diff --git a/openpype/hosts/substancepainter/deploy/startup/openpype_load_on_first_run.py b/openpype/hosts/substancepainter/deploy/startup/openpype_load_on_first_run.py
new file mode 100644
index 0000000000..04b610b4df
--- /dev/null
+++ b/openpype/hosts/substancepainter/deploy/startup/openpype_load_on_first_run.py
@@ -0,0 +1,43 @@
+"""Ease the OpenPype on-boarding process by loading the plug-in on first run"""
+
+OPENPYPE_PLUGIN_NAME = "openpype_plugin"
+
+
+def start_plugin():
+ try:
+ # This isn't exposed in the official API so we keep it in a try-except
+ from painter_plugins_ui import (
+ get_settings,
+ LAUNCH_AT_START_KEY,
+ ON_STATE,
+ PLUGINS_MENU,
+ plugin_manager
+ )
+
+ # The `painter_plugins_ui` plug-in itself is also a startup plug-in
+ # we need to take into account that it could run either earlier or
+ # later than this startup script, we check whether its menu initialized
+ is_before_plugins_menu = PLUGINS_MENU is None
+
+ settings = get_settings(OPENPYPE_PLUGIN_NAME)
+ if settings.value(LAUNCH_AT_START_KEY, None) is None:
+ print("Initializing OpenPype plug-in on first run...")
+ if is_before_plugins_menu:
+ print("- running before 'painter_plugins_ui'")
+ # Delay the launch to the painter_plugins_ui initialization
+ settings.setValue(LAUNCH_AT_START_KEY, ON_STATE)
+ else:
+ # Launch now
+ print("- running after 'painter_plugins_ui'")
+ plugin_manager(OPENPYPE_PLUGIN_NAME)(True)
+
+ # Set the checked state in the menu to avoid confusion
+ action = next(action for action in PLUGINS_MENU._menu.actions()
+ if action.text() == OPENPYPE_PLUGIN_NAME)
+ if action is not None:
+ action.blockSignals(True)
+ action.setChecked(True)
+ action.blockSignals(False)
+
+ except Exception as exc:
+ print(exc)
diff --git a/openpype/hosts/substancepainter/plugins/create/create_textures.py b/openpype/hosts/substancepainter/plugins/create/create_textures.py
new file mode 100644
index 0000000000..dece4b2cc1
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/create/create_textures.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+"""Creator plugin for creating textures."""
+
+from openpype.pipeline import CreatedInstance, Creator, CreatorError
+from openpype.lib import (
+ EnumDef,
+ UILabelDef,
+ NumberDef,
+ BoolDef
+)
+
+from openpype.hosts.substancepainter.api.pipeline import (
+ get_instances,
+ set_instance,
+ set_instances,
+ remove_instance
+)
+from openpype.hosts.substancepainter.api.lib import get_export_presets
+
+import substance_painter.project
+
+
+class CreateTextures(Creator):
+ """Create a texture set."""
+ identifier = "io.openpype.creators.substancepainter.textureset"
+ label = "Textures"
+ family = "textureSet"
+ icon = "picture-o"
+
+ default_variant = "Main"
+
+ def create(self, subset_name, instance_data, pre_create_data):
+
+ if not substance_painter.project.is_open():
+ raise CreatorError("Can't create a Texture Set instance without "
+ "an open project.")
+
+ instance = self.create_instance_in_context(subset_name,
+ instance_data)
+ set_instance(
+ instance_id=instance["instance_id"],
+ instance_data=instance.data_to_store()
+ )
+
+ def collect_instances(self):
+ for instance in get_instances():
+ if (instance.get("creator_identifier") == self.identifier or
+ instance.get("family") == self.family):
+ self.create_instance_in_context_from_existing(instance)
+
+ def update_instances(self, update_list):
+ instance_data_by_id = {}
+ for instance, _changes in update_list:
+ # Persist the data
+ instance_id = instance.get("instance_id")
+ instance_data = instance.data_to_store()
+ instance_data_by_id[instance_id] = instance_data
+ set_instances(instance_data_by_id, update=True)
+
+ def remove_instances(self, instances):
+ for instance in instances:
+ remove_instance(instance["instance_id"])
+ self._remove_instance_from_context(instance)
+
+ # Helper methods (this might get moved into Creator class)
+ def create_instance_in_context(self, subset_name, data):
+ instance = CreatedInstance(
+ self.family, subset_name, data, self
+ )
+ self.create_context.creator_adds_instance(instance)
+ return instance
+
+ def create_instance_in_context_from_existing(self, data):
+ instance = CreatedInstance.from_existing(data, self)
+ self.create_context.creator_adds_instance(instance)
+ return instance
+
+ def get_instance_attr_defs(self):
+
+ return [
+ EnumDef("exportPresetUrl",
+ items=get_export_presets(),
+ label="Output Template"),
+ BoolDef("allowSkippedMaps",
+ label="Allow Skipped Output Maps",
+ tooltip="When enabled this allows the publish to ignore "
+ "output maps in the used output template if one "
+ "or more maps are skipped due to the required "
+ "channels not being present in the current file.",
+ default=True),
+ EnumDef("exportFileFormat",
+ items={
+ None: "Based on output template",
+ # TODO: Get available extensions from substance API
+ "bmp": "bmp",
+ "ico": "ico",
+ "jpeg": "jpeg",
+ "jng": "jng",
+ "pbm": "pbm",
+ "pgm": "pgm",
+ "png": "png",
+ "ppm": "ppm",
+ "tga": "targa",
+ "tif": "tiff",
+ "wap": "wap",
+ "wbmp": "wbmp",
+ "xpm": "xpm",
+ "gif": "gif",
+ "hdr": "hdr",
+ "exr": "exr",
+ "j2k": "j2k",
+ "jp2": "jp2",
+ "pfm": "pfm",
+ "webp": "webp",
+ # TODO: Unsure why jxr format fails to export
+ # "jxr": "jpeg-xr",
+ # TODO: File formats that combine the exported textures
+ # like psd are not correctly supported due to
+ # publishing only a single file
+ # "psd": "psd",
+ # "sbsar": "sbsar",
+ },
+ default=None,
+ label="File type"),
+ EnumDef("exportSize",
+ items={
+ None: "Based on each Texture Set's size",
+ # The key is size of the texture file in log2.
+ # (i.e. 10 means 2^10 = 1024)
+ 7: "128",
+ 8: "256",
+ 9: "512",
+ 10: "1024",
+ 11: "2048",
+ 12: "4096"
+ },
+ default=None,
+ label="Size"),
+
+ EnumDef("exportPadding",
+ items={
+ "passthrough": "No padding (passthrough)",
+ "infinite": "Dilation infinite",
+ "transparent": "Dilation + transparent",
+ "color": "Dilation + default background color",
+ "diffusion": "Dilation + diffusion"
+ },
+ default="infinite",
+ label="Padding"),
+ NumberDef("exportDilationDistance",
+ minimum=0,
+ maximum=256,
+ decimals=0,
+ default=16,
+ label="Dilation Distance"),
+ UILabelDef("*only used with "
+ "'Dilation + ' padding"),
+ ]
+
+ def get_pre_create_attr_defs(self):
+ # Use same attributes as for instance attributes
+ return self.get_instance_attr_defs()
diff --git a/openpype/hosts/substancepainter/plugins/create/create_workfile.py b/openpype/hosts/substancepainter/plugins/create/create_workfile.py
new file mode 100644
index 0000000000..d7f31f9dcf
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/create/create_workfile.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+"""Creator plugin for creating workfiles."""
+
+from openpype.pipeline import CreatedInstance, AutoCreator
+from openpype.client import get_asset_by_name
+
+from openpype.hosts.substancepainter.api.pipeline import (
+ set_instances,
+ set_instance,
+ get_instances
+)
+
+import substance_painter.project
+
+
+class CreateWorkfile(AutoCreator):
+ """Workfile auto-creator."""
+ identifier = "io.openpype.creators.substancepainter.workfile"
+ label = "Workfile"
+ family = "workfile"
+ icon = "document"
+
+ default_variant = "Main"
+
+ def create(self):
+
+ if not substance_painter.project.is_open():
+ return
+
+ variant = self.default_variant
+ project_name = self.project_name
+ asset_name = self.create_context.get_current_asset_name()
+ task_name = self.create_context.get_current_task_name()
+ host_name = self.create_context.host_name
+
+ # Workfile instance should always exist and must only exist once.
+ # As such we'll first check if it already exists and is collected.
+ current_instance = next(
+ (
+ instance for instance in self.create_context.instances
+ if instance.creator_identifier == self.identifier
+ ), None)
+
+ if current_instance is None:
+ self.log.info("Auto-creating workfile instance...")
+ asset_doc = get_asset_by_name(project_name, asset_name)
+ subset_name = self.get_subset_name(
+ variant, task_name, asset_doc, project_name, host_name
+ )
+ data = {
+ "asset": asset_name,
+ "task": task_name,
+ "variant": variant
+ }
+ current_instance = self.create_instance_in_context(subset_name,
+ data)
+ elif (
+ current_instance["asset"] != asset_name
+ or current_instance["task"] != task_name
+ ):
+ # Update instance context if is not the same
+ asset_doc = get_asset_by_name(project_name, asset_name)
+ subset_name = self.get_subset_name(
+ variant, task_name, asset_doc, project_name, host_name
+ )
+ current_instance["asset"] = asset_name
+ current_instance["task"] = task_name
+ current_instance["subset"] = subset_name
+
+ set_instance(
+ instance_id=current_instance.get("instance_id"),
+ instance_data=current_instance.data_to_store()
+ )
+
+ def collect_instances(self):
+ for instance in get_instances():
+ if (instance.get("creator_identifier") == self.identifier or
+ instance.get("family") == self.family):
+ self.create_instance_in_context_from_existing(instance)
+
+ def update_instances(self, update_list):
+ instance_data_by_id = {}
+ for instance, _changes in update_list:
+ # Persist the data
+ instance_id = instance.get("instance_id")
+ instance_data = instance.data_to_store()
+ instance_data_by_id[instance_id] = instance_data
+ set_instances(instance_data_by_id, update=True)
+
+ # Helper methods (this might get moved into Creator class)
+ def create_instance_in_context(self, subset_name, data):
+ instance = CreatedInstance(
+ self.family, subset_name, data, self
+ )
+ self.create_context.creator_adds_instance(instance)
+ return instance
+
+ def create_instance_in_context_from_existing(self, data):
+ instance = CreatedInstance.from_existing(data, self)
+ self.create_context.creator_adds_instance(instance)
+ return instance
diff --git a/openpype/hosts/substancepainter/plugins/load/load_mesh.py b/openpype/hosts/substancepainter/plugins/load/load_mesh.py
new file mode 100644
index 0000000000..822095641d
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/load/load_mesh.py
@@ -0,0 +1,124 @@
+from openpype.pipeline import (
+ load,
+ get_representation_path,
+)
+from openpype.pipeline.load import LoadError
+from openpype.hosts.substancepainter.api.pipeline import (
+ imprint_container,
+ set_container_metadata,
+ remove_container_metadata
+)
+from openpype.hosts.substancepainter.api.lib import prompt_new_file_with_mesh
+
+import substance_painter.project
+import qargparse
+
+
+class SubstanceLoadProjectMesh(load.LoaderPlugin):
+ """Load mesh for project"""
+
+ families = ["*"]
+ representations = ["abc", "fbx", "obj", "gltf"]
+
+ label = "Load mesh"
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ options = [
+ qargparse.Boolean(
+ "preserve_strokes",
+ default=True,
+ help="Preserve strokes positions on mesh.\n"
+ "(only relevant when loading into existing project)"
+ ),
+ qargparse.Boolean(
+ "import_cameras",
+ default=True,
+ help="Import cameras from the mesh file."
+ )
+ ]
+
+ def load(self, context, name, namespace, data):
+
+ # Get user inputs
+ import_cameras = data.get("import_cameras", True)
+ preserve_strokes = data.get("preserve_strokes", True)
+
+ if not substance_painter.project.is_open():
+ # Allow to 'initialize' a new project
+ result = prompt_new_file_with_mesh(mesh_filepath=self.fname)
+ if not result:
+ self.log.info("User cancelled new project prompt.")
+ return
+
+ else:
+ # Reload the mesh
+ settings = substance_painter.project.MeshReloadingSettings(
+ import_cameras=import_cameras,
+ preserve_strokes=preserve_strokes
+ )
+
+ def on_mesh_reload(status: substance_painter.project.ReloadMeshStatus): # noqa
+ if status == substance_painter.project.ReloadMeshStatus.SUCCESS: # noqa
+ self.log.info("Reload succeeded")
+ else:
+ raise LoadError("Reload of mesh failed")
+
+ path = self.fname
+ substance_painter.project.reload_mesh(path,
+ settings,
+ on_mesh_reload)
+
+ # Store container
+ container = {}
+ project_mesh_object_name = "_ProjectMesh_"
+ imprint_container(container,
+ name=project_mesh_object_name,
+ namespace=project_mesh_object_name,
+ context=context,
+ loader=self)
+
+ # We want store some options for updating to keep consistent behavior
+ # from the user's original choice. We don't store 'preserve_strokes'
+ # as we always preserve strokes on updates.
+ container["options"] = {
+ "import_cameras": import_cameras,
+ }
+
+ set_container_metadata(project_mesh_object_name, container)
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def update(self, container, representation):
+
+ path = get_representation_path(representation)
+
+ # Reload the mesh
+ container_options = container.get("options", {})
+ settings = substance_painter.project.MeshReloadingSettings(
+ import_cameras=container_options.get("import_cameras", True),
+ preserve_strokes=True
+ )
+
+ def on_mesh_reload(status: substance_painter.project.ReloadMeshStatus):
+ if status == substance_painter.project.ReloadMeshStatus.SUCCESS:
+ self.log.info("Reload succeeded")
+ else:
+ raise LoadError("Reload of mesh failed")
+
+ substance_painter.project.reload_mesh(path, settings, on_mesh_reload)
+
+ # Update container representation
+ object_name = container["objectName"]
+ update_data = {"representation": str(representation["_id"])}
+ set_container_metadata(object_name, update_data, update=True)
+
+ def remove(self, container):
+
+ # Remove OpenPype related settings about what model was loaded
+ # or close the project?
+ # TODO: This is likely best 'hidden' away to the user because
+ # this will leave the project's mesh unmanaged.
+ remove_container_metadata(container["objectName"])
diff --git a/openpype/hosts/substancepainter/plugins/publish/collect_current_file.py b/openpype/hosts/substancepainter/plugins/publish/collect_current_file.py
new file mode 100644
index 0000000000..9a37eb0d1c
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/collect_current_file.py
@@ -0,0 +1,17 @@
+import pyblish.api
+
+from openpype.pipeline import registered_host
+
+
+class CollectCurrentFile(pyblish.api.ContextPlugin):
+ """Inject the current working file into context"""
+
+ order = pyblish.api.CollectorOrder - 0.49
+ label = "Current Workfile"
+ hosts = ["substancepainter"]
+
+ def process(self, context):
+ host = registered_host()
+ path = host.get_current_workfile()
+ context.data["currentFile"] = path
+ self.log.debug(f"Current workfile: {path}")
diff --git a/openpype/hosts/substancepainter/plugins/publish/collect_textureset_images.py b/openpype/hosts/substancepainter/plugins/publish/collect_textureset_images.py
new file mode 100644
index 0000000000..d11abd1019
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/collect_textureset_images.py
@@ -0,0 +1,196 @@
+import os
+import copy
+import pyblish.api
+
+from openpype.pipeline import publish
+
+import substance_painter.textureset
+from openpype.hosts.substancepainter.api.lib import (
+ get_parsed_export_maps,
+ strip_template
+)
+from openpype.pipeline.create import get_subset_name
+from openpype.client import get_asset_by_name
+
+
+class CollectTextureSet(pyblish.api.InstancePlugin):
+ """Extract Textures using an output template config"""
+ # TODO: Production-test usage of color spaces
+ # TODO: Detect what source data channels end up in each file
+
+ label = "Collect Texture Set images"
+ hosts = ["substancepainter"]
+ families = ["textureSet"]
+ order = pyblish.api.CollectorOrder
+
+ def process(self, instance):
+
+ config = self.get_export_config(instance)
+ asset_doc = get_asset_by_name(
+ project_name=instance.context.data["projectName"],
+ asset_name=instance.data["asset"]
+ )
+
+ instance.data["exportConfig"] = config
+ maps = get_parsed_export_maps(config)
+
+ # Let's break the instance into multiple instances to integrate
+ # a subset per generated texture or texture UDIM sequence
+ for (texture_set_name, stack_name), template_maps in maps.items():
+ self.log.info(f"Processing {texture_set_name}/{stack_name}")
+ for template, outputs in template_maps.items():
+ self.log.info(f"Processing {template}")
+ self.create_image_instance(instance, template, outputs,
+ asset_doc=asset_doc,
+ texture_set_name=texture_set_name,
+ stack_name=stack_name)
+
+ def create_image_instance(self, instance, template, outputs,
+ asset_doc, texture_set_name, stack_name):
+ """Create a new instance per image or UDIM sequence.
+
+ The new instances will be of family `image`.
+
+ """
+
+ context = instance.context
+ first_filepath = outputs[0]["filepath"]
+ fnames = [os.path.basename(output["filepath"]) for output in outputs]
+ ext = os.path.splitext(first_filepath)[1]
+ assert ext.lstrip("."), f"No extension: {ext}"
+
+ always_include_texture_set_name = False # todo: make this configurable
+ all_texture_sets = substance_painter.textureset.all_texture_sets()
+ texture_set = substance_painter.textureset.TextureSet.from_name(
+ texture_set_name
+ )
+
+ # Define the suffix we want to give this particular texture
+ # set and set up a remapped subset naming for it.
+ suffix = ""
+ if always_include_texture_set_name or len(all_texture_sets) > 1:
+ # More than one texture set, include texture set name
+ suffix += f".{texture_set_name}"
+ if texture_set.is_layered_material() and stack_name:
+ # More than one stack, include stack name
+ suffix += f".{stack_name}"
+
+ # Always include the map identifier
+ map_identifier = strip_template(template)
+ suffix += f".{map_identifier}"
+
+ image_subset = get_subset_name(
+ # TODO: The family actually isn't 'texture' currently but for now
+ # this is only done so the subset name starts with 'texture'
+ family="texture",
+ variant=instance.data["variant"] + suffix,
+ task_name=instance.data.get("task"),
+ asset_doc=asset_doc,
+ project_name=context.data["projectName"],
+ host_name=context.data["hostName"],
+ project_settings=context.data["project_settings"]
+ )
+
+ # Prepare representation
+ representation = {
+ "name": ext.lstrip("."),
+ "ext": ext.lstrip("."),
+ "files": fnames if len(fnames) > 1 else fnames[0],
+ }
+
+ # Mark as UDIM explicitly if it has UDIM tiles.
+ if bool(outputs[0].get("udim")):
+ # The representation for a UDIM sequence should have a `udim` key
+ # that is a list of all udim tiles (str) like: ["1001", "1002"]
+ # strings. See CollectTextures plug-in and Integrators.
+ representation["udim"] = [output["udim"] for output in outputs]
+
+ # Set up the representation for thumbnail generation
+ # TODO: Simplify this once thumbnail extraction is refactored
+ staging_dir = os.path.dirname(first_filepath)
+ representation["tags"] = ["review"]
+ representation["stagingDir"] = staging_dir
+
+ # Clone the instance
+ image_instance = context.create_instance(image_subset)
+ image_instance[:] = instance[:]
+ image_instance.data.update(copy.deepcopy(instance.data))
+ image_instance.data["name"] = image_subset
+ image_instance.data["label"] = image_subset
+ image_instance.data["subset"] = image_subset
+ image_instance.data["family"] = "image"
+ image_instance.data["families"] = ["image", "textures"]
+ image_instance.data["representations"] = [representation]
+
+ # Group the textures together in the loader
+ image_instance.data["subsetGroup"] = instance.data["subset"]
+
+ # Store the texture set name and stack name on the instance
+ image_instance.data["textureSetName"] = texture_set_name
+ image_instance.data["textureStackName"] = stack_name
+
+ # Store color space with the instance
+ # Note: The extractor will assign it to the representation
+ colorspace = outputs[0].get("colorSpace")
+ if colorspace:
+ self.log.debug(f"{image_subset} colorspace: {colorspace}")
+ image_instance.data["colorspace"] = colorspace
+
+ # Store the instance in the original instance as a member
+ instance.append(image_instance)
+
+ def get_export_config(self, instance):
+ """Return an export configuration dict for texture exports.
+
+ This config can be supplied to:
+ - `substance_painter.export.export_project_textures`
+ - `substance_painter.export.list_project_textures`
+
+ See documentation on substance_painter.export module about the
+ formatting of the configuration dictionary.
+
+ Args:
+ instance (pyblish.api.Instance): Texture Set instance to be
+ published.
+
+ Returns:
+ dict: Export config
+
+ """
+
+ creator_attrs = instance.data["creator_attributes"]
+ preset_url = creator_attrs["exportPresetUrl"]
+ self.log.debug(f"Exporting using preset: {preset_url}")
+
+ # See: https://substance3d.adobe.com/documentation/ptpy/api/substance_painter/export # noqa
+ config = { # noqa
+ "exportShaderParams": True,
+ "exportPath": publish.get_instance_staging_dir(instance),
+ "defaultExportPreset": preset_url,
+
+ # Custom overrides to the exporter
+ "exportParameters": [
+ {
+ "parameters": {
+ "fileFormat": creator_attrs["exportFileFormat"],
+ "sizeLog2": creator_attrs["exportSize"],
+ "paddingAlgorithm": creator_attrs["exportPadding"],
+ "dilationDistance": creator_attrs["exportDilationDistance"] # noqa
+ }
+ }
+ ]
+ }
+
+ # Create the list of Texture Sets to export.
+ config["exportList"] = []
+ for texture_set in substance_painter.textureset.all_texture_sets():
+ config["exportList"].append({"rootPath": texture_set.name()})
+
+ # Consider None values from the creator attributes optionals
+ for override in config["exportParameters"]:
+ parameters = override.get("parameters")
+ for key, value in dict(parameters).items():
+ if value is None:
+ parameters.pop(key)
+
+ return config
diff --git a/openpype/hosts/substancepainter/plugins/publish/collect_workfile_representation.py b/openpype/hosts/substancepainter/plugins/publish/collect_workfile_representation.py
new file mode 100644
index 0000000000..8d98d0b014
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/collect_workfile_representation.py
@@ -0,0 +1,26 @@
+import os
+import pyblish.api
+
+
+class CollectWorkfileRepresentation(pyblish.api.InstancePlugin):
+ """Create a publish representation for the current workfile instance."""
+
+ order = pyblish.api.CollectorOrder
+ label = "Workfile representation"
+ hosts = ["substancepainter"]
+ families = ["workfile"]
+
+ def process(self, instance):
+
+ context = instance.context
+ current_file = context.data["currentFile"]
+
+ folder, file = os.path.split(current_file)
+ filename, ext = os.path.splitext(file)
+
+ instance.data["representations"] = [{
+ "name": ext.lstrip("."),
+ "ext": ext.lstrip("."),
+ "files": file,
+ "stagingDir": folder,
+ }]
diff --git a/openpype/hosts/substancepainter/plugins/publish/extract_textures.py b/openpype/hosts/substancepainter/plugins/publish/extract_textures.py
new file mode 100644
index 0000000000..bb6f15ead9
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/extract_textures.py
@@ -0,0 +1,62 @@
+import substance_painter.export
+
+from openpype.pipeline import KnownPublishError, publish
+
+
+class ExtractTextures(publish.Extractor,
+ publish.ColormanagedPyblishPluginMixin):
+ """Extract Textures using an output template config.
+
+ Note:
+ This Extractor assumes that `collect_textureset_images` has prepared
+ the relevant export config and has also collected the individual image
+ instances for publishing including its representation. That is why this
+ particular Extractor doesn't specify representations to integrate.
+
+ """
+
+ label = "Extract Texture Set"
+ hosts = ["substancepainter"]
+ families = ["textureSet"]
+
+ # Run before thumbnail extractors
+ order = publish.Extractor.order - 0.1
+
+ def process(self, instance):
+
+ config = instance.data["exportConfig"]
+ result = substance_painter.export.export_project_textures(config)
+
+ if result.status != substance_painter.export.ExportStatus.Success:
+ raise KnownPublishError(
+ "Failed to export texture set: {}".format(result.message)
+ )
+
+ # Log what files we generated
+ for (texture_set_name, stack_name), maps in result.textures.items():
+ # Log our texture outputs
+ self.log.info(f"Exported stack: {texture_set_name} {stack_name}")
+ for texture_map in maps:
+ self.log.info(f"Exported texture: {texture_map}")
+
+ # We'll insert the color space data for each image instance that we
+ # added into this texture set. The collector couldn't do so because
+ # some anatomy and other instance data needs to be collected prior
+ context = instance.context
+ for image_instance in instance:
+ representation = next(iter(image_instance.data["representations"]))
+
+ colorspace = image_instance.data.get("colorspace")
+ if not colorspace:
+ self.log.debug("No color space data present for instance: "
+ f"{image_instance}")
+ continue
+
+ self.set_representation_colorspace(representation,
+ context=context,
+ colorspace=colorspace)
+
+ # The TextureSet instance should not be integrated. It generates no
+ # output data. Instead the separated texture instances are generated
+ # from it which themselves integrate into the database.
+ instance.data["integrate"] = False
diff --git a/openpype/hosts/substancepainter/plugins/publish/increment_workfile.py b/openpype/hosts/substancepainter/plugins/publish/increment_workfile.py
new file mode 100644
index 0000000000..b45d66fbb1
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/increment_workfile.py
@@ -0,0 +1,23 @@
+import pyblish.api
+
+from openpype.lib import version_up
+from openpype.pipeline import registered_host
+
+
+class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
+ """Increment current workfile version."""
+
+ order = pyblish.api.IntegratorOrder + 1
+ label = "Increment Workfile Version"
+ optional = True
+ hosts = ["substancepainter"]
+
+ def process(self, context):
+
+ assert all(result["success"] for result in context.data["results"]), (
+ "Publishing not successful so version is not increased.")
+
+ host = registered_host()
+ path = context.data["currentFile"]
+ self.log.info(f"Incrementing current workfile to: {path}")
+ host.save_workfile(version_up(path))
diff --git a/openpype/hosts/substancepainter/plugins/publish/save_workfile.py b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py
new file mode 100644
index 0000000000..4874b5e5c7
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py
@@ -0,0 +1,27 @@
+import pyblish.api
+
+from openpype.pipeline import (
+ registered_host,
+ KnownPublishError
+)
+
+
+class SaveCurrentWorkfile(pyblish.api.ContextPlugin):
+ """Save current workfile"""
+
+ label = "Save current workfile"
+ order = pyblish.api.ExtractorOrder - 0.49
+ hosts = ["substancepainter"]
+
+ def process(self, context):
+
+ host = registered_host()
+ if context.data["currentFile"] != host.get_current_workfile():
+ raise KnownPublishError("Workfile has changed during publishing!")
+
+ if host.has_unsaved_changes():
+ self.log.info("Saving current file..")
+ host.save_workfile()
+ else:
+ self.log.debug("Skipping workfile save because there are no "
+ "unsaved changes.")
diff --git a/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py
new file mode 100644
index 0000000000..b57cf4c5a2
--- /dev/null
+++ b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py
@@ -0,0 +1,109 @@
+import copy
+import os
+
+import pyblish.api
+
+import substance_painter.export
+
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateOutputMaps(pyblish.api.InstancePlugin):
+ """Validate all output maps for Output Template are generated.
+
+ Output maps will be skipped by Substance Painter if it is an output
+ map in the Substance Output Template which uses channels that the current
+ substance painter project has not painted or generated.
+
+ """
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate output maps"
+ hosts = ["substancepainter"]
+ families = ["textureSet"]
+
+ def process(self, instance):
+
+ config = instance.data["exportConfig"]
+
+ # Substance Painter API does not allow to query the actual output maps
+ # it will generate without actually exporting the files. So we try to
+ # generate the smallest size / fastest export as possible
+ config = copy.deepcopy(config)
+ parameters = config["exportParameters"][0]["parameters"]
+ parameters["sizeLog2"] = [1, 1] # output 2x2 images (smallest)
+ parameters["paddingAlgorithm"] = "passthrough" # no dilation (faster)
+ parameters["dithering"] = False # no dithering (faster)
+
+ result = substance_painter.export.export_project_textures(config)
+ if result.status != substance_painter.export.ExportStatus.Success:
+ raise PublishValidationError(
+ "Failed to export texture set: {}".format(result.message)
+ )
+
+ generated_files = set()
+ for texture_maps in result.textures.values():
+ for texture_map in texture_maps:
+ generated_files.add(os.path.normpath(texture_map))
+ # Directly clean up our temporary export
+ os.remove(texture_map)
+
+ creator_attributes = instance.data.get("creator_attributes", {})
+ allow_skipped_maps = creator_attributes.get("allowSkippedMaps", True)
+ error_report_missing = []
+ for image_instance in instance:
+
+ # Confirm whether the instance has its expected files generated.
+ # We assume there's just one representation and that it is
+ # the actual texture representation from the collector.
+ representation = next(iter(image_instance.data["representations"]))
+ staging_dir = representation["stagingDir"]
+ filenames = representation["files"]
+ if not isinstance(filenames, (list, tuple)):
+ # Convert single file to list
+ filenames = [filenames]
+
+ missing = []
+ for filename in filenames:
+ filepath = os.path.join(staging_dir, filename)
+ filepath = os.path.normpath(filepath)
+ if filepath not in generated_files:
+ self.log.warning(f"Missing texture: {filepath}")
+ missing.append(filepath)
+
+ if not missing:
+ continue
+
+ if allow_skipped_maps:
+ # TODO: This is changing state on the instance's which
+ # should not be done during validation.
+ self.log.warning(f"Disabling texture instance: "
+ f"{image_instance}")
+ image_instance.data["active"] = False
+ image_instance.data["integrate"] = False
+ representation.setdefault("tags", []).append("delete")
+ continue
+ else:
+ error_report_missing.append((image_instance, missing))
+
+ if error_report_missing:
+
+ message = (
+ "The Texture Set skipped exporting some output maps which are "
+ "defined in the Output Template. This happens if the Output "
+ "Templates exports maps from channels which you do not "
+ "have in your current Substance Painter project.\n\n"
+ "To allow this enable the *Allow Skipped Output Maps* setting "
+ "on the instance.\n\n"
+ f"Instance {instance} skipped exporting output maps:\n"
+ ""
+ )
+
+ for image_instance, missing in error_report_missing:
+ missing_str = ", ".join(missing)
+ message += f"- **{image_instance}** skipped: {missing_str}\n"
+
+ raise PublishValidationError(
+ message=message,
+ title="Missing output maps"
+ )
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_review_frames.py b/openpype/hosts/traypublisher/plugins/publish/collect_review_frames.py
new file mode 100644
index 0000000000..6b41c0dd21
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_review_frames.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+
+
+class CollectReviewInfo(pyblish.api.InstancePlugin):
+ """Collect data required for review instances.
+
+ ExtractReview plugin requires frame start/end, fps on instance data which
+ are missing on instances from TrayPublishes.
+
+ Warning:
+ This is temporary solution to "make it work". Contains removed changes
+ from https://github.com/ynput/OpenPype/pull/4383 reduced only for
+ review instances.
+ """
+
+ label = "Collect Review Info"
+ order = pyblish.api.CollectorOrder + 0.491
+ families = ["review"]
+ hosts = ["traypublisher"]
+
+ def process(self, instance):
+ asset_entity = instance.data.get("assetEntity")
+ if instance.data.get("frameStart") is not None or not asset_entity:
+ self.log.debug("Missing required data on instance")
+ return
+
+ asset_data = asset_entity["data"]
+ # Store collected data for logging
+ collected_data = {}
+ for key in (
+ "fps",
+ "frameStart",
+ "frameEnd",
+ "handleStart",
+ "handleEnd",
+ ):
+ if key in instance.data or key not in asset_data:
+ continue
+ value = asset_data[key]
+ collected_data[key] = value
+ instance.data[key] = value
+ self.log.debug("Collected data: {}".format(str(collected_data)))
diff --git a/openpype/hosts/unreal/README.md b/openpype/hosts/unreal/README.md
index 0a69b9e0cf..d131105659 100644
--- a/openpype/hosts/unreal/README.md
+++ b/openpype/hosts/unreal/README.md
@@ -4,6 +4,6 @@ Supported Unreal Engine version is 4.26+ (mainly because of major Python changes
### Project naming
Unreal doesn't support project names starting with non-alphabetic character. So names like `123_myProject` are
-invalid. If OpenPype detects such name it automatically prepends letter **P** to make it valid name, so `123_myProject`
+invalid. If Ayon detects such name it automatically prepends letter **P** to make it valid name, so `123_myProject`
will become `P123_myProject`. There is also soft-limit on project name length to be shorter than 20 characters.
-Longer names will issue warning in Unreal Editor that there might be possible side effects.
\ No newline at end of file
+Longer names will issue warning in Unreal Editor that there might be possible side effects.
diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py
index 24e2db975d..db40d629bc 100644
--- a/openpype/hosts/unreal/addon.py
+++ b/openpype/hosts/unreal/addon.py
@@ -1,5 +1,8 @@
import os
-from openpype.modules import OpenPypeModule, IHostAddon
+from pathlib import Path
+
+from openpype.modules import IHostAddon, OpenPypeModule
+from .lib import get_compatible_integration
UNREAL_ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -13,15 +16,22 @@ class UnrealAddon(OpenPypeModule, IHostAddon):
def add_implementation_envs(self, env, app):
"""Modify environments to contain all required for implementation."""
- # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation
+ # Set AYON_UNREAL_PLUGIN required for Unreal implementation
- ue_plugin = "UE_5.0" if app.name[:1] == "5" else "UE_4.7"
+ ue_version = app.name.replace("-", ".")
unreal_plugin_path = os.path.join(
- UNREAL_ROOT_DIR, "integration", ue_plugin, "OpenPype"
+ UNREAL_ROOT_DIR, "integration", f"UE_{ue_version}", "Ayon"
)
- if not env.get("OPENPYPE_UNREAL_PLUGIN") or \
- env.get("OPENPYPE_UNREAL_PLUGIN") != unreal_plugin_path:
- env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path
+ if not Path(unreal_plugin_path).exists():
+ if compatible_versions := get_compatible_integration(
+ ue_version, Path(UNREAL_ROOT_DIR) / "integration"
+ ):
+ unreal_plugin_path = compatible_versions[-1] / "Ayon"
+ unreal_plugin_path = unreal_plugin_path.as_posix()
+
+ if not env.get("AYON_UNREAL_PLUGIN") or \
+ env.get("AYON_UNREAL_PLUGIN") != unreal_plugin_path:
+ env["AYON_UNREAL_PLUGIN"] = unreal_plugin_path
# Set default environments if are not set via settings
defaults = {
diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py
index 2618a7677c..de0fce13d5 100644
--- a/openpype/hosts/unreal/api/__init__.py
+++ b/openpype/hosts/unreal/api/__init__.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-"""Unreal Editor OpenPype host API."""
+"""Unreal Editor Ayon host API."""
from .plugin import (
UnrealActorCreator,
diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py
index 0b6f07f52f..e9ab3fb4c5 100644
--- a/openpype/hosts/unreal/api/helpers.py
+++ b/openpype/hosts/unreal/api/helpers.py
@@ -2,15 +2,15 @@
import unreal # noqa
-class OpenPypeUnrealException(Exception):
+class AyonUnrealException(Exception):
pass
@unreal.uclass()
-class OpenPypeHelpers(unreal.OpenPypeLib):
- """Class wrapping some useful functions for OpenPype.
+class AyonHelpers(unreal.AyonLib):
+ """Class wrapping some useful functions for Ayon.
- This class is extending native BP class in OpenPype Integration Plugin.
+ This class is extending native BP class in Ayon Integration Plugin.
"""
@@ -29,13 +29,13 @@ class OpenPypeHelpers(unreal.OpenPypeLib):
Example:
- OpenPypeHelpers().set_folder_color(
+ AyonHelpers().set_folder_color(
"/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0)
)
Note:
This will take effect only after Editor is restarted. I couldn't
- find a way to refresh it. Also this saves the color definition
+ find a way to refresh it. Also, this saves the color definition
into the project config, binding this path with color. So if you
delete this path and later re-create, it will set this color
again.
diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py
index 1a7c626984..bb45fa8c01 100644
--- a/openpype/hosts/unreal/api/pipeline.py
+++ b/openpype/hosts/unreal/api/pipeline.py
@@ -14,7 +14,7 @@ from openpype.pipeline import (
register_creator_plugin_path,
deregister_loader_plugin_path,
deregister_creator_plugin_path,
- AVALON_CONTAINER_ID,
+ AYON_CONTAINER_ID,
)
from openpype.tools.utils import host_tools
import openpype.hosts.unreal
@@ -22,12 +22,13 @@ from openpype.host import HostBase, ILoadHost, IPublishHost
import unreal # noqa
+# Rename to Ayon once parent module renames
logger = logging.getLogger("openpype.hosts.unreal")
-OPENPYPE_CONTAINERS = "OpenPypeContainers"
-CONTEXT_CONTAINER = "OpenPype/context.json"
+AYON_CONTAINERS = "AyonContainers"
+CONTEXT_CONTAINER = "Ayon/context.json"
UNREAL_VERSION = semver.VersionInfo(
- *os.getenv("OPENPYPE_UNREAL_VERSION").split(".")
+ *os.getenv("AYON_UNREAL_VERSION").split(".")
)
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__))
@@ -53,14 +54,14 @@ class UnrealHost(HostBase, ILoadHost, IPublishHost):
def get_containers(self):
return ls()
- def show_tools_popup(self):
+ @staticmethod
+ def show_tools_popup():
"""Show tools popup with actions leading to show other tools."""
-
show_tools_popup()
- def show_tools_dialog(self):
+ @staticmethod
+ def show_tools_dialog():
"""Show tools dialog with actions leading to show other tools."""
-
show_tools_dialog()
def update_context_data(self, data, changes):
@@ -72,9 +73,10 @@ class UnrealHost(HostBase, ILoadHost, IPublishHost):
with open(op_ctx, "w+") as f:
json.dump(data, f)
break
- except IOError:
+ except IOError as e:
if i == attempts - 1:
- raise Exception("Failed to write context data. Aborting.")
+ raise Exception(
+ "Failed to write context data. Aborting.") from e
unreal.log_warning("Failed to write context data. Retrying...")
i += 1
time.sleep(3)
@@ -95,19 +97,30 @@ def install():
print("-=" * 40)
logo = '''.
.
- ____________
- / \\ __ \\
- \\ \\ \\/_\\ \\
- \\ \\ _____/ ______
- \\ \\ \\___// \\ \\
- \\ \\____\\ \\ \\_____\\
- \\/_____/ \\/______/ PYPE Club .
+ ·
+ │
+ ·∙/
+ ·-∙•∙-·
+ / \\ /∙· / \\
+ ∙ \\ │ / ∙
+ \\ \\ · / /
+ \\\\ ∙ ∙ //
+ \\\\/ \\//
+ ___
+ │ │
+ │ │
+ │ │
+ │___│
+ -·
+
+ ·-─═─-∙ A Y O N ∙-─═─-·
+ by YNPUT
.
'''
print(logo)
- print("installing OpenPype for Unreal ...")
+ print("installing Ayon for Unreal ...")
print("-=" * 40)
- logger.info("installing OpenPype for Unreal")
+ logger.info("installing Ayon for Unreal")
pyblish.api.register_host("unreal")
pyblish.api.register_plugin_path(str(PUBLISH_PATH))
register_loader_plugin_path(str(LOAD_PATH))
@@ -117,7 +130,7 @@ def install():
def uninstall():
- """Uninstall Unreal configuration for Avalon."""
+ """Uninstall Unreal configuration for Ayon."""
pyblish.api.deregister_plugin_path(str(PUBLISH_PATH))
deregister_loader_plugin_path(str(LOAD_PATH))
deregister_creator_plugin_path(str(CREATE_PATH))
@@ -125,14 +138,14 @@ def uninstall():
def _register_callbacks():
"""
- TODO: Implement callbacks if supported by UE4
+ TODO: Implement callbacks if supported by UE
"""
pass
def _register_events():
"""
- TODO: Implement callbacks if supported by UE4
+ TODO: Implement callbacks if supported by UE
"""
pass
@@ -146,32 +159,30 @@ def ls():
"""
ar = unreal.AssetRegistryHelpers.get_asset_registry()
# UE 5.1 changed how class name is specified
- class_name = ["/Script/OpenPype", "AssetContainer"] if UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor > 0 else "AssetContainer" # noqa
- openpype_containers = ar.get_assets_by_class(class_name, True)
+ class_name = ["/Script/Ayon", "AyonAssetContainer"] if UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor > 0 else "AyonAssetContainer" # noqa
+ ayon_containers = ar.get_assets_by_class(class_name, True)
# get_asset_by_class returns AssetData. To get all metadata we need to
# load asset. get_tag_values() work only on metadata registered in
# Asset Registry Project settings (and there is no way to set it with
# python short of editing ini configuration file).
- for asset_data in openpype_containers:
+ for asset_data in ayon_containers:
asset = asset_data.get_asset()
data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset)
data["objectName"] = asset_data.asset_name
- data = cast_map_to_str_dict(data)
-
- yield data
+ yield cast_map_to_str_dict(data)
def ls_inst():
ar = unreal.AssetRegistryHelpers.get_asset_registry()
# UE 5.1 changed how class name is specified
class_name = [
- "/Script/OpenPype",
- "OpenPypePublishInstance"
+ "/Script/Ayon",
+ "AyonPublishInstance"
] if (
UNREAL_VERSION.major == 5
and UNREAL_VERSION.minor > 0
- ) else "OpenPypePublishInstance" # noqa
+ ) else "AyonPublishInstance" # noqa
instances = ar.get_assets_by_class(class_name, True)
# get_asset_by_class returns AssetData. To get all metadata we need to
@@ -182,13 +193,11 @@ def ls_inst():
asset = asset_data.get_asset()
data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset)
data["objectName"] = asset_data.asset_name
- data = cast_map_to_str_dict(data)
-
- yield data
+ yield cast_map_to_str_dict(data)
def parse_container(container):
- """To get data from container, AssetContainer must be loaded.
+ """To get data from container, AyonAssetContainer must be loaded.
Args:
container(str): path to container
@@ -217,7 +226,7 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"):
Unreal doesn't support *groups* of assets that you can add metadata to.
But it does support folders that helps to organize asset. Unfortunately
those folders are just that - you cannot add any additional information
- to them. OpenPype Integration Plugin is providing way out - Implementing
+ to them. Ayon Integration Plugin is providing way out - Implementing
`AssetContainer` Blueprint class. This class when added to folder can
handle metadata on it using standard
:func:`unreal.EditorAssetLibrary.set_metadata_tag()` and
@@ -226,30 +235,30 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"):
those assets is available as `assets` property.
This is list of strings starting with asset type and ending with its path:
- `Material /Game/OpenPype/Test/TestMaterial.TestMaterial`
+ `Material /Game/Ayon/Test/TestMaterial.TestMaterial`
"""
# 1 - create directory for container
root = "/Game"
- container_name = "{}{}".format(name, suffix)
+ container_name = f"{name}{suffix}"
new_name = move_assets_to_path(root, container_name, nodes)
# 2 - create Asset Container there
- path = "{}/{}".format(root, new_name)
+ path = f"{root}/{new_name}"
create_container(container=container_name, path=path)
namespace = path
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"name": new_name,
"namespace": namespace,
"loader": str(loader),
"representation": context["representation"]["_id"],
}
# 3 - imprint data
- imprint("{}/{}".format(path, container_name), data)
+ imprint(f"{path}/{container_name}", data)
return path
@@ -257,7 +266,7 @@ def instantiate(root, name, data, assets=None, suffix="_INS"):
"""Bundles *nodes* into *container*.
Marking it with metadata as publishable instance. If assets are provided,
- they are moved to new path where `OpenPypePublishInstance` class asset is
+ they are moved to new path where `AyonPublishInstance` class asset is
created and imprinted with metadata.
This can then be collected for publishing by Pyblish for example.
@@ -271,7 +280,7 @@ def instantiate(root, name, data, assets=None, suffix="_INS"):
suffix (str): suffix string to append to instance name
"""
- container_name = "{}{}".format(name, suffix)
+ container_name = f"{name}{suffix}"
# if we specify assets, create new folder and move them there. If not,
# just create empty folder
@@ -280,10 +289,10 @@ def instantiate(root, name, data, assets=None, suffix="_INS"):
else:
new_name = create_folder(root, name)
- path = "{}/{}".format(root, new_name)
+ path = f"{root}/{new_name}"
create_publish_instance(instance=container_name, path=path)
- imprint("{}/{}".format(path, container_name), data)
+ imprint(f"{path}/{container_name}", data)
def imprint(node, data):
@@ -299,7 +308,7 @@ def imprint(node, data):
loaded_asset, key, str(value)
)
- with unreal.ScopedEditorTransaction("OpenPype containerising"):
+ with unreal.ScopedEditorTransaction("Ayon containerising"):
unreal.EditorAssetLibrary.save_asset(node)
@@ -366,11 +375,11 @@ def create_folder(root: str, name: str) -> str:
eal = unreal.EditorAssetLibrary
index = 1
while True:
- if eal.does_directory_exist("{}/{}".format(root, name)):
- name = "{}{}".format(name, index)
+ if eal.does_directory_exist(f"{root}/{name}"):
+ name = f"{name}{index}"
index += 1
else:
- eal.make_directory("{}/{}".format(root, name))
+ eal.make_directory(f"{root}/{name}")
break
return name
@@ -403,9 +412,7 @@ def move_assets_to_path(root: str, name: str, assets: List[str]) -> str:
unreal.log(assets)
for asset in assets:
loaded = eal.load_asset(asset)
- eal.rename_asset(
- asset, "{}/{}/{}".format(root, name, loaded.get_name())
- )
+ eal.rename_asset(asset, f"{root}/{name}/{loaded.get_name()}")
return name
@@ -432,17 +439,16 @@ def create_container(container: str, path: str) -> unreal.Object:
)
"""
- factory = unreal.AssetContainerFactory()
+ factory = unreal.AyonAssetContainerFactory()
tools = unreal.AssetToolsHelpers().get_asset_tools()
- asset = tools.create_asset(container, path, None, factory)
- return asset
+ return tools.create_asset(container, path, None, factory)
def create_publish_instance(instance: str, path: str) -> unreal.Object:
- """Helper function to create OpenPype Publish Instance on given path.
+ """Helper function to create Ayon Publish Instance on given path.
- This behaves similarly as :func:`create_openpype_container`.
+ This behaves similarly as :func:`create_ayon_container`.
Args:
path (str): Path where to create Publish Instance.
@@ -460,10 +466,9 @@ def create_publish_instance(instance: str, path: str) -> unreal.Object:
)
"""
- factory = unreal.OpenPypePublishInstanceFactory()
+ factory = unreal.AyonPublishInstanceFactory()
tools = unreal.AssetToolsHelpers().get_asset_tools()
- asset = tools.create_asset(instance, path, None, factory)
- return asset
+ return tools.create_asset(instance, path, None, factory)
def cast_map_to_str_dict(umap) -> dict:
@@ -494,11 +499,14 @@ def get_subsequences(sequence: unreal.LevelSequence):
"""
tracks = sequence.get_master_tracks()
- subscene_track = None
- for t in tracks:
- if t.get_class() == unreal.MovieSceneSubTrack.static_class():
- subscene_track = t
- break
+ subscene_track = next(
+ (
+ t
+ for t in tracks
+ if t.get_class() == unreal.MovieSceneSubTrack.static_class()
+ ),
+ None,
+ )
if subscene_track is not None and subscene_track.get_sections():
return subscene_track.get_sections()
return []
diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py
index d60050a696..26ef69af86 100644
--- a/openpype/hosts/unreal/api/plugin.py
+++ b/openpype/hosts/unreal/api/plugin.py
@@ -31,7 +31,7 @@ from openpype.pipeline import (
@six.add_metaclass(ABCMeta)
class UnrealBaseCreator(Creator):
"""Base class for Unreal creator plugins."""
- root = "/Game/OpenPype/PublishInstances"
+ root = "/Game/Ayon/AyonPublishInstances"
suffix = "_INS"
@staticmethod
@@ -243,5 +243,5 @@ class UnrealActorCreator(UnrealBaseCreator):
class Loader(LoaderPlugin, ABC):
- """This serves as skeleton for future OpenPype specific functionality"""
+ """This serves as skeleton for future Ayon specific functionality"""
pass
diff --git a/openpype/hosts/unreal/api/rendering.py b/openpype/hosts/unreal/api/rendering.py
index 29e4747f6e..efe6fc54ad 100644
--- a/openpype/hosts/unreal/api/rendering.py
+++ b/openpype/hosts/unreal/api/rendering.py
@@ -2,8 +2,10 @@ import os
import unreal
+from openpype.settings import get_project_settings
from openpype.pipeline import Anatomy
from openpype.hosts.unreal.api import pipeline
+from openpype.widgets.message_window import Window
queue = None
@@ -32,15 +34,24 @@ def start_rendering():
"""
Start the rendering process.
"""
- print("Starting rendering...")
+ unreal.log("Starting rendering...")
# Get selected sequences
assets = unreal.EditorUtilityLibrary.get_selected_assets()
+ if not assets:
+ Window(
+ parent=None,
+ title="No assets selected",
+ message="No assets selected. Select a render instance.",
+ level="warning")
+ raise RuntimeError(
+ "No assets selected. You need to select a render instance.")
+
# instances = pipeline.ls_inst()
instances = [
a for a in assets
- if a.get_class().get_name() == "OpenPypePublishInstance"]
+ if a.get_class().get_name() == "AyonPublishInstance"]
inst_data = []
@@ -53,8 +64,9 @@ def start_rendering():
project = os.environ.get("AVALON_PROJECT")
anatomy = Anatomy(project)
root = anatomy.roots['renders']
- except Exception:
- raise Exception("Could not find render root in anatomy settings.")
+ except Exception as e:
+ raise Exception(
+ "Could not find render root in anatomy settings.") from e
render_dir = f"{root}/{project}"
@@ -66,6 +78,13 @@ def start_rendering():
ar = unreal.AssetRegistryHelpers.get_asset_registry()
+ data = get_project_settings(project)
+ config = None
+ config_path = str(data.get("unreal").get("render_config_path"))
+ if config_path and unreal.EditorAssetLibrary.does_asset_exist(config_path):
+ unreal.log("Found saved render configuration")
+ config = ar.get_asset_by_object_path(config_path).get_asset()
+
for i in inst_data:
sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset()
@@ -81,55 +100,80 @@ def start_rendering():
# Get all the sequences to render. If there are subsequences,
# add them and their frame ranges to the render list. We also
# use the names for the output paths.
- for s in sequences:
- subscenes = pipeline.get_subsequences(s.get('sequence'))
+ for seq in sequences:
+ subscenes = pipeline.get_subsequences(seq.get('sequence'))
if subscenes:
- for ss in subscenes:
+ for sub_seq in subscenes:
sequences.append({
- "sequence": ss.get_sequence(),
- "output": (f"{s.get('output')}/"
- f"{ss.get_sequence().get_name()}"),
+ "sequence": sub_seq.get_sequence(),
+ "output": (f"{seq.get('output')}/"
+ f"{sub_seq.get_sequence().get_name()}"),
"frame_range": (
- ss.get_start_frame(), ss.get_end_frame())
+ sub_seq.get_start_frame(), sub_seq.get_end_frame())
})
else:
# Avoid rendering camera sequences
- if "_camera" not in s.get('sequence').get_name():
- render_list.append(s)
+ if "_camera" not in seq.get('sequence').get_name():
+ render_list.append(seq)
# Create the rendering jobs and add them to the queue.
- for r in render_list:
+ for render_setting in render_list:
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
job.sequence = unreal.SoftObjectPath(i["master_sequence"])
job.map = unreal.SoftObjectPath(i["master_level"])
- job.author = "OpenPype"
+ job.author = "Ayon"
+
+ # If we have a saved configuration, copy it to the job.
+ if config:
+ job.get_configuration().copy_from(config)
# User data could be used to pass data to the job, that can be
# read in the job's OnJobFinished callback. We could,
- # for instance, pass the AvalonPublishInstance's path to the job.
+ # for instance, pass the AyonPublishInstance's path to the job.
# job.user_data = ""
+ output_dir = render_setting.get('output')
+ shot_name = render_setting.get('sequence').get_name()
+
settings = job.get_configuration().find_or_add_setting_by_class(
unreal.MoviePipelineOutputSetting)
settings.output_resolution = unreal.IntPoint(1920, 1080)
- settings.custom_start_frame = r.get("frame_range")[0]
- settings.custom_end_frame = r.get("frame_range")[1]
+ settings.custom_start_frame = render_setting.get("frame_range")[0]
+ settings.custom_end_frame = render_setting.get("frame_range")[1]
settings.use_custom_playback_range = True
- settings.file_name_format = "{sequence_name}.{frame_number}"
- settings.output_directory.path = f"{render_dir}/{r.get('output')}"
-
- renderPass = job.get_configuration().find_or_add_setting_by_class(
- unreal.MoviePipelineDeferredPassBase)
- renderPass.disable_multisample_effects = True
+ settings.file_name_format = f"{shot_name}" + ".{frame_number}"
+ settings.output_directory.path = f"{render_dir}/{output_dir}"
job.get_configuration().find_or_add_setting_by_class(
- unreal.MoviePipelineImageSequenceOutput_PNG)
+ unreal.MoviePipelineDeferredPassBase)
+
+ render_format = data.get("unreal").get("render_format", "png")
+
+ if render_format == "png":
+ job.get_configuration().find_or_add_setting_by_class(
+ unreal.MoviePipelineImageSequenceOutput_PNG)
+ elif render_format == "exr":
+ job.get_configuration().find_or_add_setting_by_class(
+ unreal.MoviePipelineImageSequenceOutput_EXR)
+ elif render_format == "jpg":
+ job.get_configuration().find_or_add_setting_by_class(
+ unreal.MoviePipelineImageSequenceOutput_JPG)
+ elif render_format == "bmp":
+ job.get_configuration().find_or_add_setting_by_class(
+ unreal.MoviePipelineImageSequenceOutput_BMP)
# If there are jobs in the queue, start the rendering process.
if queue.get_jobs():
global executor
executor = unreal.MoviePipelinePIEExecutor()
+
+ preroll_frames = data.get("unreal").get("preroll_frames", 0)
+
+ settings = unreal.MoviePipelinePIEExecutorSettings()
+ settings.set_editor_property(
+ "initial_delay_frame_count", preroll_frames)
+
executor.on_executor_finished_delegate.add_callable_unique(
_queue_finish_callback)
executor.on_individual_job_finished_delegate.add_callable_unique(
diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py
index 8531472142..5a4c689918 100644
--- a/openpype/hosts/unreal/api/tools_ui.py
+++ b/openpype/hosts/unreal/api/tools_ui.py
@@ -64,7 +64,7 @@ class ToolsDialog(QtWidgets.QDialog):
def __init__(self, *args, **kwargs):
super(ToolsDialog, self).__init__(*args, **kwargs)
- self.setWindowTitle("OpenPype tools")
+ self.setWindowTitle("Ayon tools")
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
index efbacc3b16..f01609d314 100644
--- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
+++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py
@@ -186,15 +186,15 @@ class UnrealPrelaunchHook(PreLaunchHook):
project_path.mkdir(parents=True, exist_ok=True)
- # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for
+ # Set "AYON_UNREAL_PLUGIN" to current process environment for
# execution of `create_unreal_project`
- if self.launch_context.env.get("OPENPYPE_UNREAL_PLUGIN"):
+ if self.launch_context.env.get("AYON_UNREAL_PLUGIN"):
self.log.info((
- f"{self.signature} using OpenPype plugin from "
- f"{self.launch_context.env.get('OPENPYPE_UNREAL_PLUGIN')}"
+ f"{self.signature} using Ayon plugin from "
+ f"{self.launch_context.env.get('AYON_UNREAL_PLUGIN')}"
))
- env_key = "OPENPYPE_UNREAL_PLUGIN"
+ env_key = "AYON_UNREAL_PLUGIN"
if self.launch_context.env.get(env_key):
os.environ[env_key] = self.launch_context.env[env_key]
@@ -213,7 +213,7 @@ class UnrealPrelaunchHook(PreLaunchHook):
engine_path,
project_path)
- self.launch_context.env["OPENPYPE_UNREAL_VERSION"] = engine_version
+ self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version
# Append project file to launch arguments
self.launch_context.launch_args.append(
f"\"{project_file.as_posix()}\"")
diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/README.md
new file mode 100644
index 0000000000..961eea83e6
--- /dev/null
+++ b/openpype/hosts/unreal/integration/README.md
@@ -0,0 +1,10 @@
+# Building the plugin
+
+In order to successfully build the plugin, make sure that the path to the UnrealBuildTool.exe is specified correctly.
+After the UBT path specify for which platform it will be compiled. in the -Project parameter, specify the path to the
+CommandletProject.uproject file. Next the build type has to be specified (DebugGame, Development, Package, etc.) and then the -TargetType (Editor, Runtime, etc.)
+
+`BuildPlugin_[Ver].bat` runs the building process in the background. If you want to show the progress inside the
+command prompt, use the `BuildPlugin_[Ver]_Window.bat` file.
+
+
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/.gitignore b/openpype/hosts/unreal/integration/UE_4.27/Ayon/.gitignore
similarity index 100%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/.gitignore
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/.gitignore
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Ayon.uplugin b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Ayon.uplugin
new file mode 100644
index 0000000000..0838da5577
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Ayon.uplugin
@@ -0,0 +1,23 @@
+{
+ "FileVersion": 3,
+ "Version": 1,
+ "VersionName": "1.0",
+ "FriendlyName": "Ayon",
+ "Description": "Ayon Integration",
+ "Category": "Ayon.Integration",
+ "CreatedBy": "Ondrej Samohel",
+ "CreatedByURL": "https://ayon.ynput.io",
+ "DocsURL": "https://ayon.ynput.io/docs/artist_hosts_unreal",
+ "MarketplaceURL": "",
+ "SupportURL": "https://ynput.io/",
+ "EngineVersion": "4.27",
+ "CanContainContent": true,
+ "Installed": true,
+ "Modules": [
+ {
+ "Name": "Ayon",
+ "Type": "Editor",
+ "LoadingPhase": "Default"
+ }
+ ]
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Config/DefaultAyonSettings.ini b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Config/DefaultAyonSettings.ini
new file mode 100644
index 0000000000..9ad7f55201
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Config/DefaultAyonSettings.ini
@@ -0,0 +1,2 @@
+[/Script/Ayon.AyonSettings]
+FolderColor=(R=91,G=197,B=220,A=255)
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/FilterPlugin.ini b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Config/FilterPlugin.ini
similarity index 100%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/FilterPlugin.ini
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Config/FilterPlugin.ini
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Content/Python/init_unreal.py
new file mode 100644
index 0000000000..43d6b8b7cf
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Content/Python/init_unreal.py
@@ -0,0 +1,30 @@
+import unreal
+
+ayon_detected = True
+try:
+ from openpype.pipeline import install_host
+ from openpype.hosts.unreal.api import UnrealHost
+
+ ayon_host = UnrealHost()
+except ImportError as exc:
+ ayon_host = None
+ ayon_detected = False
+ unreal.log_error(f"OpenPype: cannot load Ayon [ {exc} ]")
+
+if ayon_detected:
+ install_host(ayon_host)
+
+
+@unreal.uclass()
+class AyonIntegration(unreal.AyonPythonBridge):
+ @unreal.ufunction(override=True)
+ def RunInPython_Popup(self):
+ unreal.log_warning("Ayon: showing tools popup")
+ if ayon_detected:
+ ayon_host.show_tools_popup()
+
+ @unreal.ufunction(override=True)
+ def RunInPython_Dialog(self):
+ unreal.log_warning("Ayon: showing tools dialog")
+ if ayon_detected:
+ ayon_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/README.md b/openpype/hosts/unreal/integration/UE_4.27/Ayon/README.md
new file mode 100644
index 0000000000..77ae8c7e98
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/README.md
@@ -0,0 +1,3 @@
+# Ayon Unreal Integration plugin - UE 4.x
+
+This is plugin for Unreal Editor, creating menu for [Ayon](https://github.com/ynput/OpenPype) tools to run.
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon128.png b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon128.png
new file mode 100644
index 0000000000..799d849aa3
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon128.png differ
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon40.png b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon40.png
new file mode 100644
index 0000000000..f5bf40ea16
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon40.png differ
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon512.png b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon512.png
new file mode 100644
index 0000000000..990d5917e2
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Resources/ayon512.png differ
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Ayon.Build.cs
similarity index 82%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Ayon.Build.cs
index f77c1383eb..a18fa93d4f 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Ayon.Build.cs
@@ -2,35 +2,37 @@
using UnrealBuildTool;
-public class OpenPype : ModuleRules
+public class Ayon : ModuleRules
{
- public OpenPype(ReadOnlyTargetRules Target) : base(Target)
+ public Ayon(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicIncludePaths.AddRange(
- new string[] {
+ new string[]
+ {
// ... add public include paths required here ...
}
- );
-
-
+ );
+
+
PrivateIncludePaths.AddRange(
- new string[] {
+ new string[]
+ {
// ... add other private include paths required here ...
}
- );
-
-
+ );
+
+
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
// ... add other public dependencies that you statically link with here ...
}
- );
-
-
+ );
+
+
PrivateDependencyModuleNames.AddRange(
new string[]
{
@@ -46,14 +48,14 @@ public class OpenPype : ModuleRules
"AssetTools"
// ... add private dependencies that you statically link with here ...
}
- );
-
-
+ );
+
+
DynamicallyLoadedModuleNames.AddRange(
new string[]
{
// ... add any modules that your module loads dynamically here ...
}
- );
+ );
}
-}
+}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Ayon.cpp
similarity index 59%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Ayon.cpp
index 9bf7b341c5..158a32e496 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Ayon.cpp
@@ -1,25 +1,26 @@
// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPype.h"
+#include "Ayon.h"
#include "ISettingsContainer.h"
#include "ISettingsModule.h"
#include "ISettingsSection.h"
#include "LevelEditor.h"
-#include "OpenPypePythonBridge.h"
-#include "OpenPypeSettings.h"
-#include "OpenPypeStyle.h"
+#include "AyonPythonBridge.h"
+#include "AyonSettings.h"
+#include "AyonStyle.h"
+#include "Modules/ModuleManager.h"
-static const FName OpenPypeTabName("OpenPype");
+static const FName AyonTabName("Ayon");
-#define LOCTEXT_NAMESPACE "FOpenPypeModule"
+#define LOCTEXT_NAMESPACE "FAyonModule"
// This function is triggered when the plugin is staring up
-void FOpenPypeModule::StartupModule()
+void FAyonModule::StartupModule()
{
if (!IsRunningCommandlet()) {
- FOpenPypeStyle::Initialize();
- FOpenPypeStyle::SetIcon("Logo", "openpype40");
+ FAyonStyle::Initialize();
+ FAyonStyle::SetIcon("Logo", "ayon40");
// Create the Extender that will add content to the menu
FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor");
@@ -31,13 +32,13 @@ void FOpenPypeModule::StartupModule()
"LevelEditor",
EExtensionHook::After,
NULL,
- FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry)
+ FMenuExtensionDelegate::CreateRaw(this, &FAyonModule::AddMenuEntry)
);
ToolbarExtender->AddToolBarExtension(
"Settings",
EExtensionHook::After,
NULL,
- FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry));
+ FToolBarExtensionDelegate::CreateRaw(this, &FAyonModule::AddToobarEntry));
LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender);
@@ -47,56 +48,56 @@ void FOpenPypeModule::StartupModule()
}
}
-void FOpenPypeModule::ShutdownModule()
+void FAyonModule::ShutdownModule()
{
- FOpenPypeStyle::Shutdown();
+ FAyonStyle::Shutdown();
}
-void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder)
+void FAyonModule::AddMenuEntry(FMenuBuilder& MenuBuilder)
{
// Create Section
- MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype")));
+ MenuBuilder.BeginSection("Ayon", TAttribute(FText::FromString("Ayon")));
{
// Create a Submenu inside of the Section
MenuBuilder.AddMenuEntry(
FText::FromString("Tools..."),
FText::FromString("Pipeline tools"),
- FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"),
- FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup))
+ FSlateIcon(FAyonStyle::GetStyleSetName(), "Ayon.Logo"),
+ FUIAction(FExecuteAction::CreateRaw(this, &FAyonModule::MenuPopup))
);
MenuBuilder.AddMenuEntry(
FText::FromString("Tools dialog..."),
FText::FromString("Pipeline tools dialog"),
- FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"),
- FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog))
+ FSlateIcon(FAyonStyle::GetStyleSetName(), "Ayon.Logo"),
+ FUIAction(FExecuteAction::CreateRaw(this, &FAyonModule::MenuDialog))
);
}
MenuBuilder.EndSection();
}
-void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder)
+void FAyonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder)
{
- ToolbarBuilder.BeginSection(TEXT("OpenPype"));
+ ToolbarBuilder.BeginSection(TEXT("Ayon"));
{
ToolbarBuilder.AddToolBarButton(
FUIAction(
- FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup),
+ FExecuteAction::CreateRaw(this, &FAyonModule::MenuPopup),
NULL,
FIsActionChecked()
),
NAME_None,
- LOCTEXT("OpenPype_label", "OpenPype"),
- LOCTEXT("OpenPype_tooltip", "OpenPype Tools"),
- FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo")
+ LOCTEXT("Ayon_label", "Ayon"),
+ LOCTEXT("Ayon_tooltip", "Ayon Tools"),
+ FSlateIcon(FAyonStyle::GetStyleSetName(), "Ayon.Logo")
);
}
ToolbarBuilder.EndSection();
}
-void FOpenPypeModule::RegisterSettings()
+void FAyonModule::RegisterSettings()
{
ISettingsModule& SettingsModule = FModuleManager::LoadModuleChecked("Settings");
@@ -104,10 +105,10 @@ void FOpenPypeModule::RegisterSettings()
// TODO: After the movement of the plugin from the game to editor, it might be necessary to move this!
ISettingsContainerPtr SettingsContainer = SettingsModule.GetContainer("Project");
- UOpenPypeSettings* Settings = GetMutableDefault();
+ UAyonSettings* Settings = GetMutableDefault();
// Register the settings
- ISettingsSectionPtr SettingsSection = SettingsModule.RegisterSettings("Project", "OpenPype", "General",
+ ISettingsSectionPtr SettingsSection = SettingsModule.RegisterSettings("Project", "Ayon", "General",
LOCTEXT("RuntimeGeneralSettingsName",
"General"),
LOCTEXT("RuntimeGeneralSettingsDescription",
@@ -119,13 +120,13 @@ void FOpenPypeModule::RegisterSettings()
// validate those or just act to settings changes.
if (SettingsSection.IsValid())
{
- SettingsSection->OnModified().BindRaw(this, &FOpenPypeModule::HandleSettingsSaved);
+ SettingsSection->OnModified().BindRaw(this, &FAyonModule::HandleSettingsSaved);
}
}
-bool FOpenPypeModule::HandleSettingsSaved()
+bool FAyonModule::HandleSettingsSaved()
{
- UOpenPypeSettings* Settings = GetMutableDefault();
+ UAyonSettings* Settings = GetMutableDefault();
bool ResaveSettings = false;
// You can put any validation code in here and resave the settings in case an invalid
@@ -140,16 +141,16 @@ bool FOpenPypeModule::HandleSettingsSaved()
}
-void FOpenPypeModule::MenuPopup()
+void FAyonModule::MenuPopup()
{
- UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
bridge->RunInPython_Popup();
}
-void FOpenPypeModule::MenuDialog()
+void FAyonModule::MenuDialog()
{
- UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
bridge->RunInPython_Dialog();
}
-IMPLEMENT_MODULE(FOpenPypeModule, OpenPype)
+IMPLEMENT_MODULE(FAyonModule, Ayon)
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
new file mode 100644
index 0000000000..e3989eb03c
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
@@ -0,0 +1,114 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#include "AyonAssetContainer.h"
+#include "AssetRegistryModule.h"
+#include "Misc/PackageName.h"
+#include "Containers/UnrealString.h"
+
+UAyonAssetContainer::UAyonAssetContainer(const FObjectInitializer& ObjectInitializer)
+: UAssetUserData(ObjectInitializer)
+{
+ FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry");
+ FString path = UAyonAssetContainer::GetPathName();
+ UE_LOG(LogTemp, Warning, TEXT("UAyonAssetContainer %s"), *path);
+ FARFilter Filter;
+ Filter.PackagePaths.Add(FName(*path));
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonAssetContainer::OnAssetAdded);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonAssetContainer::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAyonAssetContainer::OnAssetRenamed);
+}
+
+void UAyonAssetContainer::OnAssetAdded(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AyonAssetContainer")
+ {
+ assets.Add(assetPath);
+ assetsData.Add(AssetData);
+ UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir);
+ }
+ }
+}
+
+void UAyonAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ FString path = UAyonAssetContainer::GetPathName();
+ FString lpp = FPackageName::GetLongPackagePath(*path);
+
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AyonAssetContainer")
+ {
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp);
+ assets.Remove(assetPath);
+ assetsData.Remove(AssetData);
+ }
+ }
+}
+
+void UAyonAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.AssetClass.ToString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AyonAssetContainer")
+ {
+
+ assets.Remove(str);
+ assets.Add(assetPath);
+ assetsData.Remove(AssetData);
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str);
+ }
+ }
+}
+
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
new file mode 100644
index 0000000000..086fc1036e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
@@ -0,0 +1,20 @@
+#include "AyonAssetContainerFactory.h"
+#include "AyonAssetContainer.h"
+
+UAyonAssetContainerFactory::UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonAssetContainer::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ UAyonAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags);
+ return AssetContainer;
+}
+
+bool UAyonAssetContainerFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonLib.cpp
similarity index 83%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonLib.cpp
index 34faba1f49..bff99caee3 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonLib.cpp
@@ -1,5 +1,5 @@
// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypeLib.h"
+#include "AyonLib.h"
#include "AssetViewUtils.h"
#include "Misc/Paths.h"
@@ -13,7 +13,7 @@
* @warning This color will appear only after Editor restart. Is there a better way?
*/
-bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor, const bool& bForceAdd)
+bool UAyonLib::SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor, const bool& bForceAdd)
{
if (AssetViewUtils::DoesFolderExist(FolderPath))
{
@@ -31,11 +31,11 @@ bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor&
}
/**
- * Returns all properties on given object
+ * Returns all poperties on given object
* @param cls - class
* @return TArray of properties
*/
-TArray UOpenPypeLib::GetAllProperties(UClass* cls)
+TArray UAyonLib::GetAllProperties(UClass* cls)
{
TArray Ret;
if (cls != nullptr)
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
new file mode 100644
index 0000000000..d7550e2ed1
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
@@ -0,0 +1,203 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.h"
+#include "AssetRegistryModule.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
+#include "Framework/Notifications/NotificationManager.h"
+#include "Widgets/Notifications/SNotificationList.h"
+
+//Moves all the invalid pointers to the end to prepare them for the shrinking
+#define REMOVE_INVALID_ENTRIES(VAR) VAR.CompactStable(); \
+ VAR.Shrink();
+
+UAyonPublishInstance::UAyonPublishInstance(const FObjectInitializer& ObjectInitializer)
+ : UPrimaryDataAsset(ObjectInitializer)
+{
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<
+ FAssetRegistryModule>("AssetRegistry");
+
+ const FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked(
+ "PropertyEditor");
+
+ FString Left, Right;
+ GetPathName().Split("/" + GetName(), &Left, &Right);
+
+ FARFilter Filter;
+ Filter.PackagePaths.Emplace(FName(Left));
+
+ TArray FoundAssets;
+ AssetRegistryModule.GetRegistry().GetAssets(Filter, FoundAssets);
+
+ for (const FAssetData& AssetData : FoundAssets)
+ OnAssetCreated(AssetData);
+
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonPublishInstance::OnAssetCreated);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonPublishInstance::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetUpdated().AddUObject(this, &UAyonPublishInstance::OnAssetUpdated);
+
+#ifdef WITH_EDITOR
+ ColorAyonDirs();
+#endif
+
+}
+
+void UAyonPublishInstance::OnAssetCreated(const FAssetData& InAssetData)
+{
+ TArray split;
+
+ UObject* Asset = InAssetData.GetAsset();
+
+ if (!IsValid(Asset))
+ {
+ UE_LOG(LogAssetData, Warning, TEXT("Asset \"%s\" is not valid! Skipping the addition."),
+ *InAssetData.ObjectPath.ToString());
+ return;
+ }
+
+ const bool result = IsUnderSameDir(Asset) && Cast(Asset) == nullptr;
+
+ if (result)
+ {
+ if (AssetDataInternal.Emplace(Asset).IsValidId())
+ {
+ UE_LOG(LogTemp, Log, TEXT("Added an Asset to PublishInstance - Publish Instance: %s, Asset %s"),
+ *this->GetName(), *Asset->GetName());
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetRemoved(const FAssetData& InAssetData)
+{
+ if (Cast(InAssetData.GetAsset()) == nullptr)
+ {
+ if (AssetDataInternal.Contains(nullptr))
+ {
+ AssetDataInternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ }
+ else
+ {
+ AssetDataExternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetUpdated(const FAssetData& InAssetData)
+{
+ REMOVE_INVALID_ENTRIES(AssetDataInternal);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal);
+}
+
+bool UAyonPublishInstance::IsUnderSameDir(const UObject* InAsset) const
+{
+ FString ThisLeft, ThisRight;
+ this->GetPathName().Split(this->GetName(), &ThisLeft, &ThisRight);
+
+ return InAsset->GetPathName().StartsWith(ThisLeft);
+}
+
+#ifdef WITH_EDITOR
+
+void UAyonPublishInstance::ColorAyonDirs()
+{
+ FString PathName = this->GetPathName();
+
+ //Check whether the path contains the defined Ayon folder
+ if (!PathName.Contains(TEXT("Ayon"))) return;
+
+ //Get the base path for open pype
+ FString PathLeft, PathRight;
+ PathName.Split(FString("Ayon"), &PathLeft, &PathRight);
+
+ if (PathLeft.IsEmpty() || PathRight.IsEmpty())
+ {
+ UE_LOG(LogAssetData, Error, TEXT("Failed to retrieve the base Ayon directory!"))
+ return;
+ }
+
+ PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
+
+ //Get the current settings
+ const UAyonSettings* Settings = GetMutableDefault();
+
+ //Color the base folder
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+
+ //Get Sub paths, iterate through them and color them according to the folder color in UAyonSettings
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
+ "AssetRegistry");
+
+ TArray PathList;
+
+ AssetRegistryModule.Get().GetSubPaths(PathName, PathList, true);
+
+ if (PathList.Num() > 0)
+ {
+ for (const FString& Path : PathList)
+ {
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ }
+ }
+}
+
+void UAyonPublishInstance::SendNotification(const FString& Text) const
+{
+ FNotificationInfo Info{FText::FromString(Text)};
+
+ Info.bFireAndForget = true;
+ Info.bUseLargeFont = false;
+ Info.bUseThrobber = false;
+ Info.bUseSuccessFailIcons = false;
+ Info.ExpireDuration = 4.f;
+ Info.FadeOutDuration = 2.f;
+
+ FSlateNotificationManager::Get().AddNotification(Info);
+
+ UE_LOG(LogAssetData, Warning,
+ TEXT(
+ "Removed duplicated asset from the AssetsDataExternal in Container \"%s\", Asset is already included in the AssetDataInternal!"
+ ), *GetName()
+ )
+}
+
+
+void UAyonPublishInstance::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
+{
+ Super::PostEditChangeProperty(PropertyChangedEvent);
+
+ if (PropertyChangedEvent.ChangeType == EPropertyChangeType::ValueSet &&
+ PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(
+ UAyonPublishInstance, AssetDataExternal))
+ {
+ // Check for duplicated assets
+ for (const auto& Asset : AssetDataInternal)
+ {
+ if (AssetDataExternal.Contains(Asset))
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification(
+ "You are not allowed to add assets into AssetDataExternal which are already included in AssetDataInternal!");
+ }
+ }
+
+ // Check if no UAyonPublishInstance type assets are included
+ for (const auto& Asset : AssetDataExternal)
+ {
+ if (Cast(Asset.Get()) != nullptr)
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification("You are not allowed to add publish instances!");
+ }
+ }
+ }
+}
+
+#endif
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
new file mode 100644
index 0000000000..f79c428a6d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
@@ -0,0 +1,23 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#include "AyonPublishInstanceFactory.h"
+#include "AyonPublishInstance.h"
+
+UAyonPublishInstanceFactory::UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonPublishInstance::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonPublishInstanceFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ check(InClass->IsChildOf(UAyonPublishInstance::StaticClass()));
+ return NewObject(InParent, InClass, InName, Flags);
+}
+
+bool UAyonPublishInstanceFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
new file mode 100644
index 0000000000..0ed4b2f704
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
@@ -0,0 +1,14 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "AyonPythonBridge.h"
+
+UAyonPythonBridge* UAyonPythonBridge::Get()
+{
+ TArray AyonPythonBridgeClasses;
+ GetDerivedClasses(UAyonPythonBridge::StaticClass(), AyonPythonBridgeClasses);
+ int32 NumClasses = AyonPythonBridgeClasses.Num();
+ if (NumClasses > 0)
+ {
+ return Cast(AyonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
+ }
+ return nullptr;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonSettings.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonSettings.cpp
new file mode 100644
index 0000000000..509b7268ba
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonSettings.cpp
@@ -0,0 +1,20 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonSettings.h"
+
+#include "Interfaces/IPluginManager.h"
+
+/**
+ * Mainly is used for initializing default values if the DefaultAyonSettings.ini file does not exist in the saved config
+ */
+UAyonSettings::UAyonSettings(const FObjectInitializer& ObjectInitializer)
+{
+
+ const FString ConfigFilePath = AYON_SETTINGS_FILEPATH;
+
+ // This has to be probably in the future set using the UE Reflection system
+ FColor Color;
+ GConfig->GetColor(TEXT("/Script/Ayon.AyonSettings"), TEXT("FolderColor"), Color, ConfigFilePath);
+
+ FolderColor = Color;
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonStyle.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonStyle.cpp
new file mode 100644
index 0000000000..b133225fd5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/AyonStyle.cpp
@@ -0,0 +1,70 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "AyonStyle.h"
+#include "Framework/Application/SlateApplication.h"
+#include "Styling/SlateStyle.h"
+#include "Styling/SlateStyleRegistry.h"
+
+
+TUniquePtr< FSlateStyleSet > FAyonStyle::AyonStyleInstance = nullptr;
+
+void FAyonStyle::Initialize()
+{
+ if (!AyonStyleInstance.IsValid())
+ {
+ AyonStyleInstance = Create();
+ FSlateStyleRegistry::RegisterSlateStyle(*AyonStyleInstance);
+ }
+}
+
+void FAyonStyle::Shutdown()
+{
+ if (AyonStyleInstance.IsValid())
+ {
+ FSlateStyleRegistry::UnRegisterSlateStyle(*AyonStyleInstance);
+ AyonStyleInstance.Reset();
+ }
+}
+
+FName FAyonStyle::GetStyleSetName()
+{
+ static FName StyleSetName(TEXT("AyonStyle"));
+ return StyleSetName;
+}
+
+FName FAyonStyle::GetContextName()
+{
+ static FName ContextName(TEXT("Ayon"));
+ return ContextName;
+}
+
+#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ )
+
+const FVector2D Icon40x40(40.0f, 40.0f);
+
+TUniquePtr< FSlateStyleSet > FAyonStyle::Create()
+{
+ TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName());
+ Style->SetContentRoot(FPaths::EnginePluginsDir() / TEXT("Marketplace/Ayon/Resources"));
+
+ return Style;
+}
+
+void FAyonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath)
+{
+ FSlateStyleSet* Style = AyonStyleInstance.Get();
+
+ FString Name(GetContextName().ToString());
+ Name = Name + "." + StyleName;
+ Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40));
+
+
+ FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
+}
+
+#undef IMAGE_BRUSH
+
+const ISlateStyle& FAyonStyle::Get()
+{
+ check(AyonStyleInstance);
+ return *AyonStyleInstance;
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
new file mode 100644
index 0000000000..49376e8648
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
@@ -0,0 +1,41 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+
+#include "Commandlets/AyonActionResult.h"
+#include "Logging/Ayon_Log.h"
+
+EAyon_ActionResult::Type& FAyon_ActionResult::GetStatus()
+{
+ return Status;
+}
+
+FText& FAyon_ActionResult::GetReason()
+{
+ return Reason;
+}
+
+FAyon_ActionResult::FAyon_ActionResult():Status(EAyon_ActionResult::Type::Ok)
+{
+
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum):Status(InEnum)
+{
+ TryLog();
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason)
+{
+ TryLog();
+};
+
+bool FAyon_ActionResult::IsProblem() const
+{
+ return Status != EAyon_ActionResult::Ok;
+}
+
+void FAyon_ActionResult::TryLog() const
+{
+ if(IsProblem())
+ UE_LOG(LogCommandletOPGenerateProject, Error, TEXT("%s"), *Reason.ToString());
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
new file mode 100644
index 0000000000..0328d3b7e6
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
@@ -0,0 +1,141 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "Commandlets/Implementations/AyonGenerateProjectCommandlet.h"
+
+#include "Editor.h"
+#include "GameProjectUtils.h"
+#include "AyonConstants.h"
+#include "Commandlets/AyonActionResult.h"
+#include "ProjectDescriptor.h"
+
+int32 UAyonGenerateProjectCommandlet::Main(const FString& CommandLineParams)
+{
+ //Parses command line parameters & creates structure FProjectInformation
+ const FAyonGenerateProjectParams ParsedParams = FAyonGenerateProjectParams(CommandLineParams);
+ ProjectInformation = ParsedParams.GenerateUEProjectInformation();
+
+ //Creates .uproject & other UE files
+ EVALUATE_AYON_ACTION_RESULT(TryCreateProject());
+
+ //Loads created .uproject
+ EVALUATE_AYON_ACTION_RESULT(TryLoadProjectDescriptor());
+
+ //Adds needed plugin to .uproject
+ AttachPluginsToProjectDescriptor();
+
+ //Saves .uproject
+ EVALUATE_AYON_ACTION_RESULT(TrySave());
+
+ //When we are here, there should not be problems in generating Unreal Project for Ayon
+ return 0;
+}
+
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(): FAyonGenerateProjectParams("")
+{
+}
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(const FString& CommandLineParams): CommandLineParams(
+ CommandLineParams)
+{
+ UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches);
+}
+
+FProjectInformation FAyonGenerateProjectParams::GenerateUEProjectInformation() const
+{
+ FProjectInformation ProjectInformation = FProjectInformation();
+ ProjectInformation.ProjectFilename = GetProjectFileName();
+
+ ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode");
+
+ return ProjectInformation;
+}
+
+FString FAyonGenerateProjectParams::TryGetToken(const int32 Index) const
+{
+ return Tokens.IsValidIndex(Index) ? Tokens[Index] : "";
+}
+
+FString FAyonGenerateProjectParams::GetProjectFileName() const
+{
+ return TryGetToken(0);
+}
+
+bool FAyonGenerateProjectParams::IsSwitchPresent(const FString& Switch) const
+{
+ return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool
+ {
+ return Item.Equals(Switch);
+ }
+ );
+}
+
+
+UAyonGenerateProjectCommandlet::UAyonGenerateProjectCommandlet()
+{
+ LogToConsole = true;
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryCreateProject() const
+{
+ FText FailReason;
+ FText FailLog;
+ TArray OutCreatedFiles;
+
+ if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles))
+ return FAyon_ActionResult(EAyon_ActionResult::ProjectNotCreated, FailReason);
+ return FAyon_ActionResult();
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryLoadProjectDescriptor()
+{
+ FText FailReason;
+ const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bLoaded ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotLoaded, FailReason);
+}
+
+void UAyonGenerateProjectCommandlet::AttachPluginsToProjectDescriptor()
+{
+ FPluginReferenceDescriptor AyonPluginDescriptor;
+ AyonPluginDescriptor.bEnabled = true;
+ AyonPluginDescriptor.Name = AyonConstants::Ayon_PluginName;
+ ProjectDescriptor.Plugins.Add(AyonPluginDescriptor);
+
+ FPluginReferenceDescriptor PythonPluginDescriptor;
+ PythonPluginDescriptor.bEnabled = true;
+ PythonPluginDescriptor.Name = AyonConstants::PythonScript_PluginName;
+ ProjectDescriptor.Plugins.Add(PythonPluginDescriptor);
+
+ FPluginReferenceDescriptor SequencerScriptingPluginDescriptor;
+ SequencerScriptingPluginDescriptor.bEnabled = true;
+ SequencerScriptingPluginDescriptor.Name = AyonConstants::SequencerScripting_PluginName;
+ ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor);
+
+ FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor;
+ MovieRenderPipelinePluginDescriptor.bEnabled = true;
+ MovieRenderPipelinePluginDescriptor.Name = AyonConstants::MovieRenderPipeline_PluginName;
+ ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor);
+
+ FPluginReferenceDescriptor EditorScriptingPluginDescriptor;
+ EditorScriptingPluginDescriptor.bEnabled = true;
+ EditorScriptingPluginDescriptor.Name = AyonConstants::EditorScriptingUtils_PluginName;
+ ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor);
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TrySave()
+{
+ FText FailReason;
+ const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bSaved ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotSaved, FailReason);
+}
+
+FAyonGenerateProjectParams UAyonGenerateProjectCommandlet::ParseParameters(const FString& Params) const
+{
+ FAyonGenerateProjectParams ParamsResult;
+
+ TArray Tokens, Switches;
+ ParseCommandLine(*Params, Tokens, Switches);
+
+ return ParamsResult;
+}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
similarity index 92%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
index 05638fbd0b..320285591e 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
@@ -1,10 +1,12 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
#include "OpenPypePublishInstance.h"
#include "AssetRegistryModule.h"
-#include "OpenPypeLib.h"
-#include "OpenPypeSettings.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
@@ -43,7 +45,7 @@ UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& Obj
#ifdef WITH_EDITOR
ColorOpenPypeDirs();
#endif
-
+
}
void UOpenPypePublishInstance::OnAssetCreated(const FAssetData& InAssetData)
@@ -124,12 +126,12 @@ void UOpenPypePublishInstance::ColorOpenPypeDirs()
PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
//Get the current settings
- const UOpenPypeSettings* Settings = GetMutableDefault();
+ const UAyonSettings* Settings = GetMutableDefault();
//Color the base folder
- UOpenPypeLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
- //Get Sub paths, iterate through them and color them according to the folder color in UOpenPypeSettings
+ //Get Sub paths, iterate through them and color them according to the folder color in UAyonSettings
const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
"AssetRegistry");
@@ -141,7 +143,7 @@ void UOpenPypePublishInstance::ColorOpenPypeDirs()
{
for (const FString& Path : PathList)
{
- UOpenPypeLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
}
}
}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Ayon.h
similarity index 84%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Ayon.h
index 2454344128..d11af70058 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Ayon.h
@@ -2,10 +2,8 @@
#pragma once
-#include "Engine.h"
-
-class FOpenPypeModule : public IModuleInterface
+class FAyonModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainer.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainer.h
new file mode 100644
index 0000000000..cc17b3960a
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainer.h
@@ -0,0 +1,39 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/NoExportTypes.h"
+#include "Engine/AssetUserData.h"
+#include "AssetData.h"
+#include "AyonAssetContainer.generated.h"
+
+/**
+ *
+ */
+UCLASS(Blueprintable)
+class AYON_API UAyonAssetContainer : public UAssetUserData
+{
+ GENERATED_BODY()
+
+public:
+
+ UAyonAssetContainer(const FObjectInitializer& ObjectInitalizer);
+ // ~UAyonAssetContainer();
+
+ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Assets")
+ TArray assets;
+
+ // There seems to be no reflection option to expose array of FAssetData
+ /*
+ UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data"))
+ TArray assetsData;
+ */
+private:
+ TArray assetsData;
+ void OnAssetAdded(const FAssetData& AssetData);
+ void OnAssetRemoved(const FAssetData& AssetData);
+ void OnAssetRenamed(const FAssetData& AssetData, const FString& str);
+};
+
+
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
new file mode 100644
index 0000000000..7c35897911
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
@@ -0,0 +1,21 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Factories/Factory.h"
+#include "AyonAssetContainerFactory.generated.h"
+
+/**
+ *
+ */
+UCLASS()
+class AYON_API UAyonAssetContainerFactory : public UFactory
+{
+ GENERATED_BODY()
+
+public:
+ UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer);
+ virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
+ virtual bool ShouldShowInNewMenu() const override;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonConstants.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonConstants.h
new file mode 100644
index 0000000000..6a02b5682f
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonConstants.h
@@ -0,0 +1,15 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+
+#include "CoreMinimal.h"
+
+namespace AyonConstants
+{
+ const FString Ayon_PluginName = "Ayon";
+ const FString PythonScript_PluginName = "PythonScriptPlugin";
+ const FString SequencerScripting_PluginName = "SequencerScripting";
+ const FString MovieRenderPipeline_PluginName = "MovieRenderPipeline";
+ const FString EditorScriptingUtils_PluginName = "EditorScriptingUtilities";
+}
+
+
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonLib.h
similarity index 75%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonLib.h
index ef4d1027ea..da83b448fb 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonLib.h
@@ -1,12 +1,11 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-#include "Engine.h"
-#include "OpenPypeLib.generated.h"
+#include "AyonLib.generated.h"
UCLASS(Blueprintable)
-class OPENPYPE_API UOpenPypeLib : public UBlueprintFunctionLibrary
+class AYON_API UAyonLib : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstance.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstance.h
new file mode 100644
index 0000000000..0a0628c3ec
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstance.h
@@ -0,0 +1,103 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.generated.h"
+
+
+UCLASS(Blueprintable)
+class AYON_API UAyonPublishInstance : public UPrimaryDataAsset
+{
+ GENERATED_UCLASS_BODY()
+
+public:
+ /**
+ * Retrieves all the assets which are monitored by the Publish Instance (Monitors assets in the directory which is
+ * placed in)
+ *
+ * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetInternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataInternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Retrieves all the assets which have been added manually by the Publish Instance
+ *
+ * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetExternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataExternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Function for returning all the assets in the container combined.
+ *
+ * @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
+ * returning raw pointers. Seems like an issue in UE5
+ *
+ * @attention If the bAddExternalAssets variable is false, external assets won't be included!
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetAllAssets() const
+ {
+ const TSet>& IteratedSet = bAddExternalAssets
+ ? AssetDataInternal.Union(AssetDataExternal)
+ : AssetDataInternal;
+
+ //Create a new TSet only with raw pointers.
+ TSet ResultSet;
+
+ for (auto& Asset : IteratedSet)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+private:
+ UPROPERTY(VisibleAnywhere, Category="Assets")
+ TSet> AssetDataInternal;
+
+ /**
+ * This property allows exposing the array to include other assets from any other directory than what it's currently
+ * monitoring. NOTE: that these assets have to be added manually! They are not automatically registered or added!
+ */
+ UPROPERTY(EditAnywhere, Category = "Assets")
+ bool bAddExternalAssets = false;
+
+ UPROPERTY(EditAnywhere, meta=(EditCondition="bAddExternalAssets"), Category="Assets")
+ TSet> AssetDataExternal;
+
+
+ void OnAssetCreated(const FAssetData& InAssetData);
+ void OnAssetRemoved(const FAssetData& InAssetData);
+ void OnAssetUpdated(const FAssetData& InAssetData);
+
+ bool IsUnderSameDir(const UObject* InAsset) const;
+
+#ifdef WITH_EDITOR
+
+ void ColorAyonDirs();
+
+ void SendNotification(const FString& Text) const;
+ virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
+
+#endif
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
similarity index 54%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
index 3fdb984411..3cef8e76b2 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
@@ -1,20 +1,22 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
#include "CoreMinimal.h"
#include "Factories/Factory.h"
-#include "OpenPypePublishInstanceFactory.generated.h"
+#include "AyonPublishInstanceFactory.generated.h"
/**
*
*/
UCLASS()
-class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory
+class AYON_API UAyonPublishInstanceFactory : public UFactory
{
GENERATED_BODY()
public:
- UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
+ UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
virtual bool ShouldShowInNewMenu() const override;
};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPythonBridge.h
similarity index 70%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPythonBridge.h
index 827f76f56b..3c429fd7d3 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonPythonBridge.h
@@ -1,16 +1,15 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-#include "Engine.h"
-#include "OpenPypePythonBridge.generated.h"
+#include "AyonPythonBridge.generated.h"
UCLASS(Blueprintable)
-class UOpenPypePythonBridge : public UObject
+class UAyonPythonBridge : public UObject
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = Python)
- static UOpenPypePythonBridge* Get();
+ static UAyonPythonBridge* Get();
UFUNCTION(BlueprintImplementableEvent, Category = Python)
void RunInPython_Popup() const;
diff --git a/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonSettings.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonSettings.h
new file mode 100644
index 0000000000..7a93f107c5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonSettings.h
@@ -0,0 +1,31 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "AyonSettings.generated.h"
+
+#define AYON_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("Ayon")->GetBaseDir() / TEXT("Config") / TEXT("DefaultAyonSettings.ini")
+
+UCLASS(Config=AyonSettings, DefaultConfig)
+class AYON_API UAyonSettings : public UObject
+{
+ GENERATED_UCLASS_BODY()
+
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category = Settings)
+ FColor GetFolderFColor() const
+ {
+ return FolderColor;
+ }
+
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category = Settings)
+ FLinearColor GetFolderFLinearColor() const
+ {
+ return FLinearColor(FolderColor);
+ }
+
+protected:
+
+ UPROPERTY(config, EditAnywhere, Category = Folders)
+ FColor FolderColor = FColor(25,45,223);
+};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonStyle.h
similarity index 84%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonStyle.h
index 0e4af129d0..188e4a510c 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/AyonStyle.h
@@ -6,7 +6,7 @@ class FSlateStyleSet;
class ISlateStyle;
-class FOpenPypeStyle
+class FAyonStyle
{
public:
static void Initialize();
@@ -19,5 +19,5 @@ public:
private:
static TUniquePtr< FSlateStyleSet > Create();
- static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance;
+ static TUniquePtr< FSlateStyleSet > AyonStyleInstance;
};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
similarity index 63%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
index 322a23a3e8..4694055164 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
@@ -3,23 +3,23 @@
#pragma once
#include "CoreMinimal.h"
-#include "OPActionResult.generated.h"
+#include "AyonActionResult.generated.h"
/**
* @brief This macro returns error code when is problem or does nothing when there is no problem.
- * @param ActionResult FOP_ActionResult structure
+ * @param ActionResult FAyon_ActionResult structure
*/
-#define EVALUATE_OP_ACTION_RESULT(ActionResult) \
+#define EVALUATE_AYON_ACTION_RESULT(ActionResult) \
if(ActionResult.IsProblem()) \
return ActionResult.GetStatus();
/**
* @brief This enum values are humanly readable mapping of error codes.
* Here should be all error codes to be possible find what went wrong.
-* TODO: In the future a web document should exists with the mapped error code & what problem occurred & how to repair it...
+* TODO: In the future should exists an web document where is mapped error code & what problem occured & how to repair it...
*/
UENUM()
-namespace EOP_ActionResult
+namespace EAyon_ActionResult
{
enum Type
{
@@ -27,11 +27,11 @@ namespace EOP_ActionResult
ProjectNotCreated,
ProjectNotLoaded,
ProjectNotSaved,
- //....Here insert another values
+ //....Here insert another values
//Do not remove!
//Usable for looping through enum values
- __Last UMETA(Hidden)
+ __Last UMETA(Hidden)
};
}
@@ -40,44 +40,44 @@ namespace EOP_ActionResult
* @brief This struct holds action result enum and optionally reason of fail
*/
USTRUCT()
-struct FOP_ActionResult
+struct FAyon_ActionResult
{
GENERATED_BODY()
public:
/** @brief Default constructor usable when there is no problem */
- FOP_ActionResult();
+ FAyon_ActionResult();
/**
* @brief This constructor initializes variables & attempts to log when is error
* @param InEnum Status
*/
- FOP_ActionResult(const EOP_ActionResult::Type& InEnum);
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum);
/**
* @brief This constructor initializes variables & attempts to log when is error
* @param InEnum Status
* @param InReason Reason of potential fail
*/
- FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason);
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason);
private:
/** @brief Action status */
- EOP_ActionResult::Type Status;
+ EAyon_ActionResult::Type Status;
/** @brief Optional reason of fail */
- FText Reason;
+ FText Reason;
public:
/**
* @brief Checks if there is problematic state
- * @return true when status is not equal to EOP_ActionResult::Ok
+ * @return true when status is not equal to EAyon_ActionResult::Ok
*/
bool IsProblem() const;
- EOP_ActionResult::Type& GetStatus();
+ EAyon_ActionResult::Type& GetStatus();
FText& GetReason();
-private:
+private:
void TryLog() const;
};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
similarity index 63%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
index d1129aa070..cabd524b8c 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
@@ -2,10 +2,10 @@
#pragma once
#include "GameProjectUtils.h"
-#include "Commandlets/OPActionResult.h"
+#include "Commandlets/AyonActionResult.h"
#include "ProjectDescriptor.h"
#include "Commandlets/Commandlet.h"
-#include "OPGenerateProjectCommandlet.generated.h"
+#include "AyonGenerateProjectCommandlet.generated.h"
struct FProjectDescriptor;
struct FProjectInformation;
@@ -14,7 +14,7 @@ struct FProjectInformation;
* @brief Structure which parses command line parameters and generates FProjectInformation
*/
USTRUCT()
-struct FOPGenerateProjectParams
+struct FAyonGenerateProjectParams
{
GENERATED_BODY()
@@ -24,8 +24,8 @@ private:
TArray Switches;
public:
- FOPGenerateProjectParams();
- FOPGenerateProjectParams(const FString& CommandLineParams);
+ FAyonGenerateProjectParams();
+ FAyonGenerateProjectParams(const FString& CommandLineParams);
FProjectInformation GenerateUEProjectInformation() const;
@@ -37,7 +37,7 @@ private:
};
UCLASS()
-class OPENPYPE_API UOPGenerateProjectCommandlet : public UCommandlet
+class AYON_API UAyonGenerateProjectCommandlet : public UCommandlet
{
GENERATED_BODY()
@@ -46,15 +46,15 @@ private:
FProjectDescriptor ProjectDescriptor;
public:
- UOPGenerateProjectCommandlet();
+ UAyonGenerateProjectCommandlet();
virtual int32 Main(const FString& CommandLineParams) override;
private:
- FOPGenerateProjectParams ParseParameters(const FString& Params) const;
- FOP_ActionResult TryCreateProject() const;
- FOP_ActionResult TryLoadProjectDescriptor();
+ FAyonGenerateProjectParams ParseParameters(const FString& Params) const;
+ FAyon_ActionResult TryCreateProject() const;
+ FAyon_ActionResult TryLoadProjectDescriptor();
void AttachPluginsToProjectDescriptor();
- FOP_ActionResult TrySave();
+ FAyon_ActionResult TrySave();
};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
similarity index 95%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
index 3740c5285a..21571afd02 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
@@ -1,4 +1,4 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-DEFINE_LOG_CATEGORY_STATIC(LogCommandletOPGenerateProject, Log, All);
\ No newline at end of file
+DEFINE_LOG_CATEGORY_STATIC(LogCommandletOPGenerateProject, Log, All);
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
similarity index 94%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h
rename to openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
index 8cfcd067c0..4a7a6a3a9f 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h
+++ b/openpype/hosts/unreal/integration/UE_4.27/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
@@ -1,12 +1,13 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
-#include "Engine.h"
#include "OpenPypePublishInstance.generated.h"
UCLASS(Blueprintable)
-class OPENPYPE_API UOpenPypePublishInstance : public UPrimaryDataAsset
+class AYON_API UOpenPypePublishInstance : public UPrimaryDataAsset
{
GENERATED_UCLASS_BODY()
@@ -48,7 +49,7 @@ public:
/**
* Function for returning all the assets in the container combined.
- *
+ *
* @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
* returning raw pointers. Seems like an issue in UE5
*
@@ -94,7 +95,7 @@ private:
#ifdef WITH_EDITOR
void ColorOpenPypeDirs();
-
+
void SendNotification(const FString& Text) const;
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
diff --git a/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27.bat b/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27.bat
new file mode 100644
index 0000000000..96cdb96f8a
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27.bat
@@ -0,0 +1 @@
+D:\UE4\UE_4.27\Engine\Build\BatchFiles\RunUAT.bat BuildPlugin -plugin="D:\OpenPype\openpype\hosts\unreal\integration\UE_4.27\Ayon\Ayon.uplugin" -Package="D:\BuiltPlugins\4.27"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27_Window.bat b/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27_Window.bat
new file mode 100644
index 0000000000..1343843a82
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_4.27/BuildPlugin_4-27_Window.bat
@@ -0,0 +1 @@
+cmd /k "BuildPlugin_4-27.bat"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/.gitignore b/openpype/hosts/unreal/integration/UE_4.27/CommandletProject/.gitignore
similarity index 100%
rename from openpype/hosts/unreal/integration/UE_4.7/CommandletProject/.gitignore
rename to openpype/hosts/unreal/integration/UE_4.27/CommandletProject/.gitignore
diff --git a/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject b/openpype/hosts/unreal/integration/UE_4.27/CommandletProject/CommandletProject.uproject
similarity index 85%
rename from openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject
rename to openpype/hosts/unreal/integration/UE_4.27/CommandletProject/CommandletProject.uproject
index 4d75e03bf3..ea7bf21dc4 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject
+++ b/openpype/hosts/unreal/integration/UE_4.27/CommandletProject/CommandletProject.uproject
@@ -5,7 +5,7 @@
"Description": "",
"Plugins": [
{
- "Name": "OpenPype",
+ "Name": "Ayon",
"Enabled": true
}
]
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/DefaultOpenPypeSettings.ini b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/DefaultOpenPypeSettings.ini
deleted file mode 100644
index 8a883cf1db..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/DefaultOpenPypeSettings.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[/Script/OpenPype.OpenPypeSettings]
-FolderColor=(R=91,G=197,B=220,A=255)
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Content/Python/init_unreal.py
deleted file mode 100644
index b85f970699..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Content/Python/init_unreal.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import unreal
-
-openpype_detected = True
-try:
- from openpype.pipeline import install_host
- from openpype.hosts.unreal.api import UnrealHost
-
- openpype_host = UnrealHost()
-except ImportError as exc:
- openpype_host = None
- openpype_detected = False
- unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc))
-
-if openpype_detected:
- install_host(openpype_host)
-
-
-@unreal.uclass()
-class OpenPypeIntegration(unreal.OpenPypePythonBridge):
- @unreal.ufunction(override=True)
- def RunInPython_Popup(self):
- unreal.log_warning("OpenPype: showing tools popup")
- if openpype_detected:
- openpype_host.show_tools_popup()
-
- @unreal.ufunction(override=True)
- def RunInPython_Dialog(self):
- unreal.log_warning("OpenPype: showing tools dialog")
- if openpype_detected:
- openpype_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin
deleted file mode 100644
index b2cbe3cff3..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "FileVersion": 3,
- "Version": 1,
- "VersionName": "1.0",
- "FriendlyName": "OpenPype",
- "Description": "OpenPype Integration",
- "Category": "OpenPype.Integration",
- "CreatedBy": "Ondrej Samohel",
- "CreatedByURL": "https://openpype.io",
- "DocsURL": "https://openpype.io/docs/artist_hosts_unreal",
- "MarketplaceURL": "",
- "SupportURL": "https://pype.club/",
- "EngineVersion": "4.27",
- "CanContainContent": true,
- "Installed": true,
- "Modules": [
- {
- "Name": "OpenPype",
- "Type": "Editor",
- "LoadingPhase": "Default"
- }
- ]
-}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/README.md b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/README.md
deleted file mode 100644
index a08c1ada39..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# OpenPype Unreal Integration plugin - UE 4.x
-
-This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run.
-
-## How does this work
-
-Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button
-on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are
-declared in c++ but needs to be implemented during Unreal Editor
-startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor
-automatically.
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype128.png
deleted file mode 100644
index abe8a807ef..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype128.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype40.png
deleted file mode 100644
index f983e7a1f2..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype40.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype512.png
deleted file mode 100644
index 97c4d4326b..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype512.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp
deleted file mode 100644
index abb1975027..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "Commandlets/Implementations/OPGenerateProjectCommandlet.h"
-
-#include "Editor.h"
-#include "GameProjectUtils.h"
-#include "OPConstants.h"
-#include "Commandlets/OPActionResult.h"
-#include "ProjectDescriptor.h"
-
-int32 UOPGenerateProjectCommandlet::Main(const FString& CommandLineParams)
-{
- //Parses command line parameters & creates structure FProjectInformation
- const FOPGenerateProjectParams ParsedParams = FOPGenerateProjectParams(CommandLineParams);
- ProjectInformation = ParsedParams.GenerateUEProjectInformation();
-
- //Creates .uproject & other UE files
- EVALUATE_OP_ACTION_RESULT(TryCreateProject());
-
- //Loads created .uproject
- EVALUATE_OP_ACTION_RESULT(TryLoadProjectDescriptor());
-
- //Adds needed plugin to .uproject
- AttachPluginsToProjectDescriptor();
-
- //Saves .uproject
- EVALUATE_OP_ACTION_RESULT(TrySave());
-
- //When we are here, there should not be problems in generating Unreal Project for OpenPype
- return 0;
-}
-
-
-FOPGenerateProjectParams::FOPGenerateProjectParams(): FOPGenerateProjectParams("")
-{
-}
-
-FOPGenerateProjectParams::FOPGenerateProjectParams(const FString& CommandLineParams): CommandLineParams(
- CommandLineParams)
-{
- UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches);
-}
-
-FProjectInformation FOPGenerateProjectParams::GenerateUEProjectInformation() const
-{
- FProjectInformation ProjectInformation = FProjectInformation();
- ProjectInformation.ProjectFilename = GetProjectFileName();
-
- ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode");
-
- return ProjectInformation;
-}
-
-FString FOPGenerateProjectParams::TryGetToken(const int32 Index) const
-{
- return Tokens.IsValidIndex(Index) ? Tokens[Index] : "";
-}
-
-FString FOPGenerateProjectParams::GetProjectFileName() const
-{
- return TryGetToken(0);
-}
-
-bool FOPGenerateProjectParams::IsSwitchPresent(const FString& Switch) const
-{
- return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool
- {
- return Item.Equals(Switch);
- }
- );
-}
-
-
-UOPGenerateProjectCommandlet::UOPGenerateProjectCommandlet()
-{
- LogToConsole = true;
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TryCreateProject() const
-{
- FText FailReason;
- FText FailLog;
- TArray OutCreatedFiles;
-
- if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles))
- return FOP_ActionResult(EOP_ActionResult::ProjectNotCreated, FailReason);
- return FOP_ActionResult();
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TryLoadProjectDescriptor()
-{
- FText FailReason;
- const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason);
-
- return FOP_ActionResult(bLoaded ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotLoaded, FailReason);
-}
-
-void UOPGenerateProjectCommandlet::AttachPluginsToProjectDescriptor()
-{
- FPluginReferenceDescriptor OPPluginDescriptor;
- OPPluginDescriptor.bEnabled = true;
- OPPluginDescriptor.Name = OPConstants::OP_PluginName;
- ProjectDescriptor.Plugins.Add(OPPluginDescriptor);
-
- FPluginReferenceDescriptor PythonPluginDescriptor;
- PythonPluginDescriptor.bEnabled = true;
- PythonPluginDescriptor.Name = OPConstants::PythonScript_PluginName;
- ProjectDescriptor.Plugins.Add(PythonPluginDescriptor);
-
- FPluginReferenceDescriptor SequencerScriptingPluginDescriptor;
- SequencerScriptingPluginDescriptor.bEnabled = true;
- SequencerScriptingPluginDescriptor.Name = OPConstants::SequencerScripting_PluginName;
- ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor);
-
- FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor;
- MovieRenderPipelinePluginDescriptor.bEnabled = true;
- MovieRenderPipelinePluginDescriptor.Name = OPConstants::MovieRenderPipeline_PluginName;
- ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor);
-
- FPluginReferenceDescriptor EditorScriptingPluginDescriptor;
- EditorScriptingPluginDescriptor.bEnabled = true;
- EditorScriptingPluginDescriptor.Name = OPConstants::EditorScriptingUtils_PluginName;
- ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor);
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TrySave()
-{
- FText FailReason;
- const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason);
-
- return FOP_ActionResult(bSaved ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotSaved, FailReason);
-}
-
-FOPGenerateProjectParams UOPGenerateProjectCommandlet::ParseParameters(const FString& Params) const
-{
- FOPGenerateProjectParams ParamsResult;
-
- TArray Tokens, Switches;
- ParseCommandLine(*Params, Tokens, Switches);
-
- return ParamsResult;
-}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp
deleted file mode 100644
index 6e50ef2221..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-
-#include "Commandlets/OPActionResult.h"
-#include "Logging/OP_Log.h"
-
-EOP_ActionResult::Type& FOP_ActionResult::GetStatus()
-{
- return Status;
-}
-
-FText& FOP_ActionResult::GetReason()
-{
- return Reason;
-}
-
-FOP_ActionResult::FOP_ActionResult():Status(EOP_ActionResult::Type::Ok)
-{
-
-}
-
-FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum):Status(InEnum)
-{
- TryLog();
-}
-
-FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason)
-{
- TryLog();
-};
-
-bool FOP_ActionResult::IsProblem() const
-{
- return Status != EOP_ActionResult::Ok;
-}
-
-void FOP_ActionResult::TryLog() const
-{
- if(IsProblem())
- UE_LOG(LogCommandletOPGenerateProject, Error, TEXT("%s"), *Reason.ToString());
-}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp
deleted file mode 100644
index 29b1068c21..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp
+++ /dev/null
@@ -1 +0,0 @@
-#include "Logging/OP_Log.h"
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
deleted file mode 100644
index a32ebe32cb..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypePublishInstanceFactory.h"
-#include "OpenPypePublishInstance.h"
-
-UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
- : UFactory(ObjectInitializer)
-{
- SupportedClass = UOpenPypePublishInstance::StaticClass();
- bCreateNew = false;
- bEditorImport = true;
-}
-
-UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
-{
- check(InClass->IsChildOf(UOpenPypePublishInstance::StaticClass()));
- return NewObject(InParent, InClass, InName, Flags);
-}
-
-bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const {
- return false;
-}
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp
deleted file mode 100644
index 6ebfc528f0..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypePythonBridge.h"
-
-UOpenPypePythonBridge* UOpenPypePythonBridge::Get()
-{
- TArray OpenPypePythonBridgeClasses;
- GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses);
- int32 NumClasses = OpenPypePythonBridgeClasses.Num();
- if (NumClasses > 0)
- {
- return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
- }
- return nullptr;
-};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp
deleted file mode 100644
index dd4228dfd0..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "OpenPypeSettings.h"
-
-#include "Interfaces/IPluginManager.h"
-
-/**
- * Mainly is used for initializing default values if the DefaultOpenPypeSettings.ini file does not exist in the saved config
- */
-UOpenPypeSettings::UOpenPypeSettings(const FObjectInitializer& ObjectInitializer)
-{
-
- const FString ConfigFilePath = OPENPYPE_SETTINGS_FILEPATH;
-
- // This has to be probably in the future set using the UE Reflection system
- FColor Color;
- GConfig->GetColor(TEXT("/Script/OpenPype.OpenPypeSettings"), TEXT("FolderColor"), Color, ConfigFilePath);
-
- FolderColor = Color;
-}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp
deleted file mode 100644
index 0cc854c5ef..0000000000
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypeStyle.h"
-#include "Framework/Application/SlateApplication.h"
-#include "Styling/SlateStyle.h"
-#include "Styling/SlateStyleRegistry.h"
-
-
-TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr;
-
-void FOpenPypeStyle::Initialize()
-{
- if (!OpenPypeStyleInstance.IsValid())
- {
- OpenPypeStyleInstance = Create();
- FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance);
- }
-}
-
-void FOpenPypeStyle::Shutdown()
-{
- if (OpenPypeStyleInstance.IsValid())
- {
- FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance);
- OpenPypeStyleInstance.Reset();
- }
-}
-
-FName FOpenPypeStyle::GetStyleSetName()
-{
- static FName StyleSetName(TEXT("OpenPypeStyle"));
- return StyleSetName;
-}
-
-FName FOpenPypeStyle::GetContextName()
-{
- static FName ContextName(TEXT("OpenPype"));
- return ContextName;
-}
-
-#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ )
-
-const FVector2D Icon40x40(40.0f, 40.0f);
-
-TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create()
-{
- TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName());
- Style->SetContentRoot(FPaths::EnginePluginsDir() / TEXT("Marketplace/OpenPype/Resources"));
-
- return Style;
-}
-
-void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath)
-{
- FSlateStyleSet* Style = OpenPypeStyleInstance.Get();
-
- FString Name(GetContextName().ToString());
- Name = Name + "." + StyleName;
- Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40));
-
-
- FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
-}
-
-#undef IMAGE_BRUSH
-
-const ISlateStyle& FOpenPypeStyle::Get()
-{
- check(OpenPypeStyleInstance);
- return *OpenPypeStyleInstance;
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/.gitignore b/openpype/hosts/unreal/integration/UE_5.0/Ayon/.gitignore
similarity index 100%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/.gitignore
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/.gitignore
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Ayon.uplugin
similarity index 52%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Ayon.uplugin
index ff08edc13e..70ed8f6b9a 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Ayon.uplugin
@@ -2,23 +2,23 @@
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
- "FriendlyName": "OpenPype",
- "Description": "OpenPype Integration",
- "Category": "OpenPype.Integration",
+ "FriendlyName": "Ayon",
+ "Description": "Ayon Integration",
+ "Category": "Ayon.Integration",
"CreatedBy": "Ondrej Samohel",
- "CreatedByURL": "https://openpype.io",
- "DocsURL": "https://openpype.io/docs/artist_hosts_unreal",
+ "CreatedByURL": "https://ayon.ynput.io",
+ "DocsURL": "https://ayon.ynput.io/docs/artist_hosts_unreal",
"MarketplaceURL": "",
- "SupportURL": "https://pype.club/",
+ "SupportURL": "https://ynput.io/",
"CanContainContent": true,
"EngineVersion": "5.0",
"IsExperimentalVersion": false,
"Installed": true,
"Modules": [
{
- "Name": "OpenPype",
+ "Name": "Ayon",
"Type": "Editor",
"LoadingPhase": "Default"
}
]
-}
\ No newline at end of file
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Config/DefaultAyonSettings.ini b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Config/DefaultAyonSettings.ini
new file mode 100644
index 0000000000..9ad7f55201
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Config/DefaultAyonSettings.ini
@@ -0,0 +1,2 @@
+[/Script/Ayon.AyonSettings]
+FolderColor=(R=91,G=197,B=220,A=255)
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/FilterPlugin.ini b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Config/FilterPlugin.ini
similarity index 100%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/FilterPlugin.ini
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Config/FilterPlugin.ini
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Content/Python/init_unreal.py
new file mode 100644
index 0000000000..c0b1d0ce5d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Content/Python/init_unreal.py
@@ -0,0 +1,30 @@
+import unreal
+
+ayon_detected = True
+try:
+ from openpype.pipeline import install_host
+ from openpype.hosts.unreal.api import UnrealHost
+
+ ayon_host = UnrealHost()
+except ImportError as exc:
+ ayon_host = None
+ ayon_detected = False
+ unreal.log_error(f"Ayon: cannot load Ayon integration [ {exc} ]")
+
+if ayon_detected:
+ install_host(ayon_host)
+
+
+@unreal.uclass()
+class AyonIntegration(unreal.AyonPythonBridge):
+ @unreal.ufunction(override=True)
+ def RunInPython_Popup(self):
+ unreal.log_warning("Ayon: showing tools popup")
+ if ayon_detected:
+ ayon_host.show_tools_popup()
+
+ @unreal.ufunction(override=True)
+ def RunInPython_Dialog(self):
+ unreal.log_warning("Ayon: showing tools dialog")
+ if ayon_detected:
+ ayon_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/README.md b/openpype/hosts/unreal/integration/UE_5.0/Ayon/README.md
new file mode 100644
index 0000000000..865c8cafea
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/README.md
@@ -0,0 +1,3 @@
+# Ayon Unreal Integration plugin - UE 5.0
+
+This is plugin for Unreal Editor, creating menu for [Ayon](https://github.com/ynput/OpenPype) tools to run.
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon128.png b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon128.png
new file mode 100644
index 0000000000..799d849aa3
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon128.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon40.png b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon40.png
new file mode 100644
index 0000000000..f5bf40ea16
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon40.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon512.png b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon512.png
new file mode 100644
index 0000000000..990d5917e2
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Resources/ayon512.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Ayon.Build.cs
similarity index 93%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Ayon.Build.cs
index e1087fd720..fad0d357dd 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Ayon.Build.cs
@@ -2,9 +2,9 @@
using UnrealBuildTool;
-public class OpenPype : ModuleRules
+public class Ayon : ModuleRules
{
- public OpenPype(ReadOnlyTargetRules Target) : base(Target)
+ public Ayon(ReadOnlyTargetRules Target) : base(Target)
{
DefaultBuildSettings = BuildSettingsVersion.V2;
bLegacyPublicIncludePaths = false;
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Ayon.cpp
similarity index 57%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Ayon.cpp
index 65da29da35..5a1878ed1a 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Ayon.cpp
@@ -1,58 +1,57 @@
// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPype.h"
+#include "Ayon.h"
#include "ISettingsContainer.h"
#include "ISettingsModule.h"
#include "ISettingsSection.h"
-#include "OpenPypeStyle.h"
-#include "OpenPypeCommands.h"
-#include "OpenPypePythonBridge.h"
-#include "OpenPypeSettings.h"
-#include "Misc/MessageDialog.h"
+#include "AyonStyle.h"
+#include "AyonCommands.h"
+#include "AyonPythonBridge.h"
+#include "AyonSettings.h"
#include "ToolMenus.h"
-static const FName OpenPypeTabName("OpenPype");
+static const FName AyonTabName("Ayon");
-#define LOCTEXT_NAMESPACE "FOpenPypeModule"
+#define LOCTEXT_NAMESPACE "FAyonModule"
// This function is triggered when the plugin is staring up
-void FOpenPypeModule::StartupModule()
+void FAyonModule::StartupModule()
{
- FOpenPypeStyle::Initialize();
- FOpenPypeStyle::ReloadTextures();
- FOpenPypeCommands::Register();
+ FAyonStyle::Initialize();
+ FAyonStyle::ReloadTextures();
+ FAyonCommands::Register();
PluginCommands = MakeShareable(new FUICommandList);
PluginCommands->MapAction(
- FOpenPypeCommands::Get().OpenPypeTools,
- FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup),
+ FAyonCommands::Get().AyonTools,
+ FExecuteAction::CreateRaw(this, &FAyonModule::MenuPopup),
FCanExecuteAction());
PluginCommands->MapAction(
- FOpenPypeCommands::Get().OpenPypeToolsDialog,
- FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog),
+ FAyonCommands::Get().AyonToolsDialog,
+ FExecuteAction::CreateRaw(this, &FAyonModule::MenuDialog),
FCanExecuteAction());
UToolMenus::RegisterStartupCallback(
- FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FOpenPypeModule::RegisterMenus));
+ FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FAyonModule::RegisterMenus));
RegisterSettings();
}
-void FOpenPypeModule::ShutdownModule()
+void FAyonModule::ShutdownModule()
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
- FOpenPypeStyle::Shutdown();
+ FAyonStyle::Shutdown();
- FOpenPypeCommands::Unregister();
+ FAyonCommands::Unregister();
}
-void FOpenPypeModule::RegisterSettings()
+void FAyonModule::RegisterSettings()
{
ISettingsModule& SettingsModule = FModuleManager::LoadModuleChecked("Settings");
@@ -60,10 +59,10 @@ void FOpenPypeModule::RegisterSettings()
// TODO: After the movement of the plugin from the game to editor, it might be necessary to move this!
ISettingsContainerPtr SettingsContainer = SettingsModule.GetContainer("Project");
- UOpenPypeSettings* Settings = GetMutableDefault();
+ UAyonSettings* Settings = GetMutableDefault();
// Register the settings
- ISettingsSectionPtr SettingsSection = SettingsModule.RegisterSettings("Project", "OpenPype", "General",
+ ISettingsSectionPtr SettingsSection = SettingsModule.RegisterSettings("Project", "Ayon", "General",
LOCTEXT("RuntimeGeneralSettingsName",
"General"),
LOCTEXT("RuntimeGeneralSettingsDescription",
@@ -75,13 +74,13 @@ void FOpenPypeModule::RegisterSettings()
// validate those or just act to settings changes.
if (SettingsSection.IsValid())
{
- SettingsSection->OnModified().BindRaw(this, &FOpenPypeModule::HandleSettingsSaved);
+ SettingsSection->OnModified().BindRaw(this, &FAyonModule::HandleSettingsSaved);
}
}
-bool FOpenPypeModule::HandleSettingsSaved()
+bool FAyonModule::HandleSettingsSaved()
{
- UOpenPypeSettings* Settings = GetMutableDefault();
+ UAyonSettings* Settings = GetMutableDefault();
bool ResaveSettings = false;
// You can put any validation code in here and resave the settings in case an invalid
@@ -95,7 +94,7 @@ bool FOpenPypeModule::HandleSettingsSaved()
return true;
}
-void FOpenPypeModule::RegisterMenus()
+void FAyonModule::RegisterMenus()
{
// Owner will be used for cleanup in call to UToolMenus::UnregisterOwner
FToolMenuOwnerScoped OwnerScoped(this);
@@ -103,21 +102,21 @@ void FOpenPypeModule::RegisterMenus()
{
UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools");
{
- // FToolMenuSection& Section = Menu->FindOrAddSection("OpenPype");
+ // FToolMenuSection& Section = Menu->FindOrAddSection("Ayon");
FToolMenuSection& Section = Menu->AddSection(
- "OpenPype",
- TAttribute(FText::FromString("OpenPype")),
+ "Ayon",
+ TAttribute(FText::FromString("Ayon")),
FToolMenuInsert("Programming", EToolMenuInsertType::Before)
);
- Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeTools, PluginCommands);
- Section.AddMenuEntryWithCommandList(FOpenPypeCommands::Get().OpenPypeToolsDialog, PluginCommands);
+ Section.AddMenuEntryWithCommandList(FAyonCommands::Get().AyonTools, PluginCommands);
+ Section.AddMenuEntryWithCommandList(FAyonCommands::Get().AyonToolsDialog, PluginCommands);
}
UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.PlayToolBar");
{
FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("PluginTools");
{
FToolMenuEntry& Entry = Section.AddEntry(
- FToolMenuEntry::InitToolBarButton(FOpenPypeCommands::Get().OpenPypeTools));
+ FToolMenuEntry::InitToolBarButton(FAyonCommands::Get().AyonTools));
Entry.SetCommandList(PluginCommands);
}
}
@@ -125,16 +124,16 @@ void FOpenPypeModule::RegisterMenus()
}
-void FOpenPypeModule::MenuPopup()
+void FAyonModule::MenuPopup()
{
- UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
bridge->RunInPython_Popup();
}
-void FOpenPypeModule::MenuDialog()
+void FAyonModule::MenuDialog()
{
- UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get();
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
bridge->RunInPython_Dialog();
}
-IMPLEMENT_MODULE(FOpenPypeModule, OpenPype)
+IMPLEMENT_MODULE(FAyonModule, Ayon)
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
similarity index 76%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
index 06dcd67808..869aa45256 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
@@ -1,31 +1,30 @@
// Fill out your copyright notice in the Description page of Project Settings.
-#include "AssetContainer.h"
+#include "AyonAssetContainer.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Misc/PackageName.h"
-#include "Engine.h"
#include "Containers/UnrealString.h"
-UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer)
+UAyonAssetContainer::UAyonAssetContainer(const FObjectInitializer& ObjectInitializer)
: UAssetUserData(ObjectInitializer)
{
FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry");
- FString path = UAssetContainer::GetPathName();
- UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path);
+ FString path = UAyonAssetContainer::GetPathName();
+ UE_LOG(LogTemp, Warning, TEXT("UAyonAssetContainer %s"), *path);
FARFilter Filter;
Filter.PackagePaths.Add(FName(*path));
- AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded);
- AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved);
- AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed);
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonAssetContainer::OnAssetAdded);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonAssetContainer::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAyonAssetContainer::OnAssetRenamed);
}
-void UAssetContainer::OnAssetAdded(const FAssetData& AssetData)
+void UAyonAssetContainer::OnAssetAdded(const FAssetData& AssetData)
{
TArray split;
// get directory of current container
- FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
// get asset path and class
@@ -50,12 +49,12 @@ void UAssetContainer::OnAssetAdded(const FAssetData& AssetData)
}
}
-void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
+void UAyonAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
{
TArray split;
// get directory of current container
- FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
// get asset path and class
@@ -68,7 +67,7 @@ void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
// take interest only in paths starting with path of current container
- FString path = UAssetContainer::GetPathName();
+ FString path = UAyonAssetContainer::GetPathName();
FString lpp = FPackageName::GetLongPackagePath(*path);
if (assetDir.StartsWith(*selfDir))
@@ -83,12 +82,12 @@ void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
}
}
-void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
+void UAyonAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
{
TArray split;
// get directory of current container
- FString selfFullPath = UAssetContainer::GetPathName();
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
// get asset path and class
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
new file mode 100644
index 0000000000..086fc1036e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
@@ -0,0 +1,20 @@
+#include "AyonAssetContainerFactory.h"
+#include "AyonAssetContainer.h"
+
+UAyonAssetContainerFactory::UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonAssetContainer::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ UAyonAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags);
+ return AssetContainer;
+}
+
+bool UAyonAssetContainerFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonCommands.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonCommands.cpp
new file mode 100644
index 0000000000..566ee1dcd1
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonCommands.cpp
@@ -0,0 +1,13 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonCommands.h"
+
+#define LOCTEXT_NAMESPACE "FAyonModule"
+
+void FAyonCommands::RegisterCommands()
+{
+ UI_COMMAND(AyonTools, "Ayon Tools", "Pipeline tools", EUserInterfaceActionType::Button, FInputChord());
+ UI_COMMAND(AyonToolsDialog, "Ayon Tools Dialog", "Pipeline tools dialog", EUserInterfaceActionType::Button, FInputChord());
+}
+
+#undef LOCTEXT_NAMESPACE
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonLib.cpp
similarity index 79%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonLib.cpp
index 34faba1f49..7cfa0c9c30 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonLib.cpp
@@ -1,9 +1,7 @@
// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypeLib.h"
+#include "AyonLib.h"
#include "AssetViewUtils.h"
-#include "Misc/Paths.h"
-#include "Misc/ConfigCacheIni.h"
#include "UObject/UnrealType.h"
/**
@@ -13,7 +11,7 @@
* @warning This color will appear only after Editor restart. Is there a better way?
*/
-bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor, const bool& bForceAdd)
+bool UAyonLib::SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor, const bool& bForceAdd)
{
if (AssetViewUtils::DoesFolderExist(FolderPath))
{
@@ -31,11 +29,11 @@ bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor&
}
/**
- * Returns all properties on given object
+ * Returns all poperties on given object
* @param cls - class
* @return TArray of properties
*/
-TArray UOpenPypeLib::GetAllProperties(UClass* cls)
+TArray UAyonLib::GetAllProperties(UClass* cls)
{
TArray Ret;
if (cls != nullptr)
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
new file mode 100644
index 0000000000..8d34090a15
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
@@ -0,0 +1,204 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.h"
+#include "AssetRegistry/AssetRegistryModule.h"
+#include "AssetToolsModule.h"
+#include "Framework/Notifications/NotificationManager.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
+#include "Widgets/Notifications/SNotificationList.h"
+
+
+//Moves all the invalid pointers to the end to prepare them for the shrinking
+#define REMOVE_INVALID_ENTRIES(VAR) VAR.CompactStable(); \
+ VAR.Shrink();
+
+UAyonPublishInstance::UAyonPublishInstance(const FObjectInitializer& ObjectInitializer)
+ : UPrimaryDataAsset(ObjectInitializer)
+{
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<
+ FAssetRegistryModule>("AssetRegistry");
+
+ const FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked(
+ "PropertyEditor");
+
+ FString Left, Right;
+ GetPathName().Split("/" + GetName(), &Left, &Right);
+
+ FARFilter Filter;
+ Filter.PackagePaths.Emplace(FName(Left));
+
+ TArray FoundAssets;
+ AssetRegistryModule.GetRegistry().GetAssets(Filter, FoundAssets);
+
+ for (const FAssetData& AssetData : FoundAssets)
+ OnAssetCreated(AssetData);
+
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonPublishInstance::OnAssetCreated);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonPublishInstance::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetUpdated().AddUObject(this, &UAyonPublishInstance::OnAssetUpdated);
+
+#ifdef WITH_EDITOR
+ ColorAyonDirs();
+#endif
+}
+
+void UAyonPublishInstance::OnAssetCreated(const FAssetData& InAssetData)
+{
+ TArray split;
+
+ UObject* Asset = InAssetData.GetAsset();
+
+ if (!IsValid(Asset))
+ {
+ UE_LOG(LogAssetData, Warning, TEXT("Asset \"%s\" is not valid! Skipping the addition."),
+ *InAssetData.ObjectPath.ToString());
+ return;
+ }
+
+ const bool result = IsUnderSameDir(Asset) && Cast(Asset) == nullptr;
+
+ if (result)
+ {
+ if (AssetDataInternal.Emplace(Asset).IsValidId())
+ {
+ UE_LOG(LogTemp, Log, TEXT("Added an Asset to PublishInstance - Publish Instance: %s, Asset %s"),
+ *this->GetName(), *Asset->GetName());
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetRemoved(const FAssetData& InAssetData)
+{
+ if (Cast(InAssetData.GetAsset()) == nullptr)
+ {
+ if (AssetDataInternal.Contains(nullptr))
+ {
+ AssetDataInternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ }
+ else
+ {
+ AssetDataExternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetUpdated(const FAssetData& InAssetData)
+{
+ REMOVE_INVALID_ENTRIES(AssetDataInternal);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal);
+}
+
+bool UAyonPublishInstance::IsUnderSameDir(const UObject* InAsset) const
+{
+ FString ThisLeft, ThisRight;
+ this->GetPathName().Split(this->GetName(), &ThisLeft, &ThisRight);
+
+ return InAsset->GetPathName().StartsWith(ThisLeft);
+}
+
+#ifdef WITH_EDITOR
+
+void UAyonPublishInstance::ColorAyonDirs()
+{
+ FString PathName = this->GetPathName();
+
+ //Check whether the path contains the defined Ayon folder
+ if (!PathName.Contains(TEXT("Ayon"))) return;
+
+ //Get the base path for open pype
+ FString PathLeft, PathRight;
+ PathName.Split(FString("Ayon"), &PathLeft, &PathRight);
+
+ if (PathLeft.IsEmpty() || PathRight.IsEmpty())
+ {
+ UE_LOG(LogAssetData, Error, TEXT("Failed to retrieve the base Ayon directory!"))
+ return;
+ }
+
+ PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
+
+ //Get the current settings
+ const UAyonSettings* Settings = GetMutableDefault();
+
+ //Color the base folder
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+
+ //Get Sub paths, iterate through them and color them according to the folder color in UAyonSettings
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
+ "AssetRegistry");
+
+ TArray PathList;
+
+ AssetRegistryModule.Get().GetSubPaths(PathName, PathList, true);
+
+ if (PathList.Num() > 0)
+ {
+ for (const FString& Path : PathList)
+ {
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ }
+ }
+}
+
+void UAyonPublishInstance::SendNotification(const FString& Text) const
+{
+ FNotificationInfo Info{FText::FromString(Text)};
+
+ Info.bFireAndForget = true;
+ Info.bUseLargeFont = false;
+ Info.bUseThrobber = false;
+ Info.bUseSuccessFailIcons = false;
+ Info.ExpireDuration = 4.f;
+ Info.FadeOutDuration = 2.f;
+
+ FSlateNotificationManager::Get().AddNotification(Info);
+
+ UE_LOG(LogAssetData, Warning,
+ TEXT(
+ "Removed duplicated asset from the AssetsDataExternal in Container \"%s\", Asset is already included in the AssetDataInternal!"
+ ), *GetName()
+ )
+}
+
+
+void UAyonPublishInstance::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
+{
+ Super::PostEditChangeProperty(PropertyChangedEvent);
+
+ if (PropertyChangedEvent.ChangeType == EPropertyChangeType::ValueSet &&
+ PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(
+ UAyonPublishInstance, AssetDataExternal))
+ {
+ // Check for duplicated assets
+ for (const auto& Asset : AssetDataInternal)
+ {
+ if (AssetDataExternal.Contains(Asset))
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification(
+ "You are not allowed to add assets into AssetDataExternal which are already included in AssetDataInternal!");
+ }
+ }
+
+ // Check if no UAyonPublishInstance type assets are included
+ for (const auto& Asset : AssetDataExternal)
+ {
+ if (Cast(Asset.Get()) != nullptr)
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification("You are not allowed to add publish instances!");
+ }
+ }
+ }
+}
+
+#endif
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
new file mode 100644
index 0000000000..f79c428a6d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
@@ -0,0 +1,23 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#include "AyonPublishInstanceFactory.h"
+#include "AyonPublishInstance.h"
+
+UAyonPublishInstanceFactory::UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonPublishInstance::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonPublishInstanceFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ check(InClass->IsChildOf(UAyonPublishInstance::StaticClass()));
+ return NewObject(InParent, InClass, InName, Flags);
+}
+
+bool UAyonPublishInstanceFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
new file mode 100644
index 0000000000..0ed4b2f704
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
@@ -0,0 +1,14 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "AyonPythonBridge.h"
+
+UAyonPythonBridge* UAyonPythonBridge::Get()
+{
+ TArray AyonPythonBridgeClasses;
+ GetDerivedClasses(UAyonPythonBridge::StaticClass(), AyonPythonBridgeClasses);
+ int32 NumClasses = AyonPythonBridgeClasses.Num();
+ if (NumClasses > 0)
+ {
+ return Cast(AyonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
+ }
+ return nullptr;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonSettings.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonSettings.cpp
new file mode 100644
index 0000000000..da388fbc8f
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonSettings.cpp
@@ -0,0 +1,21 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonSettings.h"
+
+#include "Interfaces/IPluginManager.h"
+#include "UObject/UObjectGlobals.h"
+
+/**
+ * Mainly is used for initializing default values if the DefaultAyonSettings.ini file does not exist in the saved config
+ */
+UAyonSettings::UAyonSettings(const FObjectInitializer& ObjectInitializer)
+{
+
+ const FString ConfigFilePath = AYON_SETTINGS_FILEPATH;
+
+ // This has to be probably in the future set using the UE Reflection system
+ FColor Color;
+ GConfig->GetColor(TEXT("/Script/Ayon.AyonSettings"), TEXT("FolderColor"), Color, ConfigFilePath);
+
+ FolderColor = Color;
+}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonStyle.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonStyle.cpp
new file mode 100644
index 0000000000..d88df78735
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/AyonStyle.cpp
@@ -0,0 +1,62 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonStyle.h"
+#include "Framework/Application/SlateApplication.h"
+#include "Styling/SlateStyleRegistry.h"
+#include "Slate/SlateGameResources.h"
+#include "Interfaces/IPluginManager.h"
+#include "Styling/SlateStyleMacros.h"
+
+#define RootToContentDir Style->RootToContentDir
+
+TSharedPtr FAyonStyle::AyonStyleInstance = nullptr;
+
+void FAyonStyle::Initialize()
+{
+ if (!AyonStyleInstance.IsValid())
+ {
+ AyonStyleInstance = Create();
+ FSlateStyleRegistry::RegisterSlateStyle(*AyonStyleInstance);
+ }
+}
+
+void FAyonStyle::Shutdown()
+{
+ FSlateStyleRegistry::UnRegisterSlateStyle(*AyonStyleInstance);
+ ensure(AyonStyleInstance.IsUnique());
+ AyonStyleInstance.Reset();
+}
+
+FName FAyonStyle::GetStyleSetName()
+{
+ static FName StyleSetName(TEXT("AyonStyle"));
+ return StyleSetName;
+}
+
+const FVector2D Icon16x16(16.0f, 16.0f);
+const FVector2D Icon20x20(20.0f, 20.0f);
+const FVector2D Icon40x40(40.0f, 40.0f);
+
+TSharedRef< FSlateStyleSet > FAyonStyle::Create()
+{
+ TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("AyonStyle"));
+ Style->SetContentRoot(IPluginManager::Get().FindPlugin("Ayon")->GetBaseDir() / TEXT("Resources"));
+
+ Style->Set("Ayon.AyonTools", new IMAGE_BRUSH(TEXT("ayon40"), Icon40x40));
+ Style->Set("Ayon.AyonToolsDialog", new IMAGE_BRUSH(TEXT("ayon40"), Icon40x40));
+
+ return Style;
+}
+
+void FAyonStyle::ReloadTextures()
+{
+ if (FSlateApplication::IsInitialized())
+ {
+ FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
+ }
+}
+
+const ISlateStyle& FAyonStyle::Get()
+{
+ return *AyonStyleInstance;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
new file mode 100644
index 0000000000..2a137e3ed7
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
@@ -0,0 +1,40 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "Commandlets/AyonActionResult.h"
+#include "Logging/Ayon_Log.h"
+
+EAyon_ActionResult::Type& FAyon_ActionResult::GetStatus()
+{
+ return Status;
+}
+
+FText& FAyon_ActionResult::GetReason()
+{
+ return Reason;
+}
+
+FAyon_ActionResult::FAyon_ActionResult():Status(EAyon_ActionResult::Type::Ok)
+{
+
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum):Status(InEnum)
+{
+ TryLog();
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason)
+{
+ TryLog();
+};
+
+bool FAyon_ActionResult::IsProblem() const
+{
+ return Status != EAyon_ActionResult::Ok;
+}
+
+void FAyon_ActionResult::TryLog() const
+{
+ if(IsProblem())
+ UE_LOG(LogCommandletAyonGenerateProject, Error, TEXT("%s"), *Reason.ToString());
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
new file mode 100644
index 0000000000..ed876c8128
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
@@ -0,0 +1,140 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "Commandlets/Implementations/AyonGenerateProjectCommandlet.h"
+
+#include "GameProjectUtils.h"
+#include "AyonConstants.h"
+#include "Commandlets/AyonActionResult.h"
+#include "ProjectDescriptor.h"
+
+int32 UAyonGenerateProjectCommandlet::Main(const FString& CommandLineParams)
+{
+ //Parses command line parameters & creates structure FProjectInformation
+ const FAyonGenerateProjectParams ParsedParams = FAyonGenerateProjectParams(CommandLineParams);
+ ProjectInformation = ParsedParams.GenerateUEProjectInformation();
+
+ //Creates .uproject & other UE files
+ EVALUATE_Ayon_ACTION_RESULT(TryCreateProject());
+
+ //Loads created .uproject
+ EVALUATE_Ayon_ACTION_RESULT(TryLoadProjectDescriptor());
+
+ //Adds needed plugin to .uproject
+ AttachPluginsToProjectDescriptor();
+
+ //Saves .uproject
+ EVALUATE_Ayon_ACTION_RESULT(TrySave());
+
+ //When we are here, there should not be problems in generating Unreal Project for Ayon
+ return 0;
+}
+
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(): FAyonGenerateProjectParams("")
+{
+}
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(const FString& CommandLineParams): CommandLineParams(
+ CommandLineParams)
+{
+ UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches);
+}
+
+FProjectInformation FAyonGenerateProjectParams::GenerateUEProjectInformation() const
+{
+ FProjectInformation ProjectInformation = FProjectInformation();
+ ProjectInformation.ProjectFilename = GetProjectFileName();
+
+ ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode");
+
+ return ProjectInformation;
+}
+
+FString FAyonGenerateProjectParams::TryGetToken(const int32 Index) const
+{
+ return Tokens.IsValidIndex(Index) ? Tokens[Index] : "";
+}
+
+FString FAyonGenerateProjectParams::GetProjectFileName() const
+{
+ return TryGetToken(0);
+}
+
+bool FAyonGenerateProjectParams::IsSwitchPresent(const FString& Switch) const
+{
+ return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool
+ {
+ return Item.Equals(Switch);
+ }
+ );
+}
+
+
+UAyonGenerateProjectCommandlet::UAyonGenerateProjectCommandlet()
+{
+ LogToConsole = true;
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryCreateProject() const
+{
+ FText FailReason;
+ FText FailLog;
+ TArray OutCreatedFiles;
+
+ if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles))
+ return FAyon_ActionResult(EAyon_ActionResult::ProjectNotCreated, FailReason);
+ return FAyon_ActionResult();
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryLoadProjectDescriptor()
+{
+ FText FailReason;
+ const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bLoaded ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotLoaded, FailReason);
+}
+
+void UAyonGenerateProjectCommandlet::AttachPluginsToProjectDescriptor()
+{
+ FPluginReferenceDescriptor AyonPluginDescriptor;
+ AyonPluginDescriptor.bEnabled = true;
+ AyonPluginDescriptor.Name = AyonConstants::Ayon_PluginName;
+ ProjectDescriptor.Plugins.Add(AyonPluginDescriptor);
+
+ FPluginReferenceDescriptor PythonPluginDescriptor;
+ PythonPluginDescriptor.bEnabled = true;
+ PythonPluginDescriptor.Name = AyonConstants::PythonScript_PluginName;
+ ProjectDescriptor.Plugins.Add(PythonPluginDescriptor);
+
+ FPluginReferenceDescriptor SequencerScriptingPluginDescriptor;
+ SequencerScriptingPluginDescriptor.bEnabled = true;
+ SequencerScriptingPluginDescriptor.Name = AyonConstants::SequencerScripting_PluginName;
+ ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor);
+
+ FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor;
+ MovieRenderPipelinePluginDescriptor.bEnabled = true;
+ MovieRenderPipelinePluginDescriptor.Name = AyonConstants::MovieRenderPipeline_PluginName;
+ ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor);
+
+ FPluginReferenceDescriptor EditorScriptingPluginDescriptor;
+ EditorScriptingPluginDescriptor.bEnabled = true;
+ EditorScriptingPluginDescriptor.Name = AyonConstants::EditorScriptingUtils_PluginName;
+ ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor);
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TrySave()
+{
+ FText FailReason;
+ const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bSaved ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotSaved, FailReason);
+}
+
+FAyonGenerateProjectParams UAyonGenerateProjectCommandlet::ParseParameters(const FString& Params) const
+{
+ FAyonGenerateProjectParams ParamsResult;
+
+ TArray Tokens, Switches;
+ ParseCommandLine(*Params, Tokens, Switches);
+
+ return ParamsResult;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
similarity index 93%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
index 05d5c8a87d..7a65fd0c98 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
@@ -1,12 +1,14 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
#include "OpenPypePublishInstance.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetToolsModule.h"
#include "Framework/Notifications/NotificationManager.h"
-#include "OpenPypeLib.h"
-#include "OpenPypeSettings.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
#include "Widgets/Notifications/SNotificationList.h"
@@ -125,10 +127,10 @@ void UOpenPypePublishInstance::ColorOpenPypeDirs()
PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
//Get the current settings
- const UOpenPypeSettings* Settings = GetMutableDefault();
+ const UAyonSettings* Settings = GetMutableDefault();
//Color the base folder
- UOpenPypeLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
//Get Sub paths, iterate through them and color them according to the folder color in UOpenPypeSettings
const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
@@ -142,7 +144,7 @@ void UOpenPypePublishInstance::ColorOpenPypeDirs()
{
for (const FString& Path : PathList)
{
- UOpenPypeLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
}
}
}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Ayon.h
similarity index 81%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Ayon.h
index b89760099b..bb25430411 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Ayon.h
@@ -3,10 +3,9 @@
#pragma once
#include "CoreMinimal.h"
-#include "Modules/ModuleManager.h"
-class FOpenPypeModule : public IModuleInterface
+class FAyonModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainer.h
similarity index 80%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainer.h
index 9157569c08..d40642b149 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainer.h
@@ -6,20 +6,17 @@
#include "UObject/NoExportTypes.h"
#include "Engine/AssetUserData.h"
#include "AssetRegistry/AssetData.h"
-#include "AssetContainer.generated.h"
+#include "AyonAssetContainer.generated.h"
-/**
- *
- */
UCLASS(Blueprintable)
-class OPENPYPE_API UAssetContainer : public UAssetUserData
+class AYON_API UAyonAssetContainer : public UAssetUserData
{
GENERATED_BODY()
public:
- UAssetContainer(const FObjectInitializer& ObjectInitalizer);
- // ~UAssetContainer();
+ UAyonAssetContainer(const FObjectInitializer& ObjectInitalizer);
+ // ~UAyonAssetContainer();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Assets")
TArray assets;
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
similarity index 68%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
index 9095f8a3d7..da424cde2e 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
@@ -4,18 +4,15 @@
#include "CoreMinimal.h"
#include "Factories/Factory.h"
-#include "AssetContainerFactory.generated.h"
+#include "AyonAssetContainerFactory.generated.h"
-/**
- *
- */
UCLASS()
-class OPENPYPE_API UAssetContainerFactory : public UFactory
+class AYON_API UAyonAssetContainerFactory : public UFactory
{
GENERATED_BODY()
public:
- UAssetContainerFactory(const FObjectInitializer& ObjectInitializer);
+ UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer);
virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
virtual bool ShouldShowInNewMenu() const override;
};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonCommands.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonCommands.h
new file mode 100644
index 0000000000..9c40dc8241
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonCommands.h
@@ -0,0 +1,24 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Framework/Commands/Commands.h"
+#include "AyonStyle.h"
+
+class FAyonCommands : public TCommands
+{
+public:
+
+ FAyonCommands()
+ : TCommands(TEXT("Ayon"), NSLOCTEXT("Contexts", "Ayon", "Ayon Tools"), NAME_None, FAyonStyle::GetStyleSetName())
+ {
+ }
+
+ // TCommands<> interface
+ virtual void RegisterCommands() override;
+
+public:
+ TSharedPtr< FUICommandInfo > AyonTools;
+ TSharedPtr< FUICommandInfo > AyonToolsDialog;
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonConstants.h
similarity index 83%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonConstants.h
index f4587f7a50..5fe7c14360 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonConstants.h
@@ -1,9 +1,9 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-namespace OPConstants
+namespace AyonConstants
{
- const FString OP_PluginName = "OpenPype";
+ const FString Ayon_PluginName = "Ayon";
const FString PythonScript_PluginName = "PythonScriptPlugin";
const FString SequencerScripting_PluginName = "SequencerScripting";
const FString MovieRenderPipeline_PluginName = "MovieRenderPipeline";
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonLib.h
similarity index 75%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonLib.h
index ef4d1027ea..da83b448fb 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonLib.h
@@ -1,12 +1,11 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-#include "Engine.h"
-#include "OpenPypeLib.generated.h"
+#include "AyonLib.generated.h"
UCLASS(Blueprintable)
-class OPENPYPE_API UOpenPypeLib : public UBlueprintFunctionLibrary
+class AYON_API UAyonLib : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstance.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstance.h
new file mode 100644
index 0000000000..c89388036f
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstance.h
@@ -0,0 +1,104 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.generated.h"
+
+
+UCLASS(Blueprintable)
+class AYON_API UAyonPublishInstance : public UPrimaryDataAsset
+{
+ GENERATED_UCLASS_BODY()
+
+public:
+ /**
+ /**
+ * Retrieves all the assets which are monitored by the Publish Instance (Monitors assets in the directory which is
+ * placed in)
+ *
+ * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetInternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataInternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Retrieves all the assets which have been added manually by the Publish Instance
+ *
+ * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetExternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataExternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Function for returning all the assets in the container combined.
+ *
+ * @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
+ * returning raw pointers. Seems like an issue in UE5
+ *
+ * @attention If the bAddExternalAssets variable is false, external assets won't be included!
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetAllAssets() const
+ {
+ const TSet>& IteratedSet = bAddExternalAssets
+ ? AssetDataInternal.Union(AssetDataExternal)
+ : AssetDataInternal;
+
+ //Create a new TSet only with raw pointers.
+ TSet ResultSet;
+
+ for (auto& Asset : IteratedSet)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+private:
+ UPROPERTY(VisibleAnywhere, Category="Assets")
+ TSet> AssetDataInternal;
+
+ /**
+ * This property allows exposing the array to include other assets from any other directory than what it's currently
+ * monitoring. NOTE: that these assets have to be added manually! They are not automatically registered or added!
+ */
+ UPROPERTY(EditAnywhere, Category = "Assets")
+ bool bAddExternalAssets = false;
+
+ UPROPERTY(EditAnywhere, meta=(EditCondition="bAddExternalAssets"), Category="Assets")
+ TSet> AssetDataExternal;
+
+
+ void OnAssetCreated(const FAssetData& InAssetData);
+ void OnAssetRemoved(const FAssetData& InAssetData);
+ void OnAssetUpdated(const FAssetData& InAssetData);
+
+ bool IsUnderSameDir(const UObject* InAsset) const;
+
+#ifdef WITH_EDITOR
+
+ void ColorAyonDirs();
+
+ void SendNotification(const FString& Text) const;
+ virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
+
+#endif
+};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
similarity index 54%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
index 3fdb984411..3cef8e76b2 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
@@ -1,20 +1,22 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
#include "CoreMinimal.h"
#include "Factories/Factory.h"
-#include "OpenPypePublishInstanceFactory.generated.h"
+#include "AyonPublishInstanceFactory.generated.h"
/**
*
*/
UCLASS()
-class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory
+class AYON_API UAyonPublishInstanceFactory : public UFactory
{
GENERATED_BODY()
public:
- UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
+ UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
virtual bool ShouldShowInNewMenu() const override;
};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPythonBridge.h
similarity index 70%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPythonBridge.h
index 827f76f56b..3c429fd7d3 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonPythonBridge.h
@@ -1,16 +1,15 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-#include "Engine.h"
-#include "OpenPypePythonBridge.generated.h"
+#include "AyonPythonBridge.generated.h"
UCLASS(Blueprintable)
-class UOpenPypePythonBridge : public UObject
+class UAyonPythonBridge : public UObject
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = Python)
- static UOpenPypePythonBridge* Get();
+ static UAyonPythonBridge* Get();
UFUNCTION(BlueprintImplementableEvent, Category = Python)
void RunInPython_Popup() const;
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonSettings.h
similarity index 59%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonSettings.h
index b818fe0e95..4f12d1a5f2 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonSettings.h
@@ -1,15 +1,15 @@
-// Copyright 2023, Ayon, All rights reserved.
+// Copyright 2023, Ayon, All rights reserved.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
-#include "OpenPypeSettings.generated.h"
+#include "AyonSettings.generated.h"
-#define OPENPYPE_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Config") / TEXT("DefaultOpenPypeSettings.ini")
+#define AYON_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("Ayon")->GetBaseDir() / TEXT("Config") / TEXT("DefaultAyonSettings.ini")
-UCLASS(Config=OpenPypeSettings, DefaultConfig)
-class OPENPYPE_API UOpenPypeSettings : public UObject
+UCLASS(Config=AyonSettings, DefaultConfig)
+class AYON_API UAyonSettings : public UObject
{
GENERATED_UCLASS_BODY()
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonStyle.h
similarity index 79%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonStyle.h
index 039abe96ef..58f6af656e 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/AyonStyle.h
@@ -3,7 +3,7 @@
#include "CoreMinimal.h"
#include "Styling/SlateStyle.h"
-class FOpenPypeStyle
+class FAyonStyle
{
public:
static void Initialize();
@@ -15,5 +15,5 @@ public:
private:
static TSharedRef< class FSlateStyleSet > Create();
- static TSharedPtr< class FSlateStyleSet > OpenPypeStyleInstance;
+ static TSharedPtr< class FSlateStyleSet > AyonStyleInstance;
};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
similarity index 63%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
index 322a23a3e8..bb995ec452 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
@@ -3,23 +3,23 @@
#pragma once
#include "CoreMinimal.h"
-#include "OPActionResult.generated.h"
+#include "AyonActionResult.generated.h"
/**
* @brief This macro returns error code when is problem or does nothing when there is no problem.
- * @param ActionResult FOP_ActionResult structure
+ * @param ActionResult FAyon_ActionResult structure
*/
-#define EVALUATE_OP_ACTION_RESULT(ActionResult) \
+#define EVALUATE_Ayon_ACTION_RESULT(ActionResult) \
if(ActionResult.IsProblem()) \
return ActionResult.GetStatus();
/**
* @brief This enum values are humanly readable mapping of error codes.
* Here should be all error codes to be possible find what went wrong.
-* TODO: In the future a web document should exists with the mapped error code & what problem occurred & how to repair it...
+* TODO: In the future should exists an web document where is mapped error code & what problem occured & how to repair it...
*/
UENUM()
-namespace EOP_ActionResult
+namespace EAyon_ActionResult
{
enum Type
{
@@ -27,11 +27,11 @@ namespace EOP_ActionResult
ProjectNotCreated,
ProjectNotLoaded,
ProjectNotSaved,
- //....Here insert another values
+ //....Here insert another values
//Do not remove!
//Usable for looping through enum values
- __Last UMETA(Hidden)
+ __Last UMETA(Hidden)
};
}
@@ -40,44 +40,44 @@ namespace EOP_ActionResult
* @brief This struct holds action result enum and optionally reason of fail
*/
USTRUCT()
-struct FOP_ActionResult
+struct FAyon_ActionResult
{
GENERATED_BODY()
public:
/** @brief Default constructor usable when there is no problem */
- FOP_ActionResult();
+ FAyon_ActionResult();
/**
* @brief This constructor initializes variables & attempts to log when is error
* @param InEnum Status
*/
- FOP_ActionResult(const EOP_ActionResult::Type& InEnum);
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum);
/**
* @brief This constructor initializes variables & attempts to log when is error
* @param InEnum Status
* @param InReason Reason of potential fail
*/
- FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason);
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason);
private:
/** @brief Action status */
- EOP_ActionResult::Type Status;
+ EAyon_ActionResult::Type Status;
/** @brief Optional reason of fail */
- FText Reason;
+ FText Reason;
public:
/**
* @brief Checks if there is problematic state
- * @return true when status is not equal to EOP_ActionResult::Ok
+ * @return true when status is not equal to EAyon_ActionResult::Ok
*/
bool IsProblem() const;
- EOP_ActionResult::Type& GetStatus();
+ EAyon_ActionResult::Type& GetStatus();
FText& GetReason();
-private:
+private:
void TryLog() const;
};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
similarity index 63%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
index 6a6c6406e7..da8e9af661 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
@@ -3,10 +3,10 @@
#include "GameProjectUtils.h"
-#include "Commandlets/OPActionResult.h"
+#include "Commandlets/AyonActionResult.h"
#include "ProjectDescriptor.h"
#include "Commandlets/Commandlet.h"
-#include "OPGenerateProjectCommandlet.generated.h"
+#include "AyonGenerateProjectCommandlet.generated.h"
struct FProjectDescriptor;
struct FProjectInformation;
@@ -15,7 +15,7 @@ struct FProjectInformation;
* @brief Structure which parses command line parameters and generates FProjectInformation
*/
USTRUCT()
-struct FOPGenerateProjectParams
+struct FAyonGenerateProjectParams
{
GENERATED_BODY()
@@ -25,8 +25,8 @@ private:
TArray Switches;
public:
- FOPGenerateProjectParams();
- FOPGenerateProjectParams(const FString& CommandLineParams);
+ FAyonGenerateProjectParams();
+ FAyonGenerateProjectParams(const FString& CommandLineParams);
FProjectInformation GenerateUEProjectInformation() const;
@@ -38,7 +38,7 @@ private:
};
UCLASS()
-class OPENPYPE_API UOPGenerateProjectCommandlet : public UCommandlet
+class AYON_API UAyonGenerateProjectCommandlet : public UCommandlet
{
GENERATED_BODY()
@@ -47,15 +47,15 @@ private:
FProjectDescriptor ProjectDescriptor;
public:
- UOPGenerateProjectCommandlet();
+ UAyonGenerateProjectCommandlet();
virtual int32 Main(const FString& CommandLineParams) override;
private:
- FOPGenerateProjectParams ParseParameters(const FString& Params) const;
- FOP_ActionResult TryCreateProject() const;
- FOP_ActionResult TryLoadProjectDescriptor();
+ FAyonGenerateProjectParams ParseParameters(const FString& Params) const;
+ FAyon_ActionResult TryCreateProject() const;
+ FAyon_ActionResult TryLoadProjectDescriptor();
void AttachPluginsToProjectDescriptor();
- FOP_ActionResult TrySave();
+ FAyon_ActionResult TrySave();
};
diff --git a/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
new file mode 100644
index 0000000000..25b33a63e8
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
@@ -0,0 +1,4 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+
+DEFINE_LOG_CATEGORY_STATIC(LogCommandletAyonGenerateProject, Log, All);
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
similarity index 94%
rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h
rename to openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
index bce41ef1b1..9c0c4a69e5 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h
+++ b/openpype/hosts/unreal/integration/UE_5.0/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
@@ -1,12 +1,13 @@
// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
#pragma once
-#include "Engine.h"
#include "OpenPypePublishInstance.generated.h"
UCLASS(Blueprintable)
-class OPENPYPE_API UOpenPypePublishInstance : public UPrimaryDataAsset
+class AYON_API UOpenPypePublishInstance : public UPrimaryDataAsset
{
GENERATED_UCLASS_BODY()
@@ -49,7 +50,7 @@ public:
/**
* Function for returning all the assets in the container combined.
- *
+ *
* @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
* returning raw pointers. Seems like an issue in UE5
*
diff --git a/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0.bat b/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0.bat
new file mode 100644
index 0000000000..473c248cbe
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0.bat
@@ -0,0 +1 @@
+"C:\Program Files\Epic Games\UE_5.0\Engine\Build\BatchFiles\RunUAT.bat" BuildPlugin -plugin="D:\OpenPype\openpype\hosts\unreal\integration\UE_5.0\Ayon\Ayon.uplugin" -Package="D:\BuiltPlugins\5.0"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0_Window.bat b/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0_Window.bat
new file mode 100644
index 0000000000..b96de6d6c9
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.0/BuildPlugin_5-0_Window.bat
@@ -0,0 +1 @@
+cmd /k "BuildPlugin_5-0.bat"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject
index c8dc1c673e..9cf75ebaf2 100644
--- a/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject
+++ b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject
@@ -12,7 +12,7 @@
]
},
{
- "Name": "OpenPype",
+ "Name": "Ayon",
"Enabled": true,
"Type": "Editor"
}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/DefaultOpenPypeSettings.ini b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/DefaultOpenPypeSettings.ini
deleted file mode 100644
index 8a883cf1db..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/DefaultOpenPypeSettings.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[/Script/OpenPype.OpenPypeSettings]
-FolderColor=(R=91,G=197,B=220,A=255)
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Content/Python/init_unreal.py
deleted file mode 100644
index b85f970699..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Content/Python/init_unreal.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import unreal
-
-openpype_detected = True
-try:
- from openpype.pipeline import install_host
- from openpype.hosts.unreal.api import UnrealHost
-
- openpype_host = UnrealHost()
-except ImportError as exc:
- openpype_host = None
- openpype_detected = False
- unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc))
-
-if openpype_detected:
- install_host(openpype_host)
-
-
-@unreal.uclass()
-class OpenPypeIntegration(unreal.OpenPypePythonBridge):
- @unreal.ufunction(override=True)
- def RunInPython_Popup(self):
- unreal.log_warning("OpenPype: showing tools popup")
- if openpype_detected:
- openpype_host.show_tools_popup()
-
- @unreal.ufunction(override=True)
- def RunInPython_Dialog(self):
- unreal.log_warning("OpenPype: showing tools dialog")
- if openpype_detected:
- openpype_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/README.md b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/README.md
deleted file mode 100644
index cf0aa622c2..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# OpenPype Unreal Integration plugin - UE 5.x
-
-This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run.
-
-## How does this work
-
-Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button
-on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are
-declared in C++ but needs to be implemented during Unreal Editor
-startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor
-automatically.
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype128.png
deleted file mode 100644
index abe8a807ef..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype128.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype40.png
deleted file mode 100644
index f983e7a1f2..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype40.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype512.png
deleted file mode 100644
index 97c4d4326b..0000000000
Binary files a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype512.png and /dev/null differ
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainerFactory.cpp
deleted file mode 100644
index b943150bdd..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainerFactory.cpp
+++ /dev/null
@@ -1,20 +0,0 @@
-#include "AssetContainerFactory.h"
-#include "AssetContainer.h"
-
-UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer)
- : UFactory(ObjectInitializer)
-{
- SupportedClass = UAssetContainer::StaticClass();
- bCreateNew = false;
- bEditorImport = true;
-}
-
-UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
-{
- UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags);
- return AssetContainer;
-}
-
-bool UAssetContainerFactory::ShouldShowInNewMenu() const {
- return false;
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp
deleted file mode 100644
index abb1975027..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "Commandlets/Implementations/OPGenerateProjectCommandlet.h"
-
-#include "Editor.h"
-#include "GameProjectUtils.h"
-#include "OPConstants.h"
-#include "Commandlets/OPActionResult.h"
-#include "ProjectDescriptor.h"
-
-int32 UOPGenerateProjectCommandlet::Main(const FString& CommandLineParams)
-{
- //Parses command line parameters & creates structure FProjectInformation
- const FOPGenerateProjectParams ParsedParams = FOPGenerateProjectParams(CommandLineParams);
- ProjectInformation = ParsedParams.GenerateUEProjectInformation();
-
- //Creates .uproject & other UE files
- EVALUATE_OP_ACTION_RESULT(TryCreateProject());
-
- //Loads created .uproject
- EVALUATE_OP_ACTION_RESULT(TryLoadProjectDescriptor());
-
- //Adds needed plugin to .uproject
- AttachPluginsToProjectDescriptor();
-
- //Saves .uproject
- EVALUATE_OP_ACTION_RESULT(TrySave());
-
- //When we are here, there should not be problems in generating Unreal Project for OpenPype
- return 0;
-}
-
-
-FOPGenerateProjectParams::FOPGenerateProjectParams(): FOPGenerateProjectParams("")
-{
-}
-
-FOPGenerateProjectParams::FOPGenerateProjectParams(const FString& CommandLineParams): CommandLineParams(
- CommandLineParams)
-{
- UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches);
-}
-
-FProjectInformation FOPGenerateProjectParams::GenerateUEProjectInformation() const
-{
- FProjectInformation ProjectInformation = FProjectInformation();
- ProjectInformation.ProjectFilename = GetProjectFileName();
-
- ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode");
-
- return ProjectInformation;
-}
-
-FString FOPGenerateProjectParams::TryGetToken(const int32 Index) const
-{
- return Tokens.IsValidIndex(Index) ? Tokens[Index] : "";
-}
-
-FString FOPGenerateProjectParams::GetProjectFileName() const
-{
- return TryGetToken(0);
-}
-
-bool FOPGenerateProjectParams::IsSwitchPresent(const FString& Switch) const
-{
- return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool
- {
- return Item.Equals(Switch);
- }
- );
-}
-
-
-UOPGenerateProjectCommandlet::UOPGenerateProjectCommandlet()
-{
- LogToConsole = true;
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TryCreateProject() const
-{
- FText FailReason;
- FText FailLog;
- TArray OutCreatedFiles;
-
- if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles))
- return FOP_ActionResult(EOP_ActionResult::ProjectNotCreated, FailReason);
- return FOP_ActionResult();
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TryLoadProjectDescriptor()
-{
- FText FailReason;
- const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason);
-
- return FOP_ActionResult(bLoaded ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotLoaded, FailReason);
-}
-
-void UOPGenerateProjectCommandlet::AttachPluginsToProjectDescriptor()
-{
- FPluginReferenceDescriptor OPPluginDescriptor;
- OPPluginDescriptor.bEnabled = true;
- OPPluginDescriptor.Name = OPConstants::OP_PluginName;
- ProjectDescriptor.Plugins.Add(OPPluginDescriptor);
-
- FPluginReferenceDescriptor PythonPluginDescriptor;
- PythonPluginDescriptor.bEnabled = true;
- PythonPluginDescriptor.Name = OPConstants::PythonScript_PluginName;
- ProjectDescriptor.Plugins.Add(PythonPluginDescriptor);
-
- FPluginReferenceDescriptor SequencerScriptingPluginDescriptor;
- SequencerScriptingPluginDescriptor.bEnabled = true;
- SequencerScriptingPluginDescriptor.Name = OPConstants::SequencerScripting_PluginName;
- ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor);
-
- FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor;
- MovieRenderPipelinePluginDescriptor.bEnabled = true;
- MovieRenderPipelinePluginDescriptor.Name = OPConstants::MovieRenderPipeline_PluginName;
- ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor);
-
- FPluginReferenceDescriptor EditorScriptingPluginDescriptor;
- EditorScriptingPluginDescriptor.bEnabled = true;
- EditorScriptingPluginDescriptor.Name = OPConstants::EditorScriptingUtils_PluginName;
- ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor);
-}
-
-FOP_ActionResult UOPGenerateProjectCommandlet::TrySave()
-{
- FText FailReason;
- const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason);
-
- return FOP_ActionResult(bSaved ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotSaved, FailReason);
-}
-
-FOPGenerateProjectParams UOPGenerateProjectCommandlet::ParseParameters(const FString& Params) const
-{
- FOPGenerateProjectParams ParamsResult;
-
- TArray Tokens, Switches;
- ParseCommandLine(*Params, Tokens, Switches);
-
- return ParamsResult;
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp
deleted file mode 100644
index 23ae2dd329..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "Commandlets/OPActionResult.h"
-#include "Logging/OP_Log.h"
-
-EOP_ActionResult::Type& FOP_ActionResult::GetStatus()
-{
- return Status;
-}
-
-FText& FOP_ActionResult::GetReason()
-{
- return Reason;
-}
-
-FOP_ActionResult::FOP_ActionResult():Status(EOP_ActionResult::Type::Ok)
-{
-
-}
-
-FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum):Status(InEnum)
-{
- TryLog();
-}
-
-FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason)
-{
- TryLog();
-};
-
-bool FOP_ActionResult::IsProblem() const
-{
- return Status != EOP_ActionResult::Ok;
-}
-
-void FOP_ActionResult::TryLog() const
-{
- if(IsProblem())
- UE_LOG(LogCommandletOPGenerateProject, Error, TEXT("%s"), *Reason.ToString());
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp
deleted file mode 100644
index 198fb9df0c..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp
+++ /dev/null
@@ -1,3 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "Logging/OP_Log.h"
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp
deleted file mode 100644
index 881814e278..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "OpenPypeCommands.h"
-
-#define LOCTEXT_NAMESPACE "FOpenPypeModule"
-
-void FOpenPypeCommands::RegisterCommands()
-{
- UI_COMMAND(OpenPypeTools, "OpenPype Tools", "Pipeline tools", EUserInterfaceActionType::Button, FInputChord());
- UI_COMMAND(OpenPypeToolsDialog, "OpenPype Tools Dialog", "Pipeline tools dialog", EUserInterfaceActionType::Button, FInputChord());
-}
-
-#undef LOCTEXT_NAMESPACE
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
deleted file mode 100644
index a32ebe32cb..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypePublishInstanceFactory.h"
-#include "OpenPypePublishInstance.h"
-
-UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
- : UFactory(ObjectInitializer)
-{
- SupportedClass = UOpenPypePublishInstance::StaticClass();
- bCreateNew = false;
- bEditorImport = true;
-}
-
-UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
-{
- check(InClass->IsChildOf(UOpenPypePublishInstance::StaticClass()));
- return NewObject(InParent, InClass, InName, Flags);
-}
-
-bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const {
- return false;
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp
deleted file mode 100644
index 6ebfc528f0..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#include "OpenPypePythonBridge.h"
-
-UOpenPypePythonBridge* UOpenPypePythonBridge::Get()
-{
- TArray OpenPypePythonBridgeClasses;
- GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses);
- int32 NumClasses = OpenPypePythonBridgeClasses.Num();
- if (NumClasses > 0)
- {
- return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
- }
- return nullptr;
-};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp
deleted file mode 100644
index 6562a81138..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "OpenPypeSettings.h"
-
-#include "Interfaces/IPluginManager.h"
-#include "UObject/UObjectGlobals.h"
-
-/**
- * Mainly is used for initializing default values if the DefaultOpenPypeSettings.ini file does not exist in the saved config
- */
-UOpenPypeSettings::UOpenPypeSettings(const FObjectInitializer& ObjectInitializer)
-{
-
- const FString ConfigFilePath = OPENPYPE_SETTINGS_FILEPATH;
-
- // This has to be probably in the future set using the UE Reflection system
- FColor Color;
- GConfig->GetColor(TEXT("/Script/OpenPype.OpenPypeSettings"), TEXT("FolderColor"), Color, ConfigFilePath);
-
- FolderColor = Color;
-}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp
deleted file mode 100644
index a4d75e048e..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#include "OpenPypeStyle.h"
-#include "OpenPype.h"
-#include "Framework/Application/SlateApplication.h"
-#include "Styling/SlateStyleRegistry.h"
-#include "Slate/SlateGameResources.h"
-#include "Interfaces/IPluginManager.h"
-#include "Styling/SlateStyleMacros.h"
-
-#define RootToContentDir Style->RootToContentDir
-
-TSharedPtr FOpenPypeStyle::OpenPypeStyleInstance = nullptr;
-
-void FOpenPypeStyle::Initialize()
-{
- if (!OpenPypeStyleInstance.IsValid())
- {
- OpenPypeStyleInstance = Create();
- FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance);
- }
-}
-
-void FOpenPypeStyle::Shutdown()
-{
- FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance);
- ensure(OpenPypeStyleInstance.IsUnique());
- OpenPypeStyleInstance.Reset();
-}
-
-FName FOpenPypeStyle::GetStyleSetName()
-{
- static FName StyleSetName(TEXT("OpenPypeStyle"));
- return StyleSetName;
-}
-
-const FVector2D Icon16x16(16.0f, 16.0f);
-const FVector2D Icon20x20(20.0f, 20.0f);
-const FVector2D Icon40x40(40.0f, 40.0f);
-
-TSharedRef< FSlateStyleSet > FOpenPypeStyle::Create()
-{
- TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("OpenPypeStyle"));
- Style->SetContentRoot(IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Resources"));
-
- Style->Set("OpenPype.OpenPypeTools", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40));
- Style->Set("OpenPype.OpenPypeToolsDialog", new IMAGE_BRUSH(TEXT("openpype40"), Icon40x40));
-
- return Style;
-}
-
-void FOpenPypeStyle::ReloadTextures()
-{
- if (FSlateApplication::IsInitialized())
- {
- FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
- }
-}
-
-const ISlateStyle& FOpenPypeStyle::Get()
-{
- return *OpenPypeStyleInstance;
-}
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h
deleted file mode 100644
index 3740c5285a..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h
+++ /dev/null
@@ -1,4 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-#pragma once
-
-DEFINE_LOG_CATEGORY_STATIC(LogCommandletOPGenerateProject, Log, All);
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h
deleted file mode 100644
index 99b0be26f0..0000000000
--- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright 2023, Ayon, All rights reserved.
-
-#pragma once
-
-#include "CoreMinimal.h"
-#include "Framework/Commands/Commands.h"
-#include "OpenPypeStyle.h"
-
-class FOpenPypeCommands : public TCommands
-{
-public:
-
- FOpenPypeCommands()
- : TCommands(TEXT("OpenPype"), NSLOCTEXT("Contexts", "OpenPype", "OpenPype Tools"), NAME_None, FOpenPypeStyle::GetStyleSetName())
- {
- }
-
- // TCommands<> interface
- virtual void RegisterCommands() override;
-
-public:
- TSharedPtr< FUICommandInfo > OpenPypeTools;
- TSharedPtr< FUICommandInfo > OpenPypeToolsDialog;
-};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/.gitignore b/openpype/hosts/unreal/integration/UE_5.1/Ayon/.gitignore
new file mode 100644
index 0000000000..b32a6f55e5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/.gitignore
@@ -0,0 +1,35 @@
+# Prerequisites
+*.d
+
+# Compiled Object files
+*.slo
+*.lo
+*.o
+*.obj
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Compiled Dynamic libraries
+*.so
+*.dylib
+*.dll
+
+# Fortran module files
+*.mod
+*.smod
+
+# Compiled Static libraries
+*.lai
+*.la
+*.a
+*.lib
+
+# Executables
+*.exe
+*.out
+*.app
+
+/Binaries
+/Intermediate
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Ayon.uplugin b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Ayon.uplugin
new file mode 100644
index 0000000000..70ed8f6b9a
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Ayon.uplugin
@@ -0,0 +1,24 @@
+{
+ "FileVersion": 3,
+ "Version": 1,
+ "VersionName": "1.0",
+ "FriendlyName": "Ayon",
+ "Description": "Ayon Integration",
+ "Category": "Ayon.Integration",
+ "CreatedBy": "Ondrej Samohel",
+ "CreatedByURL": "https://ayon.ynput.io",
+ "DocsURL": "https://ayon.ynput.io/docs/artist_hosts_unreal",
+ "MarketplaceURL": "",
+ "SupportURL": "https://ynput.io/",
+ "CanContainContent": true,
+ "EngineVersion": "5.0",
+ "IsExperimentalVersion": false,
+ "Installed": true,
+ "Modules": [
+ {
+ "Name": "Ayon",
+ "Type": "Editor",
+ "LoadingPhase": "Default"
+ }
+ ]
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/DefaultAyonSettings.ini b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/DefaultAyonSettings.ini
new file mode 100644
index 0000000000..9ad7f55201
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/DefaultAyonSettings.ini
@@ -0,0 +1,2 @@
+[/Script/Ayon.AyonSettings]
+FolderColor=(R=91,G=197,B=220,A=255)
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/FilterPlugin.ini b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/FilterPlugin.ini
new file mode 100644
index 0000000000..ccebca2f32
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Config/FilterPlugin.ini
@@ -0,0 +1,8 @@
+[FilterPlugin]
+; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and
+; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively.
+;
+; Examples:
+; /README.txt
+; /Extras/...
+; /Binaries/ThirdParty/*.dll
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Content/Python/init_unreal.py
new file mode 100644
index 0000000000..c0b1d0ce5d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Content/Python/init_unreal.py
@@ -0,0 +1,30 @@
+import unreal
+
+ayon_detected = True
+try:
+ from openpype.pipeline import install_host
+ from openpype.hosts.unreal.api import UnrealHost
+
+ ayon_host = UnrealHost()
+except ImportError as exc:
+ ayon_host = None
+ ayon_detected = False
+ unreal.log_error(f"Ayon: cannot load Ayon integration [ {exc} ]")
+
+if ayon_detected:
+ install_host(ayon_host)
+
+
+@unreal.uclass()
+class AyonIntegration(unreal.AyonPythonBridge):
+ @unreal.ufunction(override=True)
+ def RunInPython_Popup(self):
+ unreal.log_warning("Ayon: showing tools popup")
+ if ayon_detected:
+ ayon_host.show_tools_popup()
+
+ @unreal.ufunction(override=True)
+ def RunInPython_Dialog(self):
+ unreal.log_warning("Ayon: showing tools dialog")
+ if ayon_detected:
+ ayon_host.show_tools_dialog()
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/README.md b/openpype/hosts/unreal/integration/UE_5.1/Ayon/README.md
new file mode 100644
index 0000000000..417d490548
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/README.md
@@ -0,0 +1,3 @@
+# Ayon Unreal Integration plugin - UE 5.1
+
+This is plugin for Unreal Editor, creating menu for [Ayon](https://github.com/ynput/OpenPype) tools to run.
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon128.png b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon128.png
new file mode 100644
index 0000000000..799d849aa3
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon128.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon40.png b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon40.png
new file mode 100644
index 0000000000..f5bf40ea16
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon40.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon512.png b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon512.png
new file mode 100644
index 0000000000..990d5917e2
Binary files /dev/null and b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Resources/ayon512.png differ
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Ayon.Build.cs b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Ayon.Build.cs
new file mode 100644
index 0000000000..fad0d357dd
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Ayon.Build.cs
@@ -0,0 +1,65 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+using UnrealBuildTool;
+
+public class Ayon : ModuleRules
+{
+ public Ayon(ReadOnlyTargetRules Target) : base(Target)
+ {
+ DefaultBuildSettings = BuildSettingsVersion.V2;
+ bLegacyPublicIncludePaths = false;
+ ShadowVariableWarningLevel = WarningLevel.Error;
+ PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
+ //IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_0;
+
+ PublicIncludePaths.AddRange(
+ new string[] {
+ // ... add public include paths required here ...
+ }
+ );
+
+
+ PrivateIncludePaths.AddRange(
+ new string[] {
+ // ... add other private include paths required here ...
+ }
+ );
+
+
+ PublicDependencyModuleNames.AddRange(
+ new string[]
+ {
+ "Core",
+ "CoreUObject"
+ // ... add other public dependencies that you statically link with here ...
+ }
+ );
+
+ PrivateDependencyModuleNames.AddRange(
+ new string[]
+ {
+ "GameProjectGeneration",
+ "Projects",
+ "InputCore",
+ "EditorFramework",
+ "UnrealEd",
+ "ToolMenus",
+ "LevelEditor",
+ "CoreUObject",
+ "Engine",
+ "Slate",
+ "SlateCore",
+ "AssetTools"
+ // ... add private dependencies that you statically link with here ...
+ }
+ );
+
+
+ DynamicallyLoadedModuleNames.AddRange(
+ new string[]
+ {
+ // ... add any modules that your module loads dynamically here ...
+ }
+ );
+ }
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Ayon.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Ayon.cpp
new file mode 100644
index 0000000000..5a1878ed1a
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Ayon.cpp
@@ -0,0 +1,139 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "Ayon.h"
+
+#include "ISettingsContainer.h"
+#include "ISettingsModule.h"
+#include "ISettingsSection.h"
+#include "AyonStyle.h"
+#include "AyonCommands.h"
+#include "AyonPythonBridge.h"
+#include "AyonSettings.h"
+#include "ToolMenus.h"
+
+
+static const FName AyonTabName("Ayon");
+
+#define LOCTEXT_NAMESPACE "FAyonModule"
+
+// This function is triggered when the plugin is staring up
+void FAyonModule::StartupModule()
+{
+ FAyonStyle::Initialize();
+ FAyonStyle::ReloadTextures();
+ FAyonCommands::Register();
+
+ PluginCommands = MakeShareable(new FUICommandList);
+
+ PluginCommands->MapAction(
+ FAyonCommands::Get().AyonTools,
+ FExecuteAction::CreateRaw(this, &FAyonModule::MenuPopup),
+ FCanExecuteAction());
+ PluginCommands->MapAction(
+ FAyonCommands::Get().AyonToolsDialog,
+ FExecuteAction::CreateRaw(this, &FAyonModule::MenuDialog),
+ FCanExecuteAction());
+
+ UToolMenus::RegisterStartupCallback(
+ FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FAyonModule::RegisterMenus));
+
+ RegisterSettings();
+}
+
+void FAyonModule::ShutdownModule()
+{
+ UToolMenus::UnRegisterStartupCallback(this);
+
+ UToolMenus::UnregisterOwner(this);
+
+ FAyonStyle::Shutdown();
+
+ FAyonCommands::Unregister();
+}
+
+
+void FAyonModule::RegisterSettings()
+{
+ ISettingsModule& SettingsModule = FModuleManager::LoadModuleChecked("Settings");
+
+ // Create the new category
+ // TODO: After the movement of the plugin from the game to editor, it might be necessary to move this!
+ ISettingsContainerPtr SettingsContainer = SettingsModule.GetContainer("Project");
+
+ UAyonSettings* Settings = GetMutableDefault();
+
+ // Register the settings
+ ISettingsSectionPtr SettingsSection = SettingsModule.RegisterSettings("Project", "Ayon", "General",
+ LOCTEXT("RuntimeGeneralSettingsName",
+ "General"),
+ LOCTEXT("RuntimeGeneralSettingsDescription",
+ "Base configuration for Open Pype Module"),
+ Settings
+ );
+
+ // Register the save handler to your settings, you might want to use it to
+ // validate those or just act to settings changes.
+ if (SettingsSection.IsValid())
+ {
+ SettingsSection->OnModified().BindRaw(this, &FAyonModule::HandleSettingsSaved);
+ }
+}
+
+bool FAyonModule::HandleSettingsSaved()
+{
+ UAyonSettings* Settings = GetMutableDefault();
+ bool ResaveSettings = false;
+
+ // You can put any validation code in here and resave the settings in case an invalid
+ // value has been entered
+
+ if (ResaveSettings)
+ {
+ Settings->SaveConfig();
+ }
+
+ return true;
+}
+
+void FAyonModule::RegisterMenus()
+{
+ // Owner will be used for cleanup in call to UToolMenus::UnregisterOwner
+ FToolMenuOwnerScoped OwnerScoped(this);
+
+ {
+ UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools");
+ {
+ // FToolMenuSection& Section = Menu->FindOrAddSection("Ayon");
+ FToolMenuSection& Section = Menu->AddSection(
+ "Ayon",
+ TAttribute(FText::FromString("Ayon")),
+ FToolMenuInsert("Programming", EToolMenuInsertType::Before)
+ );
+ Section.AddMenuEntryWithCommandList(FAyonCommands::Get().AyonTools, PluginCommands);
+ Section.AddMenuEntryWithCommandList(FAyonCommands::Get().AyonToolsDialog, PluginCommands);
+ }
+ UToolMenu* ToolbarMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.PlayToolBar");
+ {
+ FToolMenuSection& Section = ToolbarMenu->FindOrAddSection("PluginTools");
+ {
+ FToolMenuEntry& Entry = Section.AddEntry(
+ FToolMenuEntry::InitToolBarButton(FAyonCommands::Get().AyonTools));
+ Entry.SetCommandList(PluginCommands);
+ }
+ }
+ }
+}
+
+
+void FAyonModule::MenuPopup()
+{
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
+ bridge->RunInPython_Popup();
+}
+
+void FAyonModule::MenuDialog()
+{
+ UAyonPythonBridge* bridge = UAyonPythonBridge::Get();
+ bridge->RunInPython_Dialog();
+}
+
+IMPLEMENT_MODULE(FAyonModule, Ayon)
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
new file mode 100644
index 0000000000..3022757dc8
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainer.cpp
@@ -0,0 +1,113 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#include "AyonAssetContainer.h"
+#include "AssetRegistry/AssetRegistryModule.h"
+#include "Misc/PackageName.h"
+#include "Containers/UnrealString.h"
+
+UAyonAssetContainer::UAyonAssetContainer(const FObjectInitializer& ObjectInitializer)
+: UAssetUserData(ObjectInitializer)
+{
+ FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry");
+ FString path = UAyonAssetContainer::GetPathName();
+ UE_LOG(LogTemp, Warning, TEXT("UAyonAssetContainer %s"), *path);
+ FARFilter Filter;
+ Filter.PackagePaths.Add(FName(*path));
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonAssetContainer::OnAssetAdded);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonAssetContainer::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAyonAssetContainer::OnAssetRenamed);
+}
+
+void UAyonAssetContainer::OnAssetAdded(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.GetObjectPathString();
+ UE_LOG(LogTemp, Log, TEXT("asset name %s"), *assetFName);
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+ assets.Add(assetPath);
+ assetsData.Add(AssetData);
+ UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir);
+ }
+ }
+}
+
+void UAyonAssetContainer::OnAssetRemoved(const FAssetData& AssetData)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.GetObjectPathString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+
+ // take interest only in paths starting with path of current container
+ FString path = UAyonAssetContainer::GetPathName();
+ FString lpp = FPackageName::GetLongPackagePath(*path);
+
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp);
+ assets.Remove(assetPath);
+ assetsData.Remove(AssetData);
+ }
+ }
+}
+
+void UAyonAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str)
+{
+ TArray split;
+
+ // get directory of current container
+ FString selfFullPath = UAyonAssetContainer::GetPathName();
+ FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath);
+
+ // get asset path and class
+ FString assetPath = AssetData.GetFullName();
+ FString assetFName = AssetData.GetObjectPathString();
+
+ // split path
+ assetPath.ParseIntoArray(split, TEXT(" "), true);
+
+ FString assetDir = FPackageName::GetLongPackagePath(*split[1]);
+ if (assetDir.StartsWith(*selfDir))
+ {
+ // exclude self
+ if (assetFName != "AssetContainer")
+ {
+
+ assets.Remove(str);
+ assets.Add(assetPath);
+ assetsData.Remove(AssetData);
+ // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str);
+ }
+ }
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
new file mode 100644
index 0000000000..086fc1036e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonAssetContainerFactory.cpp
@@ -0,0 +1,20 @@
+#include "AyonAssetContainerFactory.h"
+#include "AyonAssetContainer.h"
+
+UAyonAssetContainerFactory::UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonAssetContainer::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ UAyonAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags);
+ return AssetContainer;
+}
+
+bool UAyonAssetContainerFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonCommands.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonCommands.cpp
new file mode 100644
index 0000000000..566ee1dcd1
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonCommands.cpp
@@ -0,0 +1,13 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonCommands.h"
+
+#define LOCTEXT_NAMESPACE "FAyonModule"
+
+void FAyonCommands::RegisterCommands()
+{
+ UI_COMMAND(AyonTools, "Ayon Tools", "Pipeline tools", EUserInterfaceActionType::Button, FInputChord());
+ UI_COMMAND(AyonToolsDialog, "Ayon Tools Dialog", "Pipeline tools dialog", EUserInterfaceActionType::Button, FInputChord());
+}
+
+#undef LOCTEXT_NAMESPACE
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonLib.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonLib.cpp
new file mode 100644
index 0000000000..7cfa0c9c30
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonLib.cpp
@@ -0,0 +1,51 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "AyonLib.h"
+
+#include "AssetViewUtils.h"
+#include "UObject/UnrealType.h"
+
+/**
+ * Sets color on folder icon on given path
+ * @param InPath - path to folder
+ * @param InFolderColor - color of the folder
+ * @warning This color will appear only after Editor restart. Is there a better way?
+ */
+
+bool UAyonLib::SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor, const bool& bForceAdd)
+{
+ if (AssetViewUtils::DoesFolderExist(FolderPath))
+ {
+ const TSharedPtr LinearColor = MakeShared(FolderColor);
+
+ AssetViewUtils::SaveColor(FolderPath, LinearColor, true);
+ UE_LOG(LogAssetData, Display, TEXT("A color {%s} has been set to folder \"%s\""), *LinearColor->ToString(),
+ *FolderPath)
+ return true;
+ }
+
+ UE_LOG(LogAssetData, Display, TEXT("Setting a color {%s} to folder \"%s\" has failed! Directory doesn't exist!"),
+ *FolderColor.ToString(), *FolderPath)
+ return false;
+}
+
+/**
+ * Returns all poperties on given object
+ * @param cls - class
+ * @return TArray of properties
+ */
+TArray UAyonLib::GetAllProperties(UClass* cls)
+{
+ TArray Ret;
+ if (cls != nullptr)
+ {
+ for (TFieldIterator It(cls); It; ++It)
+ {
+ FProperty* Property = *It;
+ if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit))
+ {
+ Ret.Add(Property->GetName());
+ }
+ }
+ }
+ return Ret;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
new file mode 100644
index 0000000000..d1b47a19d4
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstance.cpp
@@ -0,0 +1,204 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.h"
+#include "AssetRegistry/AssetRegistryModule.h"
+#include "AssetToolsModule.h"
+#include "Framework/Notifications/NotificationManager.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
+#include "Widgets/Notifications/SNotificationList.h"
+
+
+//Moves all the invalid pointers to the end to prepare them for the shrinking
+#define REMOVE_INVALID_ENTRIES(VAR) VAR.CompactStable(); \
+ VAR.Shrink();
+
+UAyonPublishInstance::UAyonPublishInstance(const FObjectInitializer& ObjectInitializer)
+ : UPrimaryDataAsset(ObjectInitializer)
+{
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<
+ FAssetRegistryModule>("AssetRegistry");
+
+ const FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked(
+ "PropertyEditor");
+
+ FString Left, Right;
+ GetPathName().Split("/" + GetName(), &Left, &Right);
+
+ FARFilter Filter;
+ Filter.PackagePaths.Emplace(FName(Left));
+
+ TArray FoundAssets;
+ AssetRegistryModule.GetRegistry().GetAssets(Filter, FoundAssets);
+
+ for (const FAssetData& AssetData : FoundAssets)
+ OnAssetCreated(AssetData);
+
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAyonPublishInstance::OnAssetCreated);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAyonPublishInstance::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetUpdated().AddUObject(this, &UAyonPublishInstance::OnAssetUpdated);
+
+#ifdef WITH_EDITOR
+ ColorAyonDirs();
+#endif
+}
+
+void UAyonPublishInstance::OnAssetCreated(const FAssetData& InAssetData)
+{
+ TArray split;
+
+ UObject* Asset = InAssetData.GetAsset();
+
+ if (!IsValid(Asset))
+ {
+ UE_LOG(LogAssetData, Warning, TEXT("Asset \"%s\" is not valid! Skipping the addition."),
+ *InAssetData.GetSoftObjectPath().ToString());
+ return;
+ }
+
+ const bool result = IsUnderSameDir(Asset) && Cast(Asset) == nullptr;
+
+ if (result)
+ {
+ if (AssetDataInternal.Emplace(Asset).IsValidId())
+ {
+ UE_LOG(LogTemp, Log, TEXT("Added an Asset to PublishInstance - Publish Instance: %s, Asset %s"),
+ *this->GetName(), *Asset->GetName());
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetRemoved(const FAssetData& InAssetData)
+{
+ if (Cast(InAssetData.GetAsset()) == nullptr)
+ {
+ if (AssetDataInternal.Contains(nullptr))
+ {
+ AssetDataInternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ }
+ else
+ {
+ AssetDataExternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+ }
+ }
+}
+
+void UAyonPublishInstance::OnAssetUpdated(const FAssetData& InAssetData)
+{
+ REMOVE_INVALID_ENTRIES(AssetDataInternal);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal);
+}
+
+bool UAyonPublishInstance::IsUnderSameDir(const UObject* InAsset) const
+{
+ FString ThisLeft, ThisRight;
+ this->GetPathName().Split(this->GetName(), &ThisLeft, &ThisRight);
+
+ return InAsset->GetPathName().StartsWith(ThisLeft);
+}
+
+#ifdef WITH_EDITOR
+
+void UAyonPublishInstance::ColorAyonDirs()
+{
+ FString PathName = this->GetPathName();
+
+ //Check whether the path contains the defined Ayon folder
+ if (!PathName.Contains(TEXT("Ayon"))) return;
+
+ //Get the base path for open pype
+ FString PathLeft, PathRight;
+ PathName.Split(FString("Ayon"), &PathLeft, &PathRight);
+
+ if (PathLeft.IsEmpty() || PathRight.IsEmpty())
+ {
+ UE_LOG(LogAssetData, Error, TEXT("Failed to retrieve the base Ayon directory!"))
+ return;
+ }
+
+ PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
+
+ //Get the current settings
+ const UAyonSettings* Settings = GetMutableDefault();
+
+ //Color the base folder
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+
+ //Get Sub paths, iterate through them and color them according to the folder color in UAyonSettings
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
+ "AssetRegistry");
+
+ TArray PathList;
+
+ AssetRegistryModule.Get().GetSubPaths(PathName, PathList, true);
+
+ if (PathList.Num() > 0)
+ {
+ for (const FString& Path : PathList)
+ {
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ }
+ }
+}
+
+void UAyonPublishInstance::SendNotification(const FString& Text) const
+{
+ FNotificationInfo Info{FText::FromString(Text)};
+
+ Info.bFireAndForget = true;
+ Info.bUseLargeFont = false;
+ Info.bUseThrobber = false;
+ Info.bUseSuccessFailIcons = false;
+ Info.ExpireDuration = 4.f;
+ Info.FadeOutDuration = 2.f;
+
+ FSlateNotificationManager::Get().AddNotification(Info);
+
+ UE_LOG(LogAssetData, Warning,
+ TEXT(
+ "Removed duplicated asset from the AssetsDataExternal in Container \"%s\", Asset is already included in the AssetDataInternal!"
+ ), *GetName()
+ )
+}
+
+
+void UAyonPublishInstance::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
+{
+ Super::PostEditChangeProperty(PropertyChangedEvent);
+
+ if (PropertyChangedEvent.ChangeType == EPropertyChangeType::ValueSet &&
+ PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(
+ UAyonPublishInstance, AssetDataExternal))
+ {
+ // Check for duplicated assets
+ for (const auto& Asset : AssetDataInternal)
+ {
+ if (AssetDataExternal.Contains(Asset))
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification(
+ "You are not allowed to add assets into AssetDataExternal which are already included in AssetDataInternal!");
+ }
+ }
+
+ // Check if no UAyonPublishInstance type assets are included
+ for (const auto& Asset : AssetDataExternal)
+ {
+ if (Cast(Asset.Get()) != nullptr)
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification("You are not allowed to add publish instances!");
+ }
+ }
+ }
+}
+
+#endif
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
new file mode 100644
index 0000000000..f79c428a6d
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPublishInstanceFactory.cpp
@@ -0,0 +1,23 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#include "AyonPublishInstanceFactory.h"
+#include "AyonPublishInstance.h"
+
+UAyonPublishInstanceFactory::UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer)
+ : UFactory(ObjectInitializer)
+{
+ SupportedClass = UAyonPublishInstance::StaticClass();
+ bCreateNew = false;
+ bEditorImport = true;
+}
+
+UObject* UAyonPublishInstanceFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
+{
+ check(InClass->IsChildOf(UAyonPublishInstance::StaticClass()));
+ return NewObject(InParent, InClass, InName, Flags);
+}
+
+bool UAyonPublishInstanceFactory::ShouldShowInNewMenu() const {
+ return false;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
new file mode 100644
index 0000000000..0ed4b2f704
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonPythonBridge.cpp
@@ -0,0 +1,14 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "AyonPythonBridge.h"
+
+UAyonPythonBridge* UAyonPythonBridge::Get()
+{
+ TArray AyonPythonBridgeClasses;
+ GetDerivedClasses(UAyonPythonBridge::StaticClass(), AyonPythonBridgeClasses);
+ int32 NumClasses = AyonPythonBridgeClasses.Num();
+ if (NumClasses > 0)
+ {
+ return Cast(AyonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject());
+ }
+ return nullptr;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonSettings.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonSettings.cpp
new file mode 100644
index 0000000000..da388fbc8f
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonSettings.cpp
@@ -0,0 +1,21 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonSettings.h"
+
+#include "Interfaces/IPluginManager.h"
+#include "UObject/UObjectGlobals.h"
+
+/**
+ * Mainly is used for initializing default values if the DefaultAyonSettings.ini file does not exist in the saved config
+ */
+UAyonSettings::UAyonSettings(const FObjectInitializer& ObjectInitializer)
+{
+
+ const FString ConfigFilePath = AYON_SETTINGS_FILEPATH;
+
+ // This has to be probably in the future set using the UE Reflection system
+ FColor Color;
+ GConfig->GetColor(TEXT("/Script/Ayon.AyonSettings"), TEXT("FolderColor"), Color, ConfigFilePath);
+
+ FolderColor = Color;
+}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonStyle.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonStyle.cpp
new file mode 100644
index 0000000000..d88df78735
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/AyonStyle.cpp
@@ -0,0 +1,62 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "AyonStyle.h"
+#include "Framework/Application/SlateApplication.h"
+#include "Styling/SlateStyleRegistry.h"
+#include "Slate/SlateGameResources.h"
+#include "Interfaces/IPluginManager.h"
+#include "Styling/SlateStyleMacros.h"
+
+#define RootToContentDir Style->RootToContentDir
+
+TSharedPtr FAyonStyle::AyonStyleInstance = nullptr;
+
+void FAyonStyle::Initialize()
+{
+ if (!AyonStyleInstance.IsValid())
+ {
+ AyonStyleInstance = Create();
+ FSlateStyleRegistry::RegisterSlateStyle(*AyonStyleInstance);
+ }
+}
+
+void FAyonStyle::Shutdown()
+{
+ FSlateStyleRegistry::UnRegisterSlateStyle(*AyonStyleInstance);
+ ensure(AyonStyleInstance.IsUnique());
+ AyonStyleInstance.Reset();
+}
+
+FName FAyonStyle::GetStyleSetName()
+{
+ static FName StyleSetName(TEXT("AyonStyle"));
+ return StyleSetName;
+}
+
+const FVector2D Icon16x16(16.0f, 16.0f);
+const FVector2D Icon20x20(20.0f, 20.0f);
+const FVector2D Icon40x40(40.0f, 40.0f);
+
+TSharedRef< FSlateStyleSet > FAyonStyle::Create()
+{
+ TSharedRef< FSlateStyleSet > Style = MakeShareable(new FSlateStyleSet("AyonStyle"));
+ Style->SetContentRoot(IPluginManager::Get().FindPlugin("Ayon")->GetBaseDir() / TEXT("Resources"));
+
+ Style->Set("Ayon.AyonTools", new IMAGE_BRUSH(TEXT("ayon40"), Icon40x40));
+ Style->Set("Ayon.AyonToolsDialog", new IMAGE_BRUSH(TEXT("ayon40"), Icon40x40));
+
+ return Style;
+}
+
+void FAyonStyle::ReloadTextures()
+{
+ if (FSlateApplication::IsInitialized())
+ {
+ FSlateApplication::Get().GetRenderer()->ReloadTextureResources();
+ }
+}
+
+const ISlateStyle& FAyonStyle::Get()
+{
+ return *AyonStyleInstance;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
new file mode 100644
index 0000000000..2a137e3ed7
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/AyonActionResult.cpp
@@ -0,0 +1,40 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#include "Commandlets/AyonActionResult.h"
+#include "Logging/Ayon_Log.h"
+
+EAyon_ActionResult::Type& FAyon_ActionResult::GetStatus()
+{
+ return Status;
+}
+
+FText& FAyon_ActionResult::GetReason()
+{
+ return Reason;
+}
+
+FAyon_ActionResult::FAyon_ActionResult():Status(EAyon_ActionResult::Type::Ok)
+{
+
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum):Status(InEnum)
+{
+ TryLog();
+}
+
+FAyon_ActionResult::FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason)
+{
+ TryLog();
+};
+
+bool FAyon_ActionResult::IsProblem() const
+{
+ return Status != EAyon_ActionResult::Ok;
+}
+
+void FAyon_ActionResult::TryLog() const
+{
+ if(IsProblem())
+ UE_LOG(LogCommandletAyonGenerateProject, Error, TEXT("%s"), *Reason.ToString());
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
new file mode 100644
index 0000000000..ed876c8128
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/Commandlets/Implementations/AyonGenerateProjectCommandlet.cpp
@@ -0,0 +1,140 @@
+// Copyright 2023, Ayon, All rights reserved.
+#include "Commandlets/Implementations/AyonGenerateProjectCommandlet.h"
+
+#include "GameProjectUtils.h"
+#include "AyonConstants.h"
+#include "Commandlets/AyonActionResult.h"
+#include "ProjectDescriptor.h"
+
+int32 UAyonGenerateProjectCommandlet::Main(const FString& CommandLineParams)
+{
+ //Parses command line parameters & creates structure FProjectInformation
+ const FAyonGenerateProjectParams ParsedParams = FAyonGenerateProjectParams(CommandLineParams);
+ ProjectInformation = ParsedParams.GenerateUEProjectInformation();
+
+ //Creates .uproject & other UE files
+ EVALUATE_Ayon_ACTION_RESULT(TryCreateProject());
+
+ //Loads created .uproject
+ EVALUATE_Ayon_ACTION_RESULT(TryLoadProjectDescriptor());
+
+ //Adds needed plugin to .uproject
+ AttachPluginsToProjectDescriptor();
+
+ //Saves .uproject
+ EVALUATE_Ayon_ACTION_RESULT(TrySave());
+
+ //When we are here, there should not be problems in generating Unreal Project for Ayon
+ return 0;
+}
+
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(): FAyonGenerateProjectParams("")
+{
+}
+
+FAyonGenerateProjectParams::FAyonGenerateProjectParams(const FString& CommandLineParams): CommandLineParams(
+ CommandLineParams)
+{
+ UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches);
+}
+
+FProjectInformation FAyonGenerateProjectParams::GenerateUEProjectInformation() const
+{
+ FProjectInformation ProjectInformation = FProjectInformation();
+ ProjectInformation.ProjectFilename = GetProjectFileName();
+
+ ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode");
+
+ return ProjectInformation;
+}
+
+FString FAyonGenerateProjectParams::TryGetToken(const int32 Index) const
+{
+ return Tokens.IsValidIndex(Index) ? Tokens[Index] : "";
+}
+
+FString FAyonGenerateProjectParams::GetProjectFileName() const
+{
+ return TryGetToken(0);
+}
+
+bool FAyonGenerateProjectParams::IsSwitchPresent(const FString& Switch) const
+{
+ return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool
+ {
+ return Item.Equals(Switch);
+ }
+ );
+}
+
+
+UAyonGenerateProjectCommandlet::UAyonGenerateProjectCommandlet()
+{
+ LogToConsole = true;
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryCreateProject() const
+{
+ FText FailReason;
+ FText FailLog;
+ TArray OutCreatedFiles;
+
+ if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles))
+ return FAyon_ActionResult(EAyon_ActionResult::ProjectNotCreated, FailReason);
+ return FAyon_ActionResult();
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TryLoadProjectDescriptor()
+{
+ FText FailReason;
+ const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bLoaded ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotLoaded, FailReason);
+}
+
+void UAyonGenerateProjectCommandlet::AttachPluginsToProjectDescriptor()
+{
+ FPluginReferenceDescriptor AyonPluginDescriptor;
+ AyonPluginDescriptor.bEnabled = true;
+ AyonPluginDescriptor.Name = AyonConstants::Ayon_PluginName;
+ ProjectDescriptor.Plugins.Add(AyonPluginDescriptor);
+
+ FPluginReferenceDescriptor PythonPluginDescriptor;
+ PythonPluginDescriptor.bEnabled = true;
+ PythonPluginDescriptor.Name = AyonConstants::PythonScript_PluginName;
+ ProjectDescriptor.Plugins.Add(PythonPluginDescriptor);
+
+ FPluginReferenceDescriptor SequencerScriptingPluginDescriptor;
+ SequencerScriptingPluginDescriptor.bEnabled = true;
+ SequencerScriptingPluginDescriptor.Name = AyonConstants::SequencerScripting_PluginName;
+ ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor);
+
+ FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor;
+ MovieRenderPipelinePluginDescriptor.bEnabled = true;
+ MovieRenderPipelinePluginDescriptor.Name = AyonConstants::MovieRenderPipeline_PluginName;
+ ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor);
+
+ FPluginReferenceDescriptor EditorScriptingPluginDescriptor;
+ EditorScriptingPluginDescriptor.bEnabled = true;
+ EditorScriptingPluginDescriptor.Name = AyonConstants::EditorScriptingUtils_PluginName;
+ ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor);
+}
+
+FAyon_ActionResult UAyonGenerateProjectCommandlet::TrySave()
+{
+ FText FailReason;
+ const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason);
+
+ return FAyon_ActionResult(bSaved ? EAyon_ActionResult::Ok : EAyon_ActionResult::ProjectNotSaved, FailReason);
+}
+
+FAyonGenerateProjectParams UAyonGenerateProjectCommandlet::ParseParameters(const FString& Params) const
+{
+ FAyonGenerateProjectParams ParamsResult;
+
+ TArray Tokens, Switches;
+ ParseCommandLine(*Params, Tokens, Switches);
+
+ return ParamsResult;
+}
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
new file mode 100644
index 0000000000..02a8ac800a
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Private/OpenPypePublishInstance.cpp
@@ -0,0 +1,204 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "OpenPypePublishInstance.h"
+#include "AssetRegistry/AssetRegistryModule.h"
+#include "AssetToolsModule.h"
+#include "Framework/Notifications/NotificationManager.h"
+#include "AyonLib.h"
+#include "AyonSettings.h"
+#include "Widgets/Notifications/SNotificationList.h"
+
+
+//Moves all the invalid pointers to the end to prepare them for the shrinking
+#define REMOVE_INVALID_ENTRIES(VAR) VAR.CompactStable(); \
+ VAR.Shrink();
+
+UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer)
+ : UPrimaryDataAsset(ObjectInitializer)
+{
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<
+ FAssetRegistryModule>("AssetRegistry");
+
+ const FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked(
+ "PropertyEditor");
+
+ FString Left, Right;
+ GetPathName().Split("/" + GetName(), &Left, &Right);
+
+ FARFilter Filter;
+ Filter.PackagePaths.Emplace(FName(Left));
+
+ TArray FoundAssets;
+ AssetRegistryModule.GetRegistry().GetAssets(Filter, FoundAssets);
+
+ for (const FAssetData& AssetData : FoundAssets)
+ OnAssetCreated(AssetData);
+
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+
+ AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetCreated);
+ AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved);
+ AssetRegistryModule.Get().OnAssetUpdated().AddUObject(this, &UOpenPypePublishInstance::OnAssetUpdated);
+
+#ifdef WITH_EDITOR
+ ColorOpenPypeDirs();
+#endif
+}
+
+void UOpenPypePublishInstance::OnAssetCreated(const FAssetData& InAssetData)
+{
+ TArray split;
+
+ UObject* Asset = InAssetData.GetAsset();
+
+ if (!IsValid(Asset))
+ {
+ UE_LOG(LogAssetData, Warning, TEXT("Asset \"%s\" is not valid! Skipping the addition."),
+ *InAssetData.GetSoftObjectPath().ToString());
+ return;
+ }
+
+ const bool result = IsUnderSameDir(Asset) && Cast(Asset) == nullptr;
+
+ if (result)
+ {
+ if (AssetDataInternal.Emplace(Asset).IsValidId())
+ {
+ UE_LOG(LogTemp, Log, TEXT("Added an Asset to PublishInstance - Publish Instance: %s, Asset %s"),
+ *this->GetName(), *Asset->GetName());
+ }
+ }
+}
+
+void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& InAssetData)
+{
+ if (Cast(InAssetData.GetAsset()) == nullptr)
+ {
+ if (AssetDataInternal.Contains(nullptr))
+ {
+ AssetDataInternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataInternal)
+ }
+ else
+ {
+ AssetDataExternal.Remove(nullptr);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal)
+ }
+ }
+}
+
+void UOpenPypePublishInstance::OnAssetUpdated(const FAssetData& InAssetData)
+{
+ REMOVE_INVALID_ENTRIES(AssetDataInternal);
+ REMOVE_INVALID_ENTRIES(AssetDataExternal);
+}
+
+bool UOpenPypePublishInstance::IsUnderSameDir(const UObject* InAsset) const
+{
+ FString ThisLeft, ThisRight;
+ this->GetPathName().Split(this->GetName(), &ThisLeft, &ThisRight);
+
+ return InAsset->GetPathName().StartsWith(ThisLeft);
+}
+
+#ifdef WITH_EDITOR
+
+void UOpenPypePublishInstance::ColorOpenPypeDirs()
+{
+ FString PathName = this->GetPathName();
+
+ //Check whether the path contains the defined OpenPype folder
+ if (!PathName.Contains(TEXT("OpenPype"))) return;
+
+ //Get the base path for open pype
+ FString PathLeft, PathRight;
+ PathName.Split(FString("OpenPype"), &PathLeft, &PathRight);
+
+ if (PathLeft.IsEmpty() || PathRight.IsEmpty())
+ {
+ UE_LOG(LogAssetData, Error, TEXT("Failed to retrieve the base OpenPype directory!"))
+ return;
+ }
+
+ PathName.RemoveFromEnd(PathRight, ESearchCase::CaseSensitive);
+
+ //Get the current settings
+ const UAyonSettings* Settings = GetMutableDefault();
+
+ //Color the base folder
+ UAyonLib::SetFolderColor(PathName, Settings->GetFolderFColor(), false);
+
+ //Get Sub paths, iterate through them and color them according to the folder color in UOpenPypeSettings
+ const FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(
+ "AssetRegistry");
+
+ TArray PathList;
+
+ AssetRegistryModule.Get().GetSubPaths(PathName, PathList, true);
+
+ if (PathList.Num() > 0)
+ {
+ for (const FString& Path : PathList)
+ {
+ UAyonLib::SetFolderColor(Path, Settings->GetFolderFColor(), false);
+ }
+ }
+}
+
+void UOpenPypePublishInstance::SendNotification(const FString& Text) const
+{
+ FNotificationInfo Info{FText::FromString(Text)};
+
+ Info.bFireAndForget = true;
+ Info.bUseLargeFont = false;
+ Info.bUseThrobber = false;
+ Info.bUseSuccessFailIcons = false;
+ Info.ExpireDuration = 4.f;
+ Info.FadeOutDuration = 2.f;
+
+ FSlateNotificationManager::Get().AddNotification(Info);
+
+ UE_LOG(LogAssetData, Warning,
+ TEXT(
+ "Removed duplicated asset from the AssetsDataExternal in Container \"%s\", Asset is already included in the AssetDataInternal!"
+ ), *GetName()
+ )
+}
+
+
+void UOpenPypePublishInstance::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
+{
+ Super::PostEditChangeProperty(PropertyChangedEvent);
+
+ if (PropertyChangedEvent.ChangeType == EPropertyChangeType::ValueSet &&
+ PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(
+ UOpenPypePublishInstance, AssetDataExternal))
+ {
+ // Check for duplicated assets
+ for (const auto& Asset : AssetDataInternal)
+ {
+ if (AssetDataExternal.Contains(Asset))
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification(
+ "You are not allowed to add assets into AssetDataExternal which are already included in AssetDataInternal!");
+ }
+ }
+
+ // Check if no UOpenPypePublishInstance type assets are included
+ for (const auto& Asset : AssetDataExternal)
+ {
+ if (Cast(Asset.Get()) != nullptr)
+ {
+ AssetDataExternal.Remove(Asset);
+ return SendNotification("You are not allowed to add publish instances!");
+ }
+ }
+ }
+}
+
+#endif
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Ayon.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Ayon.h
new file mode 100644
index 0000000000..bb25430411
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Ayon.h
@@ -0,0 +1,24 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#pragma once
+
+#include "CoreMinimal.h"
+
+
+class FAyonModule : public IModuleInterface
+{
+public:
+ virtual void StartupModule() override;
+ virtual void ShutdownModule() override;
+
+private:
+ void RegisterMenus();
+ void RegisterSettings();
+ bool HandleSettingsSaved();
+
+ void MenuPopup();
+ void MenuDialog();
+
+private:
+ TSharedPtr PluginCommands;
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainer.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainer.h
new file mode 100644
index 0000000000..d40642b149
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainer.h
@@ -0,0 +1,34 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/NoExportTypes.h"
+#include "Engine/AssetUserData.h"
+#include "AssetRegistry/AssetData.h"
+#include "AyonAssetContainer.generated.h"
+
+UCLASS(Blueprintable)
+class AYON_API UAyonAssetContainer : public UAssetUserData
+{
+ GENERATED_BODY()
+
+public:
+
+ UAyonAssetContainer(const FObjectInitializer& ObjectInitalizer);
+ // ~UAyonAssetContainer();
+
+ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Assets")
+ TArray assets;
+
+ // There seems to be no reflection option to expose array of FAssetData
+ /*
+ UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data"))
+ TArray assetsData;
+ */
+private:
+ TArray assetsData;
+ void OnAssetAdded(const FAssetData& AssetData);
+ void OnAssetRemoved(const FAssetData& AssetData);
+ void OnAssetRenamed(const FAssetData& AssetData, const FString& str);
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
new file mode 100644
index 0000000000..da424cde2e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonAssetContainerFactory.h
@@ -0,0 +1,18 @@
+// Fill out your copyright notice in the Description page of Project Settings.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Factories/Factory.h"
+#include "AyonAssetContainerFactory.generated.h"
+
+UCLASS()
+class AYON_API UAyonAssetContainerFactory : public UFactory
+{
+ GENERATED_BODY()
+
+public:
+ UAyonAssetContainerFactory(const FObjectInitializer& ObjectInitializer);
+ virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
+ virtual bool ShouldShowInNewMenu() const override;
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonCommands.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonCommands.h
new file mode 100644
index 0000000000..9c40dc8241
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonCommands.h
@@ -0,0 +1,24 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Framework/Commands/Commands.h"
+#include "AyonStyle.h"
+
+class FAyonCommands : public TCommands
+{
+public:
+
+ FAyonCommands()
+ : TCommands(TEXT("Ayon"), NSLOCTEXT("Contexts", "Ayon", "Ayon Tools"), NAME_None, FAyonStyle::GetStyleSetName())
+ {
+ }
+
+ // TCommands<> interface
+ virtual void RegisterCommands() override;
+
+public:
+ TSharedPtr< FUICommandInfo > AyonTools;
+ TSharedPtr< FUICommandInfo > AyonToolsDialog;
+};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonConstants.h
similarity index 83%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h
rename to openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonConstants.h
index f4587f7a50..5fe7c14360 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonConstants.h
@@ -1,9 +1,9 @@
// Copyright 2023, Ayon, All rights reserved.
#pragma once
-namespace OPConstants
+namespace AyonConstants
{
- const FString OP_PluginName = "OpenPype";
+ const FString Ayon_PluginName = "Ayon";
const FString PythonScript_PluginName = "PythonScriptPlugin";
const FString SequencerScripting_PluginName = "SequencerScripting";
const FString MovieRenderPipeline_PluginName = "MovieRenderPipeline";
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonLib.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonLib.h
new file mode 100644
index 0000000000..da83b448fb
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonLib.h
@@ -0,0 +1,19 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+
+#include "AyonLib.generated.h"
+
+
+UCLASS(Blueprintable)
+class AYON_API UAyonLib : public UBlueprintFunctionLibrary
+{
+
+ GENERATED_BODY()
+
+public:
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static bool SetFolderColor(const FString& FolderPath, const FLinearColor& FolderColor,const bool& bForceAdd);
+
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static TArray GetAllProperties(UClass* cls);
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstance.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstance.h
new file mode 100644
index 0000000000..c89388036f
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstance.h
@@ -0,0 +1,104 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "AyonPublishInstance.generated.h"
+
+
+UCLASS(Blueprintable)
+class AYON_API UAyonPublishInstance : public UPrimaryDataAsset
+{
+ GENERATED_UCLASS_BODY()
+
+public:
+ /**
+ /**
+ * Retrieves all the assets which are monitored by the Publish Instance (Monitors assets in the directory which is
+ * placed in)
+ *
+ * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetInternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataInternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Retrieves all the assets which have been added manually by the Publish Instance
+ *
+ * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetExternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataExternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Function for returning all the assets in the container combined.
+ *
+ * @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
+ * returning raw pointers. Seems like an issue in UE5
+ *
+ * @attention If the bAddExternalAssets variable is false, external assets won't be included!
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetAllAssets() const
+ {
+ const TSet>& IteratedSet = bAddExternalAssets
+ ? AssetDataInternal.Union(AssetDataExternal)
+ : AssetDataInternal;
+
+ //Create a new TSet only with raw pointers.
+ TSet ResultSet;
+
+ for (auto& Asset : IteratedSet)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+private:
+ UPROPERTY(VisibleAnywhere, Category="Assets")
+ TSet> AssetDataInternal;
+
+ /**
+ * This property allows exposing the array to include other assets from any other directory than what it's currently
+ * monitoring. NOTE: that these assets have to be added manually! They are not automatically registered or added!
+ */
+ UPROPERTY(EditAnywhere, Category = "Assets")
+ bool bAddExternalAssets = false;
+
+ UPROPERTY(EditAnywhere, meta=(EditCondition="bAddExternalAssets"), Category="Assets")
+ TSet> AssetDataExternal;
+
+
+ void OnAssetCreated(const FAssetData& InAssetData);
+ void OnAssetRemoved(const FAssetData& InAssetData);
+ void OnAssetUpdated(const FAssetData& InAssetData);
+
+ bool IsUnderSameDir(const UObject* InAsset) const;
+
+#ifdef WITH_EDITOR
+
+ void ColorAyonDirs();
+
+ void SendNotification(const FString& Text) const;
+ virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
+
+#endif
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
new file mode 100644
index 0000000000..3cef8e76b2
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPublishInstanceFactory.h
@@ -0,0 +1,22 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Factories/Factory.h"
+#include "AyonPublishInstanceFactory.generated.h"
+
+/**
+ *
+ */
+UCLASS()
+class AYON_API UAyonPublishInstanceFactory : public UFactory
+{
+ GENERATED_BODY()
+
+public:
+ UAyonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer);
+ virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
+ virtual bool ShouldShowInNewMenu() const override;
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPythonBridge.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPythonBridge.h
new file mode 100644
index 0000000000..3c429fd7d3
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonPythonBridge.h
@@ -0,0 +1,20 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+#include "AyonPythonBridge.generated.h"
+
+UCLASS(Blueprintable)
+class UAyonPythonBridge : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ UFUNCTION(BlueprintCallable, Category = Python)
+ static UAyonPythonBridge* Get();
+
+ UFUNCTION(BlueprintImplementableEvent, Category = Python)
+ void RunInPython_Popup() const;
+
+ UFUNCTION(BlueprintImplementableEvent, Category = Python)
+ void RunInPython_Dialog() const;
+
+};
diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonSettings.h
similarity index 57%
rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h
rename to openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonSettings.h
index 88defaa773..4f12d1a5f2 100644
--- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonSettings.h
@@ -1,14 +1,15 @@
-// Copyright 2023, Ayon, All rights reserved.
+// Copyright 2023, Ayon, All rights reserved.
#pragma once
#include "CoreMinimal.h"
-#include "OpenPypeSettings.generated.h"
+#include "UObject/Object.h"
+#include "AyonSettings.generated.h"
-#define OPENPYPE_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Config") / TEXT("DefaultOpenPypeSettings.ini")
+#define AYON_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("Ayon")->GetBaseDir() / TEXT("Config") / TEXT("DefaultAyonSettings.ini")
-UCLASS(Config=OpenPypeSettings, DefaultConfig)
-class OPENPYPE_API UOpenPypeSettings : public UObject
+UCLASS(Config=AyonSettings, DefaultConfig)
+class AYON_API UAyonSettings : public UObject
{
GENERATED_UCLASS_BODY()
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonStyle.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonStyle.h
new file mode 100644
index 0000000000..58f6af656e
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/AyonStyle.h
@@ -0,0 +1,19 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+#include "CoreMinimal.h"
+#include "Styling/SlateStyle.h"
+
+class FAyonStyle
+{
+public:
+ static void Initialize();
+ static void Shutdown();
+ static void ReloadTextures();
+ static const ISlateStyle& Get();
+ static FName GetStyleSetName();
+
+
+private:
+ static TSharedRef< class FSlateStyleSet > Create();
+ static TSharedPtr< class FSlateStyleSet > AyonStyleInstance;
+};
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
new file mode 100644
index 0000000000..bb995ec452
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/AyonActionResult.h
@@ -0,0 +1,83 @@
+// Copyright 2023, Ayon, All rights reserved.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "AyonActionResult.generated.h"
+
+/**
+ * @brief This macro returns error code when is problem or does nothing when there is no problem.
+ * @param ActionResult FAyon_ActionResult structure
+ */
+#define EVALUATE_Ayon_ACTION_RESULT(ActionResult) \
+ if(ActionResult.IsProblem()) \
+ return ActionResult.GetStatus();
+
+/**
+* @brief This enum values are humanly readable mapping of error codes.
+* Here should be all error codes to be possible find what went wrong.
+* TODO: In the future should exists an web document where is mapped error code & what problem occured & how to repair it...
+*/
+UENUM()
+namespace EAyon_ActionResult
+{
+ enum Type
+ {
+ Ok,
+ ProjectNotCreated,
+ ProjectNotLoaded,
+ ProjectNotSaved,
+ //....Here insert another values
+
+ //Do not remove!
+ //Usable for looping through enum values
+ __Last UMETA(Hidden)
+ };
+}
+
+
+/**
+ * @brief This struct holds action result enum and optionally reason of fail
+ */
+USTRUCT()
+struct FAyon_ActionResult
+{
+ GENERATED_BODY()
+
+public:
+ /** @brief Default constructor usable when there is no problem */
+ FAyon_ActionResult();
+
+ /**
+ * @brief This constructor initializes variables & attempts to log when is error
+ * @param InEnum Status
+ */
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum);
+
+ /**
+ * @brief This constructor initializes variables & attempts to log when is error
+ * @param InEnum Status
+ * @param InReason Reason of potential fail
+ */
+ FAyon_ActionResult(const EAyon_ActionResult::Type& InEnum, const FText& InReason);
+
+private:
+ /** @brief Action status */
+ EAyon_ActionResult::Type Status;
+
+ /** @brief Optional reason of fail */
+ FText Reason;
+
+public:
+ /**
+ * @brief Checks if there is problematic state
+ * @return true when status is not equal to EAyon_ActionResult::Ok
+ */
+ bool IsProblem() const;
+ EAyon_ActionResult::Type& GetStatus();
+ FText& GetReason();
+
+private:
+ void TryLog() const;
+};
+
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
new file mode 100644
index 0000000000..da8e9af661
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Commandlets/Implementations/AyonGenerateProjectCommandlet.h
@@ -0,0 +1,61 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+
+
+#include "GameProjectUtils.h"
+#include "Commandlets/AyonActionResult.h"
+#include "ProjectDescriptor.h"
+#include "Commandlets/Commandlet.h"
+#include "AyonGenerateProjectCommandlet.generated.h"
+
+struct FProjectDescriptor;
+struct FProjectInformation;
+
+/**
+* @brief Structure which parses command line parameters and generates FProjectInformation
+*/
+USTRUCT()
+struct FAyonGenerateProjectParams
+{
+ GENERATED_BODY()
+
+private:
+ FString CommandLineParams;
+ TArray Tokens;
+ TArray Switches;
+
+public:
+ FAyonGenerateProjectParams();
+ FAyonGenerateProjectParams(const FString& CommandLineParams);
+
+ FProjectInformation GenerateUEProjectInformation() const;
+
+private:
+ FString TryGetToken(const int32 Index) const;
+ FString GetProjectFileName() const;
+
+ bool IsSwitchPresent(const FString& Switch) const;
+};
+
+UCLASS()
+class AYON_API UAyonGenerateProjectCommandlet : public UCommandlet
+{
+ GENERATED_BODY()
+
+private:
+ FProjectInformation ProjectInformation;
+ FProjectDescriptor ProjectDescriptor;
+
+public:
+ UAyonGenerateProjectCommandlet();
+
+ virtual int32 Main(const FString& CommandLineParams) override;
+
+private:
+ FAyonGenerateProjectParams ParseParameters(const FString& Params) const;
+ FAyon_ActionResult TryCreateProject() const;
+ FAyon_ActionResult TryLoadProjectDescriptor();
+ void AttachPluginsToProjectDescriptor();
+ FAyon_ActionResult TrySave();
+};
+
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
new file mode 100644
index 0000000000..25b33a63e8
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/Logging/Ayon_Log.h
@@ -0,0 +1,4 @@
+// Copyright 2023, Ayon, All rights reserved.
+#pragma once
+
+DEFINE_LOG_CATEGORY_STATIC(LogCommandletAyonGenerateProject, Log, All);
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
new file mode 100644
index 0000000000..9c0c4a69e5
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/Ayon/Source/Ayon/Public/OpenPypePublishInstance.h
@@ -0,0 +1,104 @@
+// Copyright 2023, Ayon, All rights reserved.
+// Deprecation warning: this is left here just for backwards compatibility
+// and will be removed in next versions of Ayon.
+#pragma once
+
+#include "OpenPypePublishInstance.generated.h"
+
+
+UCLASS(Blueprintable)
+class AYON_API UOpenPypePublishInstance : public UPrimaryDataAsset
+{
+ GENERATED_UCLASS_BODY()
+
+public:
+ /**
+ /**
+ * Retrieves all the assets which are monitored by the Publish Instance (Monitors assets in the directory which is
+ * placed in)
+ *
+ * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetInternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataInternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Retrieves all the assets which have been added manually by the Publish Instance
+ *
+ * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetExternalAssets() const
+ {
+ //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed.
+ TSet ResultSet;
+
+ for (const auto& Asset : AssetDataExternal)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+ /**
+ * Function for returning all the assets in the container combined.
+ *
+ * @return Returns all the internal and externally added assets into one set (TSet of UObjects). Careful! They are
+ * returning raw pointers. Seems like an issue in UE5
+ *
+ * @attention If the bAddExternalAssets variable is false, external assets won't be included!
+ */
+ UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python")
+ TSet GetAllAssets() const
+ {
+ const TSet>& IteratedSet = bAddExternalAssets
+ ? AssetDataInternal.Union(AssetDataExternal)
+ : AssetDataInternal;
+
+ //Create a new TSet only with raw pointers.
+ TSet ResultSet;
+
+ for (auto& Asset : IteratedSet)
+ ResultSet.Add(Asset.LoadSynchronous());
+
+ return ResultSet;
+ }
+
+private:
+ UPROPERTY(VisibleAnywhere, Category="Assets")
+ TSet> AssetDataInternal;
+
+ /**
+ * This property allows exposing the array to include other assets from any other directory than what it's currently
+ * monitoring. NOTE: that these assets have to be added manually! They are not automatically registered or added!
+ */
+ UPROPERTY(EditAnywhere, Category = "Assets")
+ bool bAddExternalAssets = false;
+
+ UPROPERTY(EditAnywhere, meta=(EditCondition="bAddExternalAssets"), Category="Assets")
+ TSet> AssetDataExternal;
+
+
+ void OnAssetCreated(const FAssetData& InAssetData);
+ void OnAssetRemoved(const FAssetData& InAssetData);
+ void OnAssetUpdated(const FAssetData& InAssetData);
+
+ bool IsUnderSameDir(const UObject* InAsset) const;
+
+#ifdef WITH_EDITOR
+
+ void ColorOpenPypeDirs();
+
+ void SendNotification(const FString& Text) const;
+ virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
+
+#endif
+};
diff --git a/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1.bat b/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1.bat
new file mode 100644
index 0000000000..3cc82d54af
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1.bat
@@ -0,0 +1 @@
+"D:\UE_5.1\Engine\Build\BatchFiles\RunUAT.bat" BuildPlugin -plugin="D:\OpenPype\openpype\hosts\unreal\integration\UE_5.1\Ayon\Ayon.uplugin" -Package="D:\BuiltPlugins\5.1"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1_Window.bat b/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1_Window.bat
new file mode 100644
index 0000000000..e10f2c7add
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/BuildPlugin_5-1_Window.bat
@@ -0,0 +1 @@
+cmd /k "BuildPlugin_5-1.bat"
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/.gitignore b/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/.gitignore
new file mode 100644
index 0000000000..80814ef0a6
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/.gitignore
@@ -0,0 +1,41 @@
+# Prerequisites
+*.d
+
+# Compiled Object files
+*.slo
+*.lo
+*.o
+*.obj
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Compiled Dynamic libraries
+*.so
+*.dylib
+*.dll
+
+# Fortran module files
+*.mod
+*.smod
+
+# Compiled Static libraries
+*.lai
+*.la
+*.a
+*.lib
+
+# Executables
+*.exe
+*.out
+*.app
+
+/Saved
+/DerivedDataCache
+/Intermediate
+/Binaries
+/Content
+/Config
+/.idea
+/.vs
\ No newline at end of file
diff --git a/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/CommandletProject.uproject b/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/CommandletProject.uproject
new file mode 100644
index 0000000000..fe83346624
--- /dev/null
+++ b/openpype/hosts/unreal/integration/UE_5.1/CommandletProject/CommandletProject.uproject
@@ -0,0 +1,20 @@
+{
+ "FileVersion": 3,
+ "EngineAssociation": "5.1",
+ "Category": "",
+ "Description": "",
+ "Plugins": [
+ {
+ "Name": "ModelingToolsEditorMode",
+ "Enabled": true,
+ "TargetAllowList": [
+ "Editor"
+ ]
+ },
+ {
+ "Name": "Ayon",
+ "Enabled": true,
+ "Type": "Editor"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py
index 05fc87b318..821b4daecc 100644
--- a/openpype/hosts/unreal/lib.py
+++ b/openpype/hosts/unreal/lib.py
@@ -189,7 +189,7 @@ def create_unreal_project(project_name: str,
As there is no way I know to create a project via command line, this is
easiest option. Unreal project file is basically a JSON file. If we find
- the `OPENPYPE_UNREAL_PLUGIN` environment variable we assume this is the
+ the `AYON_UNREAL_PLUGIN` environment variable we assume this is the
location of the Integration Plugin and we copy its content to the project
folder and enable this plugin.
@@ -203,8 +203,7 @@ def create_unreal_project(project_name: str,
sources. This will trigger automatically if `Binaries`
directory is not found in plugin folders as this indicates
this is only source distribution of the plugin. Dev mode
- is also set by preset file `unreal/project_setup.json` in
- **OPENPYPE_CONFIG**.
+ is also set in Settings.
env (dict, optional): Environment to use. If not set, `os.environ`.
Throws:
@@ -237,7 +236,7 @@ def create_unreal_project(project_name: str,
print("--- Generating a new project ...")
commandlet_cmd = [f'{ue_editor_exe.as_posix()}',
f'{cmdlet_project.as_posix()}',
- f'-run=OPGenerateProject',
+ f'-run=AyonGenerateProject',
f'{project_file.resolve().as_posix()}']
if dev_mode or preset["dev_mode"]:
@@ -318,21 +317,73 @@ def get_path_to_uat(engine_path: Path) -> Path:
if platform.system().lower() == "windows":
return engine_path / "Engine/Build/BatchFiles/RunUAT.bat"
- if platform.system().lower() == "linux" \
- or platform.system().lower() == "darwin":
+ if platform.system().lower() in ["linux", "darwin"]:
return engine_path / "Engine/Build/BatchFiles/RunUAT.sh"
+def get_compatible_integration(
+ ue_version: str, integration_root: Path) -> List[Path]:
+ """Get path to compatible version of integration plugin.
+
+ This will try to get the closest compatible versions to the one
+ specified in sorted list.
+
+ Args:
+ ue_version (str): version of the current Unreal Engine.
+ integration_root (Path): path to built-in integration plugins.
+
+ Returns:
+ list of Path: Sorted list of paths closest to the specified
+ version.
+
+ """
+ major, minor = ue_version.split(".")
+ integration_paths = [p for p in integration_root.iterdir()
+ if p.is_dir()]
+
+ compatible_versions = []
+ for i in integration_paths:
+ # parse version from path
+ try:
+ i_major, i_minor = re.search(
+ r"(?P\d+).(?P\d+)$", i.name).groups()
+ except AttributeError:
+ # in case there is no match, just skip to next
+ continue
+
+ # consider versions with different major so different that they
+ # are incompatible
+ if int(major) != int(i_major):
+ continue
+
+ compatible_versions.append(i)
+
+ sorted(set(compatible_versions))
+ return compatible_versions
+
+
def get_path_to_cmdlet_project(ue_version: str) -> Path:
- cmd_project = Path(os.path.dirname(os.path.abspath(openpype.__file__)))
+ cmd_project = Path(
+ os.path.abspath(os.getenv("OPENPYPE_ROOT")))
- # For now, only tested on Windows (For Linux and Mac it has to be implemented)
- if ue_version.split(".")[0] == "4":
- cmd_project /= "hosts/unreal/integration/UE_4.7"
- elif ue_version.split(".")[0] == "5":
- cmd_project /= "hosts/unreal/integration/UE_5.0"
+ # For now, only tested on Windows (For Linux and Mac
+ # it has to be implemented)
+ cmd_project /= f"openpype/hosts/unreal/integration/UE_{ue_version}"
- return cmd_project / "CommandletProject/CommandletProject.uproject"
+ # if the integration doesn't exist for current engine version
+ # try to find the closest to it.
+ if cmd_project.exists():
+ return cmd_project / "CommandletProject/CommandletProject.uproject"
+
+ if compatible_versions := get_compatible_integration(
+ ue_version, cmd_project.parent
+ ):
+ return compatible_versions[-1] / "CommandletProject/CommandletProject.uproject" # noqa: E501
+ else:
+ raise RuntimeError(
+ ("There are no compatible versions of Unreal "
+ "integration plugin compatible with running version "
+ f"of Unreal Engine {ue_version}"))
def get_path_to_ubt(engine_path: Path, ue_version: str) -> Path:
@@ -375,13 +426,13 @@ def get_build_id(engine_path: Path, ue_version: str) -> str:
def check_plugin_existence(engine_path: Path, env: dict = None) -> bool:
env = env or os.environ
- integration_plugin_path: Path = Path(env.get("OPENPYPE_UNREAL_PLUGIN", ""))
+ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", ""))
if not os.path.isdir(integration_plugin_path):
raise RuntimeError("Path to the integration plugin is null!")
# Create a path to the plugin in the engine
- op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/OpenPype"
+ op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon"
if not op_plugin_path.is_dir():
return False
@@ -396,13 +447,13 @@ def check_plugin_existence(engine_path: Path, env: dict = None) -> bool:
def try_installing_plugin(engine_path: Path, env: dict = None) -> None:
env = env or os.environ
- integration_plugin_path: Path = Path(env.get("OPENPYPE_UNREAL_PLUGIN", ""))
+ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", ""))
if not os.path.isdir(integration_plugin_path):
raise RuntimeError("Path to the integration plugin is null!")
# Create a path to the plugin in the engine
- op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/OpenPype"
+ op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon"
if not op_plugin_path.is_dir():
op_plugin_path.mkdir(parents=True, exist_ok=True)
@@ -423,12 +474,12 @@ def _build_and_move_plugin(engine_path: Path,
uat_path: Path = get_path_to_uat(engine_path)
env = env or os.environ
- integration_plugin_path: Path = Path(env.get("OPENPYPE_UNREAL_PLUGIN", ""))
+ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", ""))
if uat_path.is_file():
temp_dir: Path = integration_plugin_path.parent / "Temp"
temp_dir.mkdir(exist_ok=True)
- uplugin_path: Path = integration_plugin_path / "OpenPype.uplugin"
+ uplugin_path: Path = integration_plugin_path / "Ayon.uplugin"
# in order to successfully build the plugin,
# It must be built outside the Engine directory and then moved
@@ -439,7 +490,7 @@ def _build_and_move_plugin(engine_path: Path,
subprocess.run(build_plugin_cmd)
# Copy the contents of the 'Temp' dir into the
- # 'OpenPype' directory in the engine
+ # 'Ayon' directory in the engine
dir_util.copy_tree(temp_dir.as_posix(), plugin_build_path.as_posix())
# We need to also copy the config folder.
diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py
index 642924e2d6..73afb6cefd 100644
--- a/openpype/hosts/unreal/plugins/create/create_camera.py
+++ b/openpype/hosts/unreal/plugins/create/create_camera.py
@@ -11,7 +11,7 @@ from openpype.hosts.unreal.api.plugin import (
class CreateCamera(UnrealAssetCreator):
"""Create Camera."""
- identifier = "io.openpype.creators.unreal.camera"
+ identifier = "io.ayon.creators.unreal.camera"
label = "Camera"
family = "camera"
icon = "fa.camera"
diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py
index 1d2e800a13..e5c7b8ee19 100644
--- a/openpype/hosts/unreal/plugins/create/create_layout.py
+++ b/openpype/hosts/unreal/plugins/create/create_layout.py
@@ -7,7 +7,7 @@ from openpype.hosts.unreal.api.plugin import (
class CreateLayout(UnrealActorCreator):
"""Layout output for character rigs."""
- identifier = "io.openpype.creators.unreal.layout"
+ identifier = "io.ayon.creators.unreal.layout"
label = "Layout"
family = "layout"
icon = "cubes"
diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py
index f6c73e47e6..e15b57b2ee 100644
--- a/openpype/hosts/unreal/plugins/create/create_look.py
+++ b/openpype/hosts/unreal/plugins/create/create_look.py
@@ -14,7 +14,7 @@ from openpype.lib import UILabelDef
class CreateLook(UnrealAssetCreator):
"""Shader connections defining shape look."""
- identifier = "io.openpype.creators.unreal.look"
+ identifier = "io.ayon.creators.unreal.look"
label = "Look"
family = "look"
icon = "paint-brush"
@@ -30,7 +30,7 @@ class CreateLook(UnrealAssetCreator):
selected_asset = selection[0]
- look_directory = "/Game/OpenPype/Looks"
+ look_directory = "/Game/Ayon/Looks"
# Create the folder
folder_name = create_folder(look_directory, subset_name)
diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py
index 5834d2e7a7..5f561e68ad 100644
--- a/openpype/hosts/unreal/plugins/create/create_render.py
+++ b/openpype/hosts/unreal/plugins/create/create_render.py
@@ -1,25 +1,118 @@
# -*- coding: utf-8 -*-
+from pathlib import Path
+
import unreal
-from openpype.pipeline import CreatorError
from openpype.hosts.unreal.api.pipeline import (
- get_subsequences
+ UNREAL_VERSION,
+ create_folder,
+ get_subsequences,
)
from openpype.hosts.unreal.api.plugin import (
UnrealAssetCreator
)
-from openpype.lib import UILabelDef
+from openpype.lib import (
+ UILabelDef,
+ UISeparatorDef,
+ BoolDef,
+ NumberDef
+)
class CreateRender(UnrealAssetCreator):
"""Create instance for sequence for rendering"""
- identifier = "io.openpype.creators.unreal.render"
+ identifier = "io.ayon.creators.unreal.render"
label = "Render"
family = "render"
icon = "eye"
- def create(self, subset_name, instance_data, pre_create_data):
+ def create_instance(
+ self, instance_data, subset_name, pre_create_data,
+ selected_asset_path, master_seq, master_lvl, seq_data
+ ):
+ instance_data["members"] = [selected_asset_path]
+ instance_data["sequence"] = selected_asset_path
+ instance_data["master_sequence"] = master_seq
+ instance_data["master_level"] = master_lvl
+ instance_data["output"] = seq_data.get('output')
+ instance_data["frameStart"] = seq_data.get('frame_range')[0]
+ instance_data["frameEnd"] = seq_data.get('frame_range')[1]
+
+ super(CreateRender, self).create(
+ subset_name,
+ instance_data,
+ pre_create_data)
+
+ def create_with_new_sequence(
+ self, subset_name, instance_data, pre_create_data
+ ):
+ # If the option to create a new level sequence is selected,
+ # create a new level sequence and a master level.
+
+ root = f"/Game/Ayon/Sequences"
+
+ # Create a new folder for the sequence in root
+ sequence_dir_name = create_folder(root, subset_name)
+ sequence_dir = f"{root}/{sequence_dir_name}"
+
+ unreal.log_warning(f"sequence_dir: {sequence_dir}")
+
+ # Create the level sequence
+ asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
+ seq = asset_tools.create_asset(
+ asset_name=subset_name,
+ package_path=sequence_dir,
+ asset_class=unreal.LevelSequence,
+ factory=unreal.LevelSequenceFactoryNew())
+
+ seq.set_playback_start(pre_create_data.get("start_frame"))
+ seq.set_playback_end(pre_create_data.get("end_frame"))
+
+ pre_create_data["members"] = [seq.get_path_name()]
+
+ unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
+
+ # Create the master level
+ if UNREAL_VERSION.major >= 5:
+ curr_level = unreal.LevelEditorSubsystem().get_current_level()
+ else:
+ world = unreal.EditorLevelLibrary.get_editor_world()
+ levels = unreal.EditorLevelUtils.get_levels(world)
+ curr_level = levels[0] if len(levels) else None
+ if not curr_level:
+ raise RuntimeError("No level loaded.")
+ curr_level_path = curr_level.get_outer().get_path_name()
+
+ # If the level path does not start with "/Game/", the current
+ # level is a temporary, unsaved level.
+ if curr_level_path.startswith("/Game/"):
+ if UNREAL_VERSION.major >= 5:
+ unreal.LevelEditorSubsystem().save_current_level()
+ else:
+ unreal.EditorLevelLibrary.save_current_level()
+
+ ml_path = f"{sequence_dir}/{subset_name}_MasterLevel"
+
+ if UNREAL_VERSION.major >= 5:
+ unreal.LevelEditorSubsystem().new_level(ml_path)
+ else:
+ unreal.EditorLevelLibrary.new_level(ml_path)
+
+ seq_data = {
+ "sequence": seq,
+ "output": f"{seq.get_name()}",
+ "frame_range": (
+ seq.get_playback_start(),
+ seq.get_playback_end())}
+
+ self.create_instance(
+ instance_data, subset_name, pre_create_data,
+ seq.get_path_name(), seq.get_path_name(), ml_path, seq_data)
+
+ def create_from_existing_sequence(
+ self, subset_name, instance_data, pre_create_data
+ ):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
sel_objects = unreal.EditorUtilityLibrary.get_selected_assets()
@@ -27,8 +120,8 @@ class CreateRender(UnrealAssetCreator):
a.get_path_name() for a in sel_objects
if a.get_class().get_name() == "LevelSequence"]
- if not selection:
- raise CreatorError("Please select at least one Level Sequence.")
+ if len(selection) == 0:
+ raise RuntimeError("Please select at least one Level Sequence.")
seq_data = None
@@ -42,28 +135,38 @@ class CreateRender(UnrealAssetCreator):
f"Skipping {selected_asset.get_name()}. It isn't a Level "
"Sequence.")
- # The asset name is the third element of the path which
- # contains the map.
- # To take the asset name, we remove from the path the prefix
- # "/Game/OpenPype/" and then we split the path by "/".
- sel_path = selected_asset_path
- asset_name = sel_path.replace("/Game/OpenPype/", "").split("/")[0]
+ if pre_create_data.get("use_hierarchy"):
+ # The asset name is the the third element of the path which
+ # contains the map.
+ # To take the asset name, we remove from the path the prefix
+ # "/Game/OpenPype/" and then we split the path by "/".
+ sel_path = selected_asset_path
+ asset_name = sel_path.replace(
+ "/Game/Ayon/", "").split("/")[0]
+
+ search_path = f"/Game/Ayon/{asset_name}"
+ else:
+ search_path = Path(selected_asset_path).parent.as_posix()
# Get the master sequence and the master level.
# There should be only one sequence and one level in the directory.
- ar_filter = unreal.ARFilter(
- class_names=["LevelSequence"],
- package_paths=[f"/Game/OpenPype/{asset_name}"],
- recursive_paths=False)
- sequences = ar.get_assets(ar_filter)
- master_seq = sequences[0].get_asset().get_path_name()
- master_seq_obj = sequences[0].get_asset()
- ar_filter = unreal.ARFilter(
- class_names=["World"],
- package_paths=[f"/Game/OpenPype/{asset_name}"],
- recursive_paths=False)
- levels = ar.get_assets(ar_filter)
- master_lvl = levels[0].get_asset().get_path_name()
+ try:
+ ar_filter = unreal.ARFilter(
+ class_names=["LevelSequence"],
+ package_paths=[search_path],
+ recursive_paths=False)
+ sequences = ar.get_assets(ar_filter)
+ master_seq = sequences[0].get_asset().get_path_name()
+ master_seq_obj = sequences[0].get_asset()
+ ar_filter = unreal.ARFilter(
+ class_names=["World"],
+ package_paths=[search_path],
+ recursive_paths=False)
+ levels = ar.get_assets(ar_filter)
+ master_lvl = levels[0].get_asset().get_path_name()
+ except IndexError:
+ raise RuntimeError(
+ f"Could not find the hierarchy for the selected sequence.")
# If the selected asset is the master sequence, we get its data
# and then we create the instance for the master sequence.
@@ -79,7 +182,8 @@ class CreateRender(UnrealAssetCreator):
master_seq_obj.get_playback_start(),
master_seq_obj.get_playback_end())}
- if selected_asset_path == master_seq:
+ if (selected_asset_path == master_seq or
+ pre_create_data.get("use_hierarchy")):
seq_data = master_seq_data
else:
seq_data_list = [master_seq_data]
@@ -119,20 +223,54 @@ class CreateRender(UnrealAssetCreator):
"sub-sequence of the master sequence.")
continue
- instance_data["members"] = [selected_asset_path]
- instance_data["sequence"] = selected_asset_path
- instance_data["master_sequence"] = master_seq
- instance_data["master_level"] = master_lvl
- instance_data["output"] = seq_data.get('output')
- instance_data["frameStart"] = seq_data.get('frame_range')[0]
- instance_data["frameEnd"] = seq_data.get('frame_range')[1]
+ self.create_instance(
+ instance_data, subset_name, pre_create_data,
+ selected_asset_path, master_seq, master_lvl, seq_data)
- super(CreateRender, self).create(
- subset_name,
- instance_data,
- pre_create_data)
+ def create(self, subset_name, instance_data, pre_create_data):
+ if pre_create_data.get("create_seq"):
+ self.create_with_new_sequence(
+ subset_name, instance_data, pre_create_data)
+ else:
+ self.create_from_existing_sequence(
+ subset_name, instance_data, pre_create_data)
def get_pre_create_attr_defs(self):
return [
- UILabelDef("Select the sequence to render.")
+ UILabelDef(
+ "Select a Level Sequence to render or create a new one."
+ ),
+ BoolDef(
+ "create_seq",
+ label="Create a new Level Sequence",
+ default=False
+ ),
+ UILabelDef(
+ "WARNING: If you create a new Level Sequence, the current\n"
+ "level will be saved and a new Master Level will be created."
+ ),
+ NumberDef(
+ "start_frame",
+ label="Start Frame",
+ default=0,
+ minimum=-999999,
+ maximum=999999
+ ),
+ NumberDef(
+ "end_frame",
+ label="Start Frame",
+ default=150,
+ minimum=-999999,
+ maximum=999999
+ ),
+ UISeparatorDef(),
+ UILabelDef(
+ "The following settings are valid only if you are not\n"
+ "creating a new sequence."
+ ),
+ BoolDef(
+ "use_hierarchy",
+ label="Use Hierarchy",
+ default=False
+ ),
]
diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
index 1acf7084d1..80816d8386 100644
--- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
+++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py
@@ -7,7 +7,7 @@ from openpype.hosts.unreal.api.plugin import (
class CreateStaticMeshFBX(UnrealAssetCreator):
"""Create Static Meshes as FBX geometry."""
- identifier = "io.openpype.creators.unreal.staticmeshfbx"
+ identifier = "io.ayon.creators.unreal.staticmeshfbx"
label = "Static Mesh (FBX)"
family = "unrealStaticMesh"
icon = "cube"
diff --git a/openpype/hosts/unreal/plugins/create/create_uasset.py b/openpype/hosts/unreal/plugins/create/create_uasset.py
index 70f17d478b..c78518e86b 100644
--- a/openpype/hosts/unreal/plugins/create/create_uasset.py
+++ b/openpype/hosts/unreal/plugins/create/create_uasset.py
@@ -12,7 +12,7 @@ from openpype.hosts.unreal.api.plugin import (
class CreateUAsset(UnrealAssetCreator):
"""Create UAsset."""
- identifier = "io.openpype.creators.unreal.uasset"
+ identifier = "io.ayon.creators.unreal.uasset"
label = "UAsset"
family = "uasset"
icon = "cube"
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
index 496b6056ea..52eea4122a 100644
--- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -68,8 +68,8 @@ class AnimationAlembicLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and openpype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and ayon container
+ root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -97,8 +97,8 @@ class AnimationAlembicLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -109,7 +109,7 @@ class AnimationAlembicLoader(plugin.Loader):
"family": context["representation"]["context"]["family"]
}
unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py
index 1fe0bef462..778ddf693d 100644
--- a/openpype/hosts/unreal/plugins/load/load_animation.py
+++ b/openpype/hosts/unreal/plugins/load/load_animation.py
@@ -11,7 +11,7 @@ from unreal import MovieSceneSkeletalAnimationSection
from openpype.pipeline.context_tools import get_current_project_asset
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -139,9 +139,9 @@ class AnimationFBXLoader(plugin.Loader):
Returns:
list(str): list of container content
"""
- # Create directory for asset and avalon container
+ # Create directory for asset and Ayon container
hierarchy = context.get('asset').get('data').get('parents')
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
asset = context.get('asset').get('name')
suffix = "_CON"
asset_name = f"{asset}_{name}" if asset else f"{name}"
@@ -156,7 +156,7 @@ class AnimationFBXLoader(plugin.Loader):
package_paths=[f"{root}/{hierarchy[0]}"],
recursive_paths=False)
levels = ar.get_assets(_filter)
- master_level = levels[0].get_editor_property('object_path')
+ master_level = levels[0].get_full_name()
hierarchy_dir = root
for h in hierarchy:
@@ -168,7 +168,7 @@ class AnimationFBXLoader(plugin.Loader):
package_paths=[f"{hierarchy_dir}/"],
recursive_paths=True)
levels = ar.get_assets(_filter)
- level = levels[0].get_editor_property('object_path')
+ level = levels[0].get_full_name()
unreal.EditorLevelLibrary.save_all_dirty_levels()
unreal.EditorLevelLibrary.load_level(level)
@@ -223,8 +223,8 @@ class AnimationFBXLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py
index 2496440e5f..2303ed1ffc 100644
--- a/openpype/hosts/unreal/plugins/load/load_camera.py
+++ b/openpype/hosts/unreal/plugins/load/load_camera.py
@@ -8,7 +8,7 @@ from unreal import EditorLevelLibrary
from unreal import EditorLevelUtils
from openpype.client import get_assets, get_asset_by_name
from openpype.pipeline import (
- AVALON_CONTAINER_ID,
+ AYON_CONTAINER_ID,
legacy_io,
)
from openpype.hosts.unreal.api import plugin
@@ -100,9 +100,9 @@ class CameraLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and avalon container
+ # Create directory for asset and Ayon container
hierarchy = context.get('asset').get('data').get('parents')
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
hierarchy_dir = root
hierarchy_dir_list = []
for h in hierarchy:
@@ -268,8 +268,8 @@ class CameraLoader(plugin.Loader):
data = get_asset_by_name(project_name, asset)["data"]
cam_seq.set_display_rate(
unreal.FrameRate(data.get("fps"), 1.0))
- cam_seq.set_playback_start(0)
- cam_seq.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1)
+ cam_seq.set_playback_start(data.get('clipIn'))
+ cam_seq.set_playback_end(data.get('clipOut') + 1)
self._set_sequence_hierarchy(
sequences[-1], cam_seq,
data.get('clipIn'), data.get('clipOut'))
@@ -291,8 +291,8 @@ class CameraLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -320,7 +320,7 @@ class CameraLoader(plugin.Loader):
def update(self, container, representation):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
- root = "/Game/OpenPype"
+ root = "/Game/ayon"
asset_dir = container.get('namespace')
@@ -378,7 +378,7 @@ class CameraLoader(plugin.Loader):
# Remove the Level Sequence from the parent.
# We need to traverse the hierarchy from the master sequence to find
# the level sequence.
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
namespace = container.get('namespace').replace(f"{root}/", "")
ms_asset = namespace.split('/')[0]
filter = unreal.ARFilter(
@@ -511,7 +511,7 @@ class CameraLoader(plugin.Loader):
# Remove the Level Sequence from the parent.
# We need to traverse the hierarchy from the master sequence to find
# the level sequence.
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
namespace = container.get('namespace').replace(f"{root}/", "")
ms_asset = namespace.split('/')[0]
filter = unreal.ARFilter(
diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
index 6ac3531b40..3a292fdbd1 100644
--- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -22,7 +22,8 @@ class PointCacheAlembicLoader(plugin.Loader):
color = "orange"
def get_task(
- self, filename, asset_dir, asset_name, replace, frame_start, frame_end
+ self, filename, asset_dir, asset_name, replace,
+ frame_start=None, frame_end=None
):
task = unreal.AssetImportTask()
options = unreal.AbcImportSettings()
@@ -51,8 +52,10 @@ class PointCacheAlembicLoader(plugin.Loader):
conversion_settings.set_editor_property(
'rotation', unreal.Vector(x=-90.0, y=0.0, z=180.0))
- sampling_settings.set_editor_property('frame_start', frame_start)
- sampling_settings.set_editor_property('frame_end', frame_end)
+ if frame_start is not None:
+ sampling_settings.set_editor_property('frame_start', frame_start)
+ if frame_end is not None:
+ sampling_settings.set_editor_property('frame_end', frame_end)
options.geometry_cache_settings = gc_settings
options.conversion_settings = conversion_settings
@@ -83,8 +86,8 @@ class PointCacheAlembicLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and OpenPype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and Ayon container
+ root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -118,8 +121,8 @@ class PointCacheAlembicLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -145,9 +148,9 @@ class PointCacheAlembicLoader(plugin.Loader):
name = container["asset_name"]
source_path = get_representation_path(representation)
destination_path = container["namespace"]
+ representation["context"]
- task = self.get_task(source_path, destination_path, name, True)
-
+ task = self.get_task(source_path, destination_path, name, False)
# do import fbx and replace existing data
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py
index 63d415a52b..e5f32c3412 100644
--- a/openpype/hosts/unreal/plugins/load/load_layout.py
+++ b/openpype/hosts/unreal/plugins/load/load_layout.py
@@ -19,7 +19,7 @@ from openpype.pipeline import (
loaders_from_representation,
load_container,
get_representation_path,
- AVALON_CONTAINER_ID,
+ AYON_CONTAINER_ID,
legacy_io,
)
from openpype.pipeline.context_tools import get_current_project_asset
@@ -37,7 +37,7 @@ class LayoutLoader(plugin.Loader):
label = "Load Layout"
icon = "code-fork"
color = "orange"
- ASSET_ROOT = "/Game/OpenPype"
+ ASSET_ROOT = "/Game/Ayon"
def _get_asset_containers(self, path):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
@@ -50,7 +50,7 @@ class LayoutLoader(plugin.Loader):
# Get all the asset containers
for a in asset_content:
obj = ar.get_asset_by_object_path(a)
- if obj.get_asset().get_class().get_name() == 'AssetContainer':
+ if obj.get_asset().get_class().get_name() == 'AyonAssetContainer':
asset_containers.append(obj)
return asset_containers
@@ -338,7 +338,7 @@ class LayoutLoader(plugin.Loader):
).replace('\\', '/')
_filter = unreal.ARFilter(
- class_names=["AssetContainer"],
+ class_names=["AyonAssetContainer"],
package_paths=[anim_path],
recursive_paths=False)
containers = ar.get_assets(_filter)
@@ -519,7 +519,7 @@ class LayoutLoader(plugin.Loader):
for asset in assets:
obj = ar.get_asset_by_object_path(asset).get_asset()
- if obj.get_class().get_name() == 'AssetContainer':
+ if obj.get_class().get_name() == 'AyonAssetContainer':
container = obj
if obj.get_class().get_name() == 'Skeleton':
skeleton = obj
@@ -634,7 +634,7 @@ class LayoutLoader(plugin.Loader):
data = get_current_project_settings()
create_sequences = data["unreal"]["level_sequences_for_layouts"]
- # Create directory for asset and avalon container
+ # Create directory for asset and Ayon container
hierarchy = context.get('asset').get('data').get('parents')
root = self.ASSET_ROOT
hierarchy_dir = root
@@ -749,8 +749,8 @@ class LayoutLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -781,7 +781,7 @@ class LayoutLoader(plugin.Loader):
ar = unreal.AssetRegistryHelpers.get_asset_registry()
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
asset_dir = container.get('namespace')
context = representation.get("context")
@@ -819,7 +819,7 @@ class LayoutLoader(plugin.Loader):
recursive_paths=False)
levels = ar.get_assets(filter)
- layout_level = levels[0].get_editor_property('object_path')
+ layout_level = levels[0].get_full_name()
EditorLevelLibrary.save_all_dirty_levels()
EditorLevelLibrary.load_level(layout_level)
@@ -867,7 +867,7 @@ class LayoutLoader(plugin.Loader):
data = get_current_project_settings()
create_sequences = data["unreal"]["level_sequences_for_layouts"]
- root = "/Game/OpenPype"
+ root = "/Game/Ayon"
path = Path(container.get("namespace"))
containers = unreal_pipeline.ls()
@@ -919,7 +919,7 @@ class LayoutLoader(plugin.Loader):
package_paths=[f"{root}/{ms_asset}"],
recursive_paths=False)
levels = ar.get_assets(_filter)
- master_level = levels[0].get_editor_property('object_path')
+ master_level = levels[0].get_full_name()
sequences = [master_sequence]
diff --git a/openpype/hosts/unreal/plugins/load/load_layout_existing.py b/openpype/hosts/unreal/plugins/load/load_layout_existing.py
index 092b273ded..96ee8cfc25 100644
--- a/openpype/hosts/unreal/plugins/load/load_layout_existing.py
+++ b/openpype/hosts/unreal/plugins/load/load_layout_existing.py
@@ -10,7 +10,7 @@ from openpype.pipeline import (
loaders_from_representation,
load_container,
get_representation_path,
- AVALON_CONTAINER_ID,
+ AYON_CONTAINER_ID,
legacy_io,
)
from openpype.hosts.unreal.api import plugin
@@ -28,7 +28,7 @@ class ExistingLayoutLoader(plugin.Loader):
label = "Load Layout on Existing Scene"
icon = "code-fork"
color = "orange"
- ASSET_ROOT = "/Game/OpenPype"
+ ASSET_ROOT = "/Game/Ayon"
delete_unmatched_assets = True
@@ -59,8 +59,8 @@ class ExistingLayoutLoader(plugin.Loader):
container = obj.get_asset()
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -416,8 +416,8 @@ class ExistingLayoutLoader(plugin.Loader):
container=container_name, path=curr_level_path)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": curr_level_path,
"container_name": container_name,
diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
index e316d255e9..7591d5582f 100644
--- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -70,8 +70,8 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and openpype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and ayon container
+ root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -98,8 +98,8 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -110,7 +110,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
"family": context["representation"]["context"]["family"]
}
unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
index 227c5c9292..e9676cde3a 100644
--- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -42,8 +42,8 @@ class SkeletalMeshFBXLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and OpenPype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and Ayon container
+ root = "/Game/Ayon/Assets"
if options and options.get("asset_dir"):
root = options["asset_dir"]
asset = context.get('asset').get('name')
@@ -103,8 +103,8 @@ class SkeletalMeshFBXLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -115,7 +115,7 @@ class SkeletalMeshFBXLoader(plugin.Loader):
"family": context["representation"]["context"]["family"]
}
unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
index c7841cef53..befc7b0ac9 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -75,8 +75,8 @@ class StaticMeshAlembicLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and OpenPype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and Ayon container
+ root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -108,8 +108,8 @@ class StaticMeshAlembicLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -119,8 +119,7 @@ class StaticMeshAlembicLoader(plugin.Loader):
"parent": context["representation"]["parent"],
"family": context["representation"]["context"]["family"]
}
- unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -136,7 +135,7 @@ class StaticMeshAlembicLoader(plugin.Loader):
source_path = get_representation_path(representation)
destination_path = container["namespace"]
- task = self.get_task(source_path, destination_path, name, True)
+ task = self.get_task(source_path, destination_path, name, True, False)
# do import fbx and replace existing data
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
index 351c686095..e416256486 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
@@ -4,7 +4,7 @@ import os
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -68,8 +68,8 @@ class StaticMeshFBXLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and OpenPype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and Ayon container
+ root = "/Game/Ayon/Assets"
if options and options.get("asset_dir"):
root = options["asset_dir"]
asset = context.get('asset').get('name')
@@ -81,7 +81,8 @@ class StaticMeshFBXLoader(plugin.Loader):
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- "{}/{}/{}".format(root, asset, name), suffix="")
+ f"{root}/{asset}/{name}", suffix=""
+ )
container_name += suffix
@@ -96,8 +97,8 @@ class StaticMeshFBXLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -107,8 +108,7 @@ class StaticMeshFBXLoader(plugin.Loader):
"parent": context["representation"]["parent"],
"family": context["representation"]["context"]["family"]
}
- unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
diff --git a/openpype/hosts/unreal/plugins/load/load_uasset.py b/openpype/hosts/unreal/plugins/load/load_uasset.py
index eccfc7b445..7606bc14e4 100644
--- a/openpype/hosts/unreal/plugins/load/load_uasset.py
+++ b/openpype/hosts/unreal/plugins/load/load_uasset.py
@@ -5,7 +5,7 @@ import shutil
from openpype.pipeline import (
get_representation_path,
- AVALON_CONTAINER_ID
+ AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
from openpype.hosts.unreal.api import pipeline as unreal_pipeline
@@ -38,8 +38,8 @@ class UAssetLoader(plugin.Loader):
list(str): list of container content
"""
- # Create directory for asset and OpenPype container
- root = "/Game/OpenPype/Assets"
+ # Create directory for asset and Ayon container
+ root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
@@ -49,7 +49,8 @@ class UAssetLoader(plugin.Loader):
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- "{}/{}/{}".format(root, asset, name), suffix="")
+ f"{root}/{asset}/{name}", suffix=""
+ )
container_name += suffix
@@ -67,8 +68,8 @@ class UAssetLoader(plugin.Loader):
container=container_name, path=asset_dir)
data = {
- "schema": "openpype:container-2.0",
- "id": AVALON_CONTAINER_ID,
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
@@ -78,8 +79,7 @@ class UAssetLoader(plugin.Loader):
"parent": context["representation"]["parent"],
"family": context["representation"]["context"]["family"]
}
- unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -107,7 +107,7 @@ class UAssetLoader(plugin.Loader):
for asset in asset_content:
obj = ar.get_asset_by_object_path(asset).get_asset()
- if not obj.get_class().get_name() == 'AssetContainer':
+ if not obj.get_class().get_name() == 'AyonAssetContainer':
unreal.EditorAssetLibrary.delete_asset(asset)
update_filepath = get_representation_path(representation)
diff --git a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py
index cb28f4bf60..6697a6b90d 100644
--- a/openpype/hosts/unreal/plugins/publish/collect_render_instances.py
+++ b/openpype/hosts/unreal/plugins/publish/collect_render_instances.py
@@ -3,6 +3,7 @@ from pathlib import Path
import unreal
+from openpype.pipeline import get_current_project_name
from openpype.pipeline import Anatomy
from openpype.hosts.unreal.api import pipeline
import pyblish.api
@@ -81,12 +82,13 @@ class CollectRenderInstances(pyblish.api.InstancePlugin):
self.log.debug(f"new instance data: {new_data}")
try:
- project = os.environ.get("AVALON_PROJECT")
+ project = get_current_project_name()
anatomy = Anatomy(project)
root = anatomy.roots['renders']
- except Exception:
- raise Exception(
- "Could not find render root in anatomy settings.")
+ except Exception as e:
+ raise Exception((
+ "Could not find render root "
+ "in anatomy settings.")) from e
render_dir = f"{root}/{project}/{s.get('output')}"
render_path = Path(render_dir)
diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py
index cac7991f00..57e7957575 100644
--- a/openpype/hosts/unreal/plugins/publish/extract_layout.py
+++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py
@@ -48,7 +48,7 @@ class ExtractLayout(publish.Extractor):
# Search the reference to the Asset Container for the object
path = unreal.Paths.get_path(mesh.get_path_name())
filter = unreal.ARFilter(
- class_names=["AssetContainer"], package_paths=[path])
+ class_names=["AyonAssetContainer"], package_paths=[path])
ar = unreal.AssetRegistryHelpers.get_asset_registry()
try:
asset_container = ar.get_assets(filter)[0].get_asset()
diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py
new file mode 100644
index 0000000000..e6584e130f
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py
@@ -0,0 +1,42 @@
+import clique
+
+import pyblish.api
+
+
+class ValidateSequenceFrames(pyblish.api.InstancePlugin):
+ """Ensure the sequence of frames is complete
+
+ The files found in the folder are checked against the frameStart and
+ frameEnd of the instance. If the first or last file is not
+ corresponding with the first or last frame it is flagged as invalid.
+ """
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate Sequence Frames"
+ families = ["render"]
+ hosts = ["unreal"]
+ optional = True
+
+ def process(self, instance):
+ representations = instance.data.get("representations")
+ for repr in representations:
+ data = instance.data.get("assetEntity", {}).get("data", {})
+ patterns = [clique.PATTERNS["frames"]]
+ collections, remainder = clique.assemble(
+ repr["files"], minimum_items=1, patterns=patterns)
+
+ assert not remainder, "Must not have remainder"
+ assert len(collections) == 1, "Must detect single collection"
+ collection = collections[0]
+ frames = list(collection.indexes)
+
+ current_range = (frames[0], frames[-1])
+ required_range = (data["frameStart"],
+ data["frameEnd"])
+
+ if current_range != required_range:
+ raise ValueError(f"Invalid frame range: {current_range} - "
+ f"expected: {required_range}")
+
+ missing = collection.holes().indexes
+ assert not missing, "Missing frames: %s" % (missing,)
diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py
index d1740124a8..e7a690ac9c 100644
--- a/openpype/hosts/unreal/ue_workers.py
+++ b/openpype/hosts/unreal/ue_workers.py
@@ -93,7 +93,7 @@ class UEProjectGenerationWorker(QtCore.QObject):
commandlet_cmd = [
f"{ue_editor_exe.as_posix()}",
f"{cmdlet_project.as_posix()}",
- "-run=OPGenerateProject",
+ "-run=AyonGenerateProject",
f"{project_file.resolve().as_posix()}",
]
@@ -286,7 +286,7 @@ class UEPluginInstallWorker(QtCore.QObject):
def _build_and_move_plugin(self, plugin_build_path: Path):
uat_path: Path = ue_lib.get_path_to_uat(self.engine_path)
- src_plugin_dir = Path(self.env.get("OPENPYPE_UNREAL_PLUGIN", ""))
+ src_plugin_dir = Path(self.env.get("AYON_UNREAL_PLUGIN", ""))
if not os.path.isdir(src_plugin_dir):
msg = "Path to the integration plugin is null!"
@@ -300,7 +300,7 @@ class UEPluginInstallWorker(QtCore.QObject):
temp_dir: Path = src_plugin_dir.parent / "Temp"
temp_dir.mkdir(exist_ok=True)
- uplugin_path: Path = src_plugin_dir / "OpenPype.uplugin"
+ uplugin_path: Path = src_plugin_dir / "Ayon.uplugin"
# in order to successfully build the plugin,
# It must be built outside the Engine directory and then moved
@@ -332,7 +332,7 @@ class UEPluginInstallWorker(QtCore.QObject):
raise RuntimeError(msg)
# Copy the contents of the 'Temp' dir into the
- # 'OpenPype' directory in the engine
+ # 'Ayon' directory in the engine
dir_util.copy_tree(temp_dir.as_posix(),
plugin_build_path.as_posix())
@@ -347,7 +347,7 @@ class UEPluginInstallWorker(QtCore.QObject):
dir_util.remove_tree(temp_dir.as_posix())
def run(self):
- src_plugin_dir = Path(self.env.get("OPENPYPE_UNREAL_PLUGIN", ""))
+ src_plugin_dir = Path(self.env.get("AYON_UNREAL_PLUGIN", ""))
if not os.path.isdir(src_plugin_dir):
msg = "Path to the integration plugin is null!"
@@ -356,7 +356,7 @@ class UEPluginInstallWorker(QtCore.QObject):
# Create a path to the plugin in the engine
op_plugin_path = self.engine_path / "Engine/Plugins/Marketplace" \
- "/OpenPype"
+ "/Ayon"
if not op_plugin_path.is_dir():
self.installing.emit("Installing and building the plugin ...")
diff --git a/openpype/lib/project_backpack.py b/openpype/lib/project_backpack.py
index ff2f1d4b88..07107ec011 100644
--- a/openpype/lib/project_backpack.py
+++ b/openpype/lib/project_backpack.py
@@ -1,16 +1,19 @@
-"""These lib functions are primarily for development purposes.
+"""These lib functions are for development purposes.
-WARNING: This is not meant for production data.
+WARNING:
+ This is not meant for production data. Please don't write code which is
+ dependent on functionality here.
-Goal is to be able create package of current state of project with related
-documents from mongo and files from disk to zip file and then be able recreate
-the project based on the zip.
+Goal is to be able to create package of current state of project with related
+documents from mongo and files from disk to zip file and then be able
+to recreate the project based on the zip.
This gives ability to create project where a changes and tests can be done.
-Keep in mind that to be able create a package of project has few requirements.
-Possible requirement should be listed in 'pack_project' function.
+Keep in mind that to be able to create a package of project has few
+requirements. Possible requirement should be listed in 'pack_project' function.
"""
+
import os
import json
import platform
@@ -19,16 +22,12 @@ import shutil
import datetime
import zipfile
-from bson.json_util import (
- loads,
- dumps,
- CANONICAL_JSON_OPTIONS
+from openpype.client.mongo import (
+ load_json_file,
+ get_project_connection,
+ replace_project_documents,
+ store_project_documents,
)
-from openpype.client import (
- get_project,
- get_whole_project,
-)
-from openpype.pipeline import AvalonMongoDB
DOCUMENTS_FILE_NAME = "database"
METADATA_FILE_NAME = "metadata"
@@ -43,7 +42,52 @@ def add_timestamp(filepath):
return new_base + ext
-def pack_project(project_name, destination_dir=None):
+def get_project_document(project_name, database_name=None):
+ """Query project document.
+
+ Function 'get_project' from client api cannot be used as it does not allow
+ to change which 'database_name' is used.
+
+ Args:
+ project_name (str): Name of project.
+ database_name (Optional[str]): Name of mongo database where to look for
+ project.
+
+ Returns:
+ Union[dict[str, Any], None]: Project document or None.
+ """
+
+ col = get_project_connection(project_name, database_name)
+ return col.find_one({"type": "project"})
+
+
+def _pack_files_to_zip(zip_stream, source_path, root_path):
+ """Pack files to a zip stream.
+
+ Args:
+ zip_stream (zipfile.ZipFile): Stream to a zipfile.
+ source_path (str): Path to a directory where files are.
+ root_path (str): Path to a directory which is used for calculation
+ of relative path.
+ """
+
+ for root, _, filenames in os.walk(source_path):
+ for filename in filenames:
+ filepath = os.path.join(root, filename)
+ # TODO add one more folder
+ archive_name = os.path.join(
+ PROJECT_FILES_DIR,
+ os.path.relpath(filepath, root_path)
+ )
+ zip_stream.write(filepath, archive_name)
+
+
+def pack_project(
+ project_name,
+ destination_dir=None,
+ only_documents=False,
+ database_name=None
+):
"""Make a package of a project with mongo documents and files.
This function has few restrictions:
@@ -52,13 +96,18 @@ def pack_project(project_name, destination_dir=None):
"{root[...]}/{project[name]}"
Args:
- project_name(str): Project that should be packaged.
- destination_dir(str): Optional path where zip will be stored. Project's
- root is used if not passed.
+ project_name (str): Project that should be packaged.
+ destination_dir (Optional[str]): Optional path where zip will be
+ stored. Project's root is used if not passed.
+ only_documents (Optional[bool]): Pack only Mongo documents and skip
+ files.
+ database_name (Optional[str]): Custom database name from which is
+ project queried.
"""
+
print("Creating package of project \"{}\"".format(project_name))
# Validate existence of project
- project_doc = get_project(project_name)
+ project_doc = get_project_document(project_name, database_name)
if not project_doc:
raise ValueError("Project \"{}\" was not found in database".format(
project_name
@@ -119,12 +168,7 @@ def pack_project(project_name, destination_dir=None):
temp_docs_json = s.name
# Query all project documents and store them to temp json
- docs = list(get_whole_project(project_name))
- data = dumps(
- docs, json_options=CANONICAL_JSON_OPTIONS
- )
- with open(temp_docs_json, "w") as stream:
- stream.write(data)
+ store_project_documents(project_name, temp_docs_json, database_name)
print("Packing files into zip")
# Write all to zip file
@@ -133,16 +177,10 @@ def pack_project(project_name, destination_dir=None):
zip_stream.write(temp_metadata_json, METADATA_FILE_NAME + ".json")
# Add database documents
zip_stream.write(temp_docs_json, DOCUMENTS_FILE_NAME + ".json")
+
# Add project files to zip
- for root, _, filenames in os.walk(project_source_path):
- for filename in filenames:
- filepath = os.path.join(root, filename)
- # TODO add one more folder
- archive_name = os.path.join(
- PROJECT_FILES_DIR,
- os.path.relpath(filepath, root_path)
- )
- zip_stream.write(filepath, archive_name)
+ if not only_documents:
+ _pack_files_to_zip(zip_stream, project_source_path, root_path)
print("Cleaning up")
# Cleanup
@@ -152,80 +190,30 @@ def pack_project(project_name, destination_dir=None):
print("*** Packing finished ***")
-def unpack_project(path_to_zip, new_root=None):
- """Unpack project zip file to recreate project.
+def _unpack_project_files(unzip_dir, root_path, project_name):
+ """Move project files from unarchived temp folder to new root.
+
+ Unpack is skipped if source files are not available in the zip. That can
+ happen if nothing was published yet or only documents were stored to
+ package.
Args:
- path_to_zip(str): Path to zip which was created using 'pack_project'
- function.
- new_root(str): Optional way how to set different root path for unpacked
- project.
+ unzip_dir (str): Location where zip was unzipped.
+ root_path (str): Path to new root.
+ project_name (str): Name of project.
"""
- print("Unpacking project from zip {}".format(path_to_zip))
- if not os.path.exists(path_to_zip):
- print("Zip file does not exists: {}".format(path_to_zip))
+
+ src_project_files_dir = os.path.join(
+ unzip_dir, PROJECT_FILES_DIR, project_name
+ )
+ # Skip if files are not in the zip
+ if not os.path.exists(src_project_files_dir):
return
- tmp_dir = tempfile.mkdtemp(prefix="unpack_")
- print("Zip is extracted to temp: {}".format(tmp_dir))
- with zipfile.ZipFile(path_to_zip, "r") as zip_stream:
- zip_stream.extractall(tmp_dir)
-
- metadata_json_path = os.path.join(tmp_dir, METADATA_FILE_NAME + ".json")
- with open(metadata_json_path, "r") as stream:
- metadata = json.load(stream)
-
- docs_json_path = os.path.join(tmp_dir, DOCUMENTS_FILE_NAME + ".json")
- with open(docs_json_path, "r") as stream:
- content = stream.readlines()
- docs = loads("".join(content))
-
- low_platform = platform.system().lower()
- project_name = metadata["project_name"]
- source_root = metadata["root"]
- root_path = source_root[low_platform]
-
- # Drop existing collection
- dbcon = AvalonMongoDB()
- database = dbcon.database
- if project_name in database.list_collection_names():
- database.drop_collection(project_name)
- print("Removed existing project collection")
-
- print("Creating project documents ({})".format(len(docs)))
- # Create new collection with loaded docs
- collection = database[project_name]
- collection.insert_many(docs)
-
- # Skip change of root if is the same as the one stored in metadata
- if (
- new_root
- and (os.path.normpath(new_root) == os.path.normpath(root_path))
- ):
- new_root = None
-
- if new_root:
- print("Using different root path {}".format(new_root))
- root_path = new_root
-
- project_doc = get_project(project_name)
- roots = project_doc["config"]["roots"]
- key = tuple(roots.keys())[0]
- update_key = "config.roots.{}.{}".format(key, low_platform)
- collection.update_one(
- {"_id": project_doc["_id"]},
- {"$set": {
- update_key: new_root
- }}
- )
-
# Make sure root path exists
if not os.path.exists(root_path):
os.makedirs(root_path)
- src_project_files_dir = os.path.join(
- tmp_dir, PROJECT_FILES_DIR, project_name
- )
dst_project_files_dir = os.path.normpath(
os.path.join(root_path, project_name)
)
@@ -241,8 +229,83 @@ def unpack_project(path_to_zip, new_root=None):
))
shutil.move(src_project_files_dir, dst_project_files_dir)
+
+def unpack_project(
+ path_to_zip, new_root=None, database_only=None, database_name=None
+):
+ """Unpack project zip file to recreate project.
+
+ Args:
+ path_to_zip (str): Path to zip which was created using 'pack_project'
+ function.
+ new_root (str): Optional way how to set different root path for
+ unpacked project.
+ database_only (Optional[bool]): Unpack only database from zip.
+ database_name (str): Name of database where project will be recreated.
+ """
+
+ if database_only is None:
+ database_only = False
+
+ print("Unpacking project from zip {}".format(path_to_zip))
+ if not os.path.exists(path_to_zip):
+ print("Zip file does not exists: {}".format(path_to_zip))
+ return
+
+ tmp_dir = tempfile.mkdtemp(prefix="unpack_")
+ print("Zip is extracted to temp: {}".format(tmp_dir))
+ with zipfile.ZipFile(path_to_zip, "r") as zip_stream:
+ if database_only:
+ for filename in (
+ "{}.json".format(METADATA_FILE_NAME),
+ "{}.json".format(DOCUMENTS_FILE_NAME),
+ ):
+ zip_stream.extract(filename, tmp_dir)
+ else:
+ zip_stream.extractall(tmp_dir)
+
+ metadata_json_path = os.path.join(tmp_dir, METADATA_FILE_NAME + ".json")
+ with open(metadata_json_path, "r") as stream:
+ metadata = json.load(stream)
+
+ docs_json_path = os.path.join(tmp_dir, DOCUMENTS_FILE_NAME + ".json")
+ docs = load_json_file(docs_json_path)
+
+ low_platform = platform.system().lower()
+ project_name = metadata["project_name"]
+ source_root = metadata["root"]
+ root_path = source_root[low_platform]
+
+ # Drop existing collection
+ replace_project_documents(project_name, docs, database_name)
+ print("Creating project documents ({})".format(len(docs)))
+
+ # Skip change of root if is the same as the one stored in metadata
+ if (
+ new_root
+ and (os.path.normpath(new_root) == os.path.normpath(root_path))
+ ):
+ new_root = None
+
+ if new_root:
+ print("Using different root path {}".format(new_root))
+ root_path = new_root
+
+ project_doc = get_project_document(project_name)
+ roots = project_doc["config"]["roots"]
+ key = tuple(roots.keys())[0]
+ update_key = "config.roots.{}.{}".format(key, low_platform)
+ collection = get_project_connection(project_name, database_name)
+ collection.update_one(
+ {"_id": project_doc["_id"]},
+ {"$set": {
+ update_key: new_root
+ }}
+ )
+
+ _unpack_project_files(tmp_dir, root_path, project_name)
+
# CLeanup
print("Cleaning up")
shutil.rmtree(tmp_dir)
- dbcon.uninstall()
print("*** Unpack finished ***")
diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
index f80bd40133..eeb813cb62 100644
--- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
@@ -438,7 +438,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"Finished copying %i files" % len(resource_files))
def _create_instances_for_aov(
- self, instance_data, exp_files, additional_data
+ self, instance_data, exp_files, additional_data, do_not_add_review
):
"""Create instance for each AOV found.
@@ -449,6 +449,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
instance_data (pyblish.plugin.Instance): skeleton data for instance
(those needed) later by collector
exp_files (list): list of expected files divided by aovs
+ additional_data (dict):
+ do_not_add_review (bool): explicitly skip review
Returns:
list of instances
@@ -514,8 +516,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
app = os.environ.get("AVALON_APP", "")
- preview = False
-
if isinstance(col, list):
render_file_name = os.path.basename(col[0])
else:
@@ -532,6 +532,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
new_instance = deepcopy(instance_data)
new_instance["subset"] = subset_name
new_instance["subsetGroup"] = group_name
+
+ preview = preview and not do_not_add_review
if preview:
new_instance["review"] = True
@@ -591,7 +593,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
self.log.debug("instances:{}".format(instances))
return instances
- def _get_representations(self, instance, exp_files):
+ def _get_representations(self, instance, exp_files, do_not_add_review):
"""Create representations for file sequences.
This will return representations of expected files if they are not
@@ -602,6 +604,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
instance (dict): instance data for which we are
setting representations
exp_files (list): list of expected files
+ do_not_add_review (bool): explicitly skip review
Returns:
list of representations
@@ -651,6 +654,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
if instance.get("slate"):
frame_start -= 1
+ preview = preview and not do_not_add_review
rep = {
"name": ext,
"ext": ext,
@@ -705,6 +709,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
preview = match_aov_pattern(
host_name, self.aov_filter, remainder
)
+ preview = preview and not do_not_add_review
if preview:
rep.update({
"fps": instance.get("fps"),
@@ -820,8 +825,12 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
families = [family]
# pass review to families if marked as review
+ do_not_add_review = False
if data.get("review"):
families.append("review")
+ elif data.get("review") == False:
+ self.log.debug("Instance has review explicitly disabled.")
+ do_not_add_review = True
instance_skeleton_data = {
"family": family,
@@ -977,7 +986,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
instances = self._create_instances_for_aov(
instance_skeleton_data,
data.get("expectedFiles"),
- additional_data
+ additional_data,
+ do_not_add_review
)
self.log.info("got {} instance{}".format(
len(instances),
@@ -986,7 +996,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
else:
representations = self._get_representations(
instance_skeleton_data,
- data.get("expectedFiles")
+ data.get("expectedFiles"),
+ do_not_add_review
)
if "representations" not in instance_skeleton_data.keys():
diff --git a/openpype/hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
similarity index 56%
rename from openpype/hooks/pre_copy_last_published_workfile.py
rename to openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
index 26b43c39cb..bbc220945c 100644
--- a/openpype/hooks/pre_copy_last_published_workfile.py
+++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
@@ -1,15 +1,20 @@
import os
import shutil
-from time import sleep
+
from openpype.client.entities import (
- get_last_version_by_subset_id,
get_representations,
- get_subsets,
+ get_project
)
+
from openpype.lib import PreLaunchHook
-from openpype.lib.local_settings import get_local_site_id
from openpype.lib.profiles_filtering import filter_profiles
-from openpype.pipeline.load.utils import get_representation_path
+from openpype.modules.sync_server.sync_server import (
+ download_last_published_workfile,
+)
+from openpype.pipeline.template_data import get_template_data
+from openpype.pipeline.workfile.path_resolving import (
+ get_workfile_template_key,
+)
from openpype.settings.lib import get_project_settings
@@ -22,7 +27,11 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
# Before `AddLastWorkfileToLaunchArgs`
order = -1
- app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"]
+ # any DCC could be used but TrayPublisher and other specials
+ app_groups = ["blender", "photoshop", "tvpaint", "aftereffects",
+ "nuke", "nukeassist", "nukex", "hiero", "nukestudio",
+ "maya", "harmony", "celaction", "flame", "fusion",
+ "houdini", "tvpaint"]
def execute(self):
"""Check if local workfile doesn't exist, else copy it.
@@ -31,11 +40,11 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
2- Check if workfile in work area doesn't exist
3- Check if published workfile exists and is copied locally in publish
4- Substitute copied published workfile as first workfile
+ with incremented version by +1
Returns:
None: This is a void method.
"""
-
sync_server = self.modules_manager.get("sync_server")
if not sync_server or not sync_server.enabled:
self.log.debug("Sync server module is not enabled or available")
@@ -53,6 +62,7 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
# Get data
project_name = self.data["project_name"]
+ asset_name = self.data["asset_name"]
task_name = self.data["task_name"]
task_type = self.data["task_type"]
host_name = self.application.host_name
@@ -68,6 +78,8 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
"hosts": host_name,
}
last_workfile_settings = filter_profiles(profiles, filter_data)
+ if not last_workfile_settings:
+ return
use_last_published_workfile = last_workfile_settings.get(
"use_last_published_workfile"
)
@@ -92,57 +104,27 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
)
return
+ max_retries = int((sync_server.sync_project_settings[project_name]
+ ["config"]
+ ["retry_cnt"]))
+
self.log.info("Trying to fetch last published workfile...")
- project_doc = self.data.get("project_doc")
asset_doc = self.data.get("asset_doc")
anatomy = self.data.get("anatomy")
- # Check it can proceed
- if not project_doc and not asset_doc:
- return
+ context_filters = {
+ "asset": asset_name,
+ "family": "workfile",
+ "task": {"name": task_name, "type": task_type}
+ }
- # Get subset id
- subset_id = next(
- (
- subset["_id"]
- for subset in get_subsets(
- project_name,
- asset_ids=[asset_doc["_id"]],
- fields=["_id", "data.family", "data.families"],
- )
- if subset["data"].get("family") == "workfile"
- # Legacy compatibility
- or "workfile" in subset["data"].get("families", {})
- ),
- None,
- )
- if not subset_id:
- self.log.debug(
- 'No any workfile for asset "{}".'.format(asset_doc["name"])
- )
- return
+ workfile_representations = list(get_representations(
+ project_name,
+ context_filters=context_filters
+ ))
- # Get workfile representation
- last_version_doc = get_last_version_by_subset_id(
- project_name, subset_id, fields=["_id"]
- )
- if not last_version_doc:
- self.log.debug("Subset does not have any versions")
- return
-
- workfile_representation = next(
- (
- representation
- for representation in get_representations(
- project_name, version_ids=[last_version_doc["_id"]]
- )
- if representation["context"]["task"]["name"] == task_name
- ),
- None,
- )
-
- if not workfile_representation:
+ if not workfile_representations:
self.log.debug(
'No published workfile for task "{}" and host "{}".'.format(
task_name, host_name
@@ -150,28 +132,55 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
)
return
- local_site_id = get_local_site_id()
- sync_server.add_site(
- project_name,
- workfile_representation["_id"],
- local_site_id,
- force=True,
- priority=99,
- reset_timer=True,
+ filtered_repres = filter(
+ lambda r: r["context"].get("version") is not None,
+ workfile_representations
)
-
- while not sync_server.is_representation_on_site(
- project_name, workfile_representation["_id"], local_site_id
- ):
- sleep(5)
-
- # Get paths
- published_workfile_path = get_representation_path(
- workfile_representation, root=anatomy.roots
+ workfile_representation = max(
+ filtered_repres, key=lambda r: r["context"]["version"]
)
- local_workfile_dir = os.path.dirname(last_workfile)
# Copy file and substitute path
- self.data["last_workfile_path"] = shutil.copy(
- published_workfile_path, local_workfile_dir
+ last_published_workfile_path = download_last_published_workfile(
+ host_name,
+ project_name,
+ task_name,
+ workfile_representation,
+ max_retries,
+ anatomy=anatomy
)
+ if not last_published_workfile_path:
+ self.log.debug(
+ "Couldn't download {}".format(last_published_workfile_path)
+ )
+ return
+
+ project_doc = self.data["project_doc"]
+
+ project_settings = self.data["project_settings"]
+ template_key = get_workfile_template_key(
+ task_name, host_name, project_name, project_settings
+ )
+
+ # Get workfile data
+ workfile_data = get_template_data(
+ project_doc, asset_doc, task_name, host_name
+ )
+
+ extension = last_published_workfile_path.split(".")[-1]
+ workfile_data["version"] = (
+ workfile_representation["context"]["version"] + 1)
+ workfile_data["ext"] = extension
+
+ anatomy_result = anatomy.format(workfile_data)
+ local_workfile_path = anatomy_result[template_key]["path"]
+
+ # Copy last published workfile to local workfile directory
+ shutil.copy(
+ last_published_workfile_path,
+ local_workfile_path,
+ )
+
+ self.data["last_workfile_path"] = local_workfile_path
+ # Keep source filepath for further path conformation
+ self.data["source_filepath"] = last_published_workfile_path
diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py
index 5b873a37cf..d1d5c2863d 100644
--- a/openpype/modules/sync_server/sync_server.py
+++ b/openpype/modules/sync_server/sync_server.py
@@ -3,10 +3,15 @@ import os
import asyncio
import threading
import concurrent.futures
-from concurrent.futures._base import CancelledError
+from time import sleep
from .providers import lib
+from openpype.client.entity_links import get_linked_representation_id
from openpype.lib import Logger
+from openpype.lib.local_settings import get_local_site_id
+from openpype.modules.base import ModulesManager
+from openpype.pipeline import Anatomy
+from openpype.pipeline.load.utils import get_representation_path_with_anatomy
from .utils import SyncStatus, ResumableError
@@ -189,6 +194,98 @@ def _site_is_working(module, project_name, site_name, site_config):
return handler.is_active()
+def download_last_published_workfile(
+ host_name: str,
+ project_name: str,
+ task_name: str,
+ workfile_representation: dict,
+ max_retries: int,
+ anatomy: Anatomy = None,
+) -> str:
+ """Download the last published workfile
+
+ Args:
+ host_name (str): Host name.
+ project_name (str): Project name.
+ task_name (str): Task name.
+ workfile_representation (dict): Workfile representation.
+ max_retries (int): complete file failure only after so many attempts
+ anatomy (Anatomy, optional): Anatomy (Used for optimization).
+ Defaults to None.
+
+ Returns:
+ str: last published workfile path localized
+ """
+
+ if not anatomy:
+ anatomy = Anatomy(project_name)
+
+ # Get sync server module
+ sync_server = ModulesManager().modules_by_name.get("sync_server")
+ if not sync_server or not sync_server.enabled:
+ print("Sync server module is disabled or unavailable.")
+ return
+
+ if not workfile_representation:
+ print(
+ "Not published workfile for task '{}' and host '{}'.".format(
+ task_name, host_name
+ )
+ )
+ return
+
+ last_published_workfile_path = get_representation_path_with_anatomy(
+ workfile_representation, anatomy
+ )
+ if (not last_published_workfile_path or
+ not os.path.exists(last_published_workfile_path)):
+ return
+
+ # If representation isn't available on remote site, then return.
+ if not sync_server.is_representation_on_site(
+ project_name,
+ workfile_representation["_id"],
+ sync_server.get_remote_site(project_name),
+ ):
+ print(
+ "Representation for task '{}' and host '{}'".format(
+ task_name, host_name
+ )
+ )
+ return
+
+ # Get local site
+ local_site_id = get_local_site_id()
+
+ # Add workfile representation to local site
+ representation_ids = {workfile_representation["_id"]}
+ representation_ids.update(
+ get_linked_representation_id(
+ project_name, repre_id=workfile_representation["_id"]
+ )
+ )
+ for repre_id in representation_ids:
+ if not sync_server.is_representation_on_site(project_name, repre_id,
+ local_site_id):
+ sync_server.add_site(
+ project_name,
+ repre_id,
+ local_site_id,
+ force=True,
+ priority=99
+ )
+ sync_server.reset_timer()
+ print("Starting to download:{}".format(last_published_workfile_path))
+ # While representation unavailable locally, wait.
+ while not sync_server.is_representation_on_site(
+ project_name, workfile_representation["_id"], local_site_id,
+ max_retries=max_retries
+ ):
+ sleep(5)
+
+ return last_published_workfile_path
+
+
class SyncServerThread(threading.Thread):
"""
Separate thread running synchronization server with asyncio loop.
@@ -358,7 +455,6 @@ class SyncServerThread(threading.Thread):
duration = time.time() - start_time
self.log.debug("One loop took {:.2f}s".format(duration))
-
delay = self.module.get_loop_delay(project_name)
self.log.debug(
"Waiting for {} seconds to new loop".format(delay)
@@ -370,8 +466,8 @@ class SyncServerThread(threading.Thread):
self.log.warning(
"ConnectionResetError in sync loop, trying next loop",
exc_info=True)
- except CancelledError:
- # just stopping server
+ except asyncio.exceptions.CancelledError:
+ # cancelling timer
pass
except ResumableError:
self.log.warning(
diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py
index 5a4fa07e98..b85b045bd9 100644
--- a/openpype/modules/sync_server/sync_server_module.py
+++ b/openpype/modules/sync_server/sync_server_module.py
@@ -838,6 +838,18 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return ret_dict
+ def get_launch_hook_paths(self):
+ """Implementation for applications launch hooks.
+
+ Returns:
+ (str): full absolut path to directory with hooks for the module
+ """
+
+ return os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "launch_hooks"
+ )
+
# Needs to be refactored after Settings are updated
# # Methods for Settings to get appriate values to fill forms
# def get_configurable_items(self, scope=None):
@@ -1045,9 +1057,23 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
self.sync_server_thread.reset_timer()
def is_representation_on_site(
- self, project_name, representation_id, site_name
+ self, project_name, representation_id, site_name, max_retries=None
):
- """Checks if 'representation_id' has all files avail. on 'site_name'"""
+ """Checks if 'representation_id' has all files avail. on 'site_name'
+
+ Args:
+ project_name (str)
+ representation_id (str)
+ site_name (str)
+ max_retries (int) (optional) - provide only if method used in while
+ loop to bail out
+ Returns:
+ (bool): True if 'representation_id' has all files correctly on the
+ 'site_name'
+ Raises:
+ (ValueError) Only If 'max_retries' provided if upload/download
+ failed too many times to limit infinite loop check.
+ """
representation = get_representation_by_id(project_name,
representation_id,
fields=["_id", "files"])
@@ -1060,6 +1086,11 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
if site["name"] != site_name:
continue
+ if max_retries:
+ tries = self._get_tries_count_from_rec(site)
+ if tries >= max_retries:
+ raise ValueError("Failed too many times")
+
if (site.get("progress") or site.get("error") or
not site.get("created_dt")):
return False
diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py
index 7a2ef59a5a..d656d58adc 100644
--- a/openpype/pipeline/__init__.py
+++ b/openpype/pipeline/__init__.py
@@ -1,5 +1,6 @@
from .constants import (
AVALON_CONTAINER_ID,
+ AYON_CONTAINER_ID,
HOST_WORKFILE_EXTENSIONS,
)
@@ -99,6 +100,7 @@ uninstall = uninstall_host
__all__ = (
"AVALON_CONTAINER_ID",
+ "AYON_CONTAINER_ID",
"HOST_WORKFILE_EXTENSIONS",
# --- MongoDB ---
diff --git a/openpype/pipeline/constants.py b/openpype/pipeline/constants.py
index e6496cbf95..755a5fb380 100644
--- a/openpype/pipeline/constants.py
+++ b/openpype/pipeline/constants.py
@@ -1,5 +1,5 @@
# Metadata ID of loaded container into scene
-AVALON_CONTAINER_ID = "pyblish.avalon.container"
+AVALON_CONTAINER_ID = AYON_CONTAINER_ID = "pyblish.avalon.container"
# TODO get extensions from host implementations
HOST_WORKFILE_EXTENSIONS = {
diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py
index ccb2415346..fd35ddb719 100644
--- a/openpype/pipeline/publish/abstract_collect_render.py
+++ b/openpype/pipeline/publish/abstract_collect_render.py
@@ -58,7 +58,7 @@ class RenderInstance(object):
# With default values
# metadata
renderer = attr.ib(default="") # renderer - can be used in Deadline
- review = attr.ib(default=False) # generate review from instance (bool)
+ review = attr.ib(default=None) # False - explicitly skip review
priority = attr.ib(default=50) # job priority on farm
family = attr.ib(default="renderlayer")
diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py
index 265a9c7822..8b6212b3ef 100644
--- a/openpype/pipeline/publish/lib.py
+++ b/openpype/pipeline/publish/lib.py
@@ -7,6 +7,7 @@ import tempfile
import xml.etree.ElementTree
import six
+import pyblish.util
import pyblish.plugin
import pyblish.api
diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py
index 331235fadc..a38896ec8e 100644
--- a/openpype/pipeline/publish/publish_plugins.py
+++ b/openpype/pipeline/publish/publish_plugins.py
@@ -45,7 +45,7 @@ class PublishValidationError(Exception):
def __init__(self, message, title=None, description=None, detail=None):
self.message = message
- self.title = title or "< Missing title >"
+ self.title = title
self.description = description or message
self.detail = detail
super(PublishValidationError, self).__init__(message)
diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py
index aa5497a99f..54b933a76d 100644
--- a/openpype/plugins/publish/extract_thumbnail.py
+++ b/openpype/plugins/publish/extract_thumbnail.py
@@ -19,9 +19,9 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
order = pyblish.api.ExtractorOrder
families = [
"imagesequence", "render", "render2d", "prerender",
- "source", "clip", "take", "online"
+ "source", "clip", "take", "online", "image"
]
- hosts = ["shell", "fusion", "resolve", "traypublisher"]
+ hosts = ["shell", "fusion", "resolve", "traypublisher", "substancepainter"]
enabled = False
# presetable attribute
diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py
index 65ce30412c..8e984a9e97 100644
--- a/openpype/plugins/publish/integrate.py
+++ b/openpype/plugins/publish/integrate.py
@@ -163,6 +163,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"Instance is marked to be processed on farm. Skipping")
return
+ # Instance is marked to not get integrated
+ if not instance.data.get("integrate", True):
+ self.log.info("Instance is marked to skip integrating. Skipping")
+ return
+
filtered_repres = self.filter_representations(instance)
# Skip instance if there are not representations to integrate
# all representations should not be integrated
diff --git a/openpype/plugins/publish/validate_sequence_frames.py b/openpype/plugins/publish/validate_sequence_frames.py
index 0dba99b07c..239008ee21 100644
--- a/openpype/plugins/publish/validate_sequence_frames.py
+++ b/openpype/plugins/publish/validate_sequence_frames.py
@@ -49,7 +49,12 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin):
collection = collections[0]
frames = list(collection.indexes)
+ if instance.data.get("slate"):
+ # Slate is not part of the frame range
+ frames = frames[1:]
+
current_range = (frames[0], frames[-1])
+
required_range = (instance.data["frameStart"],
instance.data["frameEnd"])
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index dc5b3d63c3..6a24cb0ebc 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -353,12 +353,12 @@ class PypeCommands:
version_packer = VersionRepacker(directory)
version_packer.process()
- def pack_project(self, project_name, dirpath):
+ def pack_project(self, project_name, dirpath, database_only):
from openpype.lib.project_backpack import pack_project
- pack_project(project_name, dirpath)
+ pack_project(project_name, dirpath, database_only)
- def unpack_project(self, zip_filepath, new_root):
+ def unpack_project(self, zip_filepath, new_root, database_only):
from openpype.lib.project_backpack import unpack_project
- unpack_project(zip_filepath, new_root)
+ unpack_project(zip_filepath, new_root, database_only)
diff --git a/openpype/resources/app_icons/substancepainter.png b/openpype/resources/app_icons/substancepainter.png
new file mode 100644
index 0000000000..dc46f25d74
Binary files /dev/null and b/openpype/resources/app_icons/substancepainter.png differ
diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json
index 669e1db0b8..6128534344 100644
--- a/openpype/settings/defaults/project_settings/aftereffects.json
+++ b/openpype/settings/defaults/project_settings/aftereffects.json
@@ -13,10 +13,14 @@
"RenderCreator": {
"defaults": [
"Main"
- ]
+ ],
+ "mark_for_review": true
}
},
"publish": {
+ "CollectReview": {
+ "enabled": true
+ },
"ValidateSceneSettings": {
"enabled": true,
"optional": true,
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index 12223216cd..72b330ce7a 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -1460,7 +1460,8 @@
},
"reference_loader": {
"namespace": "{asset_name}_{subset}_##_",
- "group_name": "_GRP"
+ "group_name": "_GRP",
+ "display_handle": true
}
},
"workfile_build": {
diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json
index bcf21f55dd..2454691958 100644
--- a/openpype/settings/defaults/project_settings/photoshop.json
+++ b/openpype/settings/defaults/project_settings/photoshop.json
@@ -10,23 +10,40 @@
}
},
"create": {
- "CreateImage": {
- "defaults": [
+ "ImageCreator": {
+ "enabled": true,
+ "active_on_create": true,
+ "mark_for_review": false,
+ "default_variants": [
"Main"
]
+ },
+ "AutoImageCreator": {
+ "enabled": false,
+ "active_on_create": true,
+ "mark_for_review": false,
+ "default_variant": ""
+ },
+ "ReviewCreator": {
+ "enabled": true,
+ "active_on_create": true,
+ "default_variant": ""
+ },
+ "WorkfileCreator": {
+ "enabled": true,
+ "active_on_create": true,
+ "default_variant": "Main"
}
},
"publish": {
"CollectColorCodedInstances": {
+ "enabled": true,
"create_flatten_image": "no",
"flatten_subset_template": "",
"color_code_mapping": []
},
- "CollectInstances": {
- "flatten_subset_template": ""
- },
"CollectReview": {
- "publish": true
+ "enabled": true
},
"CollectVersion": {
"enabled": false
diff --git a/openpype/settings/defaults/project_settings/substancepainter.json b/openpype/settings/defaults/project_settings/substancepainter.json
new file mode 100644
index 0000000000..60929e85fd
--- /dev/null
+++ b/openpype/settings/defaults/project_settings/substancepainter.json
@@ -0,0 +1,13 @@
+{
+ "imageio": {
+ "ocio_config": {
+ "enabled": true,
+ "filepath": []
+ },
+ "file_rules": {
+ "enabled": true,
+ "rules": {}
+ }
+ },
+ "shelves": {}
+}
diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json
index 75cee11bd9..737a17d289 100644
--- a/openpype/settings/defaults/project_settings/unreal.json
+++ b/openpype/settings/defaults/project_settings/unreal.json
@@ -11,6 +11,9 @@
},
"level_sequences_for_layouts": false,
"delete_unmatched_assets": false,
+ "render_config_path": "",
+ "preroll_frames": 0,
+ "render_format": "png",
"project_setup": {
"dev_mode": true
}
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index df5b5e07c6..b492bb9321 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -1479,6 +1479,33 @@
}
}
},
+ "substancepainter": {
+ "enabled": true,
+ "label": "Substance Painter",
+ "icon": "app_icons/substancepainter.png",
+ "host_name": "substancepainter",
+ "environment": {},
+ "variants": {
+ "8-2-0": {
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Adobe\\Adobe Substance 3D Painter\\Adobe Substance 3D Painter.exe"
+ ],
+ "darwin": [],
+ "linux": []
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": {}
+ },
+ "__dynamic_keys_labels__": {
+ "8-2-0": "8.2.0"
+ }
+ }
+ },
"unreal": {
"enabled": true,
"label": "Unreal Editor",
diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py
index c0c103ea10..de3bd353eb 100644
--- a/openpype/settings/entities/enum_entity.py
+++ b/openpype/settings/entities/enum_entity.py
@@ -168,6 +168,7 @@ class HostsEnumEntity(BaseEnumEntity):
"tvpaint",
"unreal",
"standalonepublisher",
+ "substancepainter",
"traypublisher",
"webpublisher"
]
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json
index 8c1d8ccbdd..4315987a33 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_main.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json
@@ -122,6 +122,10 @@
"type": "schema",
"name": "schema_project_photoshop"
},
+ {
+ "type": "schema",
+ "name": "schema_project_substancepainter"
+ },
{
"type": "schema",
"name": "schema_project_harmony"
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json
index 8dc83f5506..313e0ce8ea 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json
@@ -40,7 +40,13 @@
"label": "Default Variants",
"object_type": "text",
"docstring": "Fill default variant(s) (like 'Main' or 'Default') used in subset name creation."
- }
+ },
+ {
+ "type": "boolean",
+ "key": "mark_for_review",
+ "label": "Review",
+ "default": true
+ }
]
}
]
@@ -51,6 +57,21 @@
"key": "publish",
"label": "Publish plugins",
"children": [
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "CollectReview",
+ "label": "Collect Review",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled",
+ "default": true
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
index 0071e632af..f6c46aba8b 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
@@ -31,16 +31,126 @@
{
"type": "dict",
"collapsible": true,
- "key": "CreateImage",
+ "key": "ImageCreator",
"label": "Create Image",
+ "checkbox_key": "enabled",
"children": [
+ {
+ "type": "label",
+ "label": "Manually create instance from layer or group of layers. \n Separate review could be created for this image to be sent to Asset Management System."
+ },
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "boolean",
+ "key": "active_on_create",
+ "label": "Active by default"
+ },
+ {
+ "type": "boolean",
+ "key": "mark_for_review",
+ "label": "Review by default"
+ },
{
"type": "list",
- "key": "defaults",
- "label": "Default Subsets",
+ "key": "default_variants",
+ "label": "Default Variants",
"object_type": "text"
}
]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "AutoImageCreator",
+ "label": "Create Flatten Image",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "label",
+ "label": "Auto create image for all visible layers, used for simplified processing. \n Separate review could be created for this image to be sent to Asset Management System."
+ },
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "boolean",
+ "key": "active_on_create",
+ "label": "Active by default"
+ },
+ {
+ "type": "boolean",
+ "key": "mark_for_review",
+ "label": "Review by default"
+ },
+ {
+ "type": "text",
+ "key": "default_variant",
+ "label": "Default variant"
+ }
+ ]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "ReviewCreator",
+ "label": "Create Review",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "label",
+ "label": "Auto create review instance containing all published image instances or visible layers if no image instance."
+ },
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled",
+ "default": true
+ },
+ {
+ "type": "boolean",
+ "key": "active_on_create",
+ "label": "Active by default"
+ },
+ {
+ "type": "text",
+ "key": "default_variant",
+ "label": "Default variant"
+ }
+ ]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "WorkfileCreator",
+ "label": "Create Workfile",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "label",
+ "label": "Auto create workfile instance"
+ },
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "boolean",
+ "key": "active_on_create",
+ "label": "Active by default"
+ },
+ {
+ "type": "text",
+ "key": "default_variant",
+ "label": "Default variant"
+ }
+ ]
}
]
},
@@ -56,11 +166,18 @@
"is_group": true,
"key": "CollectColorCodedInstances",
"label": "Collect Color Coded Instances",
+ "checkbox_key": "enabled",
"children": [
{
"type": "label",
"label": "Set color for publishable layers, set its resulting family and template for subset name. \nCan create flatten image from published instances.(Applicable only for remote publishing!)"
},
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled",
+ "default": true
+ },
{
"key": "create_flatten_image",
"label": "Create flatten image",
@@ -131,40 +248,26 @@
}
]
},
- {
- "type": "dict",
- "collapsible": true,
- "key": "CollectInstances",
- "label": "Collect Instances",
- "children": [
- {
- "type": "label",
- "label": "Name for flatten image created if no image instance present"
- },
- {
- "type": "text",
- "key": "flatten_subset_template",
- "label": "Subset template for flatten image"
- }
- ]
- },
{
"type": "dict",
"collapsible": true,
"key": "CollectReview",
"label": "Collect Review",
+ "checkbox_key": "enabled",
"children": [
{
"type": "boolean",
- "key": "publish",
- "label": "Active"
- }
- ]
+ "key": "enabled",
+ "label": "Enabled",
+ "default": true
+ }
+ ]
},
{
"type": "dict",
"key": "CollectVersion",
"label": "Collect Version",
+ "checkbox_key": "enabled",
"children": [
{
"type": "label",
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json b/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json
new file mode 100644
index 0000000000..79a39b8e6e
--- /dev/null
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json
@@ -0,0 +1,35 @@
+{
+ "type": "dict",
+ "collapsible": true,
+ "key": "substancepainter",
+ "label": "Substance Painter",
+ "is_file": true,
+ "children": [
+ {
+ "key": "imageio",
+ "type": "dict",
+ "label": "Color Management (ImageIO)",
+ "is_group": true,
+ "children": [
+ {
+ "type": "schema",
+ "name": "schema_imageio_config"
+ },
+ {
+ "type": "schema",
+ "name": "schema_imageio_file_rules"
+ }
+
+ ]
+ },
+ {
+ "type": "dict-modifiable",
+ "key": "shelves",
+ "label": "Shelves",
+ "use_label_wrap": true,
+ "object_type": {
+ "type": "text"
+ }
+ }
+ ]
+}
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json
index 8988dd2ff0..35eb0b24f1 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json
@@ -32,6 +32,28 @@
"key": "delete_unmatched_assets",
"label": "Delete assets that are not matched"
},
+ {
+ "type": "text",
+ "key": "render_config_path",
+ "label": "Render Config Path"
+ },
+ {
+ "type": "number",
+ "key": "preroll_frames",
+ "label": "Pre-roll frames"
+ },
+ {
+ "key": "render_format",
+ "label": "Render format",
+ "type": "enum",
+ "multiselection": false,
+ "enum_items": [
+ {"png": "PNG"},
+ {"exr": "EXR"},
+ {"jpg": "JPG"},
+ {"bmp": "BMP"}
+ ]
+ },
{
"type": "dict",
"collapsible": true,
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
index c1895c4824..4b6b97ab4e 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json
@@ -111,6 +111,14 @@
{
"type": "label",
"label": "Here's a link to the doc where you can find explanations about customing the naming of referenced assets: https://openpype.io/docs/admin_hosts_maya#load-plugins"
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "type": "boolean",
+ "key": "display_handle",
+ "label": "Display Handle On Load References"
}
]
}
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_substancepainter.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_substancepainter.json
new file mode 100644
index 0000000000..fb3b21e63f
--- /dev/null
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_substancepainter.json
@@ -0,0 +1,40 @@
+{
+ "type": "dict",
+ "key": "substancepainter",
+ "label": "Substance Painter",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "schema_template",
+ "name": "template_host_unchangables"
+ },
+ {
+ "key": "environment",
+ "label": "Environment",
+ "type": "raw-json"
+ },
+ {
+ "type": "dict-modifiable",
+ "key": "variants",
+ "collapsible_key": true,
+ "use_label_wrap": false,
+ "object_type": {
+ "type": "dict",
+ "collapsible": true,
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_host_variant_items",
+ "skip_paths": ["use_python_2"]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json
index b17687cf71..abea37a9ab 100644
--- a/openpype/settings/entities/schemas/system_schema/schema_applications.json
+++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json
@@ -93,6 +93,10 @@
"type": "schema",
"name": "schema_celaction"
},
+ {
+ "type": "schema",
+ "name": "schema_substancepainter"
+ },
{
"type": "schema",
"name": "schema_unreal"
diff --git a/openpype/style/data.json b/openpype/style/data.json
index 404ca6944c..bea2a3d407 100644
--- a/openpype/style/data.json
+++ b/openpype/style/data.json
@@ -48,7 +48,7 @@
"bg-view-selection-hover": "rgba(92, 173, 214, .8)",
"border": "#373D48",
- "border-hover": "rgba(168, 175, 189, .3)",
+ "border-hover": "rgb(92, 99, 111)",
"border-focus": "rgb(92, 173, 214)",
"restart-btn-bg": "#458056",
diff --git a/openpype/style/style.css b/openpype/style/style.css
index da477eeefa..827b103f94 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -35,6 +35,11 @@ QWidget:disabled {
color: {color:font-disabled};
}
+/* Some DCCs have set borders to solid color */
+QScrollArea {
+ border: none;
+}
+
QLabel {
background: transparent;
}
@@ -42,7 +47,7 @@ QLabel {
/* Inputs */
QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
border: 1px solid {color:border};
- border-radius: 0.3em;
+ border-radius: 0.2em;
background: {color:bg-inputs};
padding: 0.1em;
}
@@ -127,6 +132,7 @@ QPushButton {
border-radius: 0.2em;
padding: 3px 5px 3px 5px;
background: {color:bg-buttons};
+ min-width: 0px; /* Substance Painter fix */
}
QPushButton:hover {
@@ -226,7 +232,7 @@ QMenu::separator {
/* Combobox */
QComboBox {
border: 1px solid {color:border};
- border-radius: 3px;
+ border-radius: 0.2em;
padding: 1px 3px 1px 3px;
background: {color:bg-inputs};
}
@@ -332,7 +338,15 @@ QTabWidget::tab-bar {
alignment: left;
}
+/* avoid QTabBar overrides in Substance Painter */
+QTabBar {
+ text-transform: none;
+ font-weight: normal;
+}
+
QTabBar::tab {
+ text-transform: none;
+ font-weight: normal;
border-top: 1px solid {color:border};
border-left: 1px solid {color:border};
border-right: 1px solid {color:border};
@@ -372,6 +386,7 @@ QHeaderView {
QHeaderView::section {
background: {color:bg-view-header};
padding: 4px;
+ border-top: 0px; /* Substance Painter fix */
border-right: 1px solid {color:bg-view};
border-radius: 0px;
text-align: center;
@@ -474,7 +489,6 @@ QAbstractItemView:disabled{
}
QAbstractItemView::item:hover {
- /* color: {color:bg-view-hover}; */
background: {color:bg-view-hover};
}
@@ -743,7 +757,7 @@ OverlayMessageWidget QWidget {
#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor {
background: transparent;
- border-radius: 0.3em;
+ border-radius: 0.2em;
}
#TypeEditor:focus, #ToolEditor:focus, #NameEditor:focus, #NumberEditor:focus {
@@ -860,7 +874,13 @@ OverlayMessageWidget QWidget {
background: {color:bg-view-hover};
}
-/* New Create/Publish UI */
+/* Publisher UI (Create/Publish) */
+#PublishWindow QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
+ padding: 1px;
+}
+#PublishWindow QComboBox {
+ padding: 1px 1px 1px 0.2em;
+}
PublisherTabsWidget {
background: {color:publisher:tab-bg};
}
@@ -944,6 +964,7 @@ PixmapButton:disabled {
border-top-left-radius: 0px;
padding-top: 0.5em;
padding-bottom: 0.5em;
+ width: 0.5em;
}
#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover {
border-color: {color:publisher:success};
@@ -1072,7 +1093,7 @@ ValidationArtistMessage QLabel {
#AssetNameInputWidget {
background: {color:bg-inputs};
border: 1px solid {color:border};
- border-radius: 0.3em;
+ border-radius: 0.2em;
}
#AssetNameInputWidget QWidget {
@@ -1465,6 +1486,12 @@ CreateNextPageOverlay {
}
/* Attribute Definition widgets */
+AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
+ padding: 1px;
+}
+AttributeDefinitionsWidget QComboBox {
+ padding: 1px 1px 1px 0.2em;
+}
InViewButton, InViewButton:disabled {
background: transparent;
}
diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py
index 0d4e1e88a9..d46c238da1 100644
--- a/openpype/tools/attribute_defs/widgets.py
+++ b/openpype/tools/attribute_defs/widgets.py
@@ -1,4 +1,3 @@
-import uuid
import copy
from qtpy import QtWidgets, QtCore
@@ -126,7 +125,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
row = 0
for attr_def in attr_defs:
- if not isinstance(attr_def, UIDef):
+ if attr_def.is_value_def:
if attr_def.key in self._current_keys:
raise KeyError(
"Duplicated key \"{}\"".format(attr_def.key))
@@ -144,11 +143,16 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
- if attr_def.label:
+ if attr_def.is_value_def and attr_def.label:
label_widget = QtWidgets.QLabel(attr_def.label, self)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
+ if attr_def.is_label_horizontal:
+ label_widget.setAlignment(
+ QtCore.Qt.AlignRight
+ | QtCore.Qt.AlignVCenter
+ )
layout.addWidget(
label_widget, row, 0, 1, expand_cols
)
diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py
index 14671e341f..e5d8400031 100644
--- a/openpype/tools/loader/model.py
+++ b/openpype/tools/loader/model.py
@@ -123,7 +123,7 @@ class BaseRepresentationModel(object):
self.remote_provider = remote_provider
-class SubsetsModel(TreeModel, BaseRepresentationModel):
+class SubsetsModel(BaseRepresentationModel, TreeModel):
doc_fetched = QtCore.Signal()
refreshed = QtCore.Signal(bool)
diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py
index 5d23886aa8..660fccecf1 100644
--- a/openpype/tools/publisher/constants.py
+++ b/openpype/tools/publisher/constants.py
@@ -2,7 +2,7 @@ from qtpy import QtCore, QtGui
# ID of context item in instance view
CONTEXT_ID = "context"
-CONTEXT_LABEL = "Options"
+CONTEXT_LABEL = "Context"
# Not showed anywhere - used as identifier
CONTEXT_GROUP = "__ContextGroup__"
@@ -15,6 +15,9 @@ VARIANT_TOOLTIP = (
"\nnumerical characters (0-9) dot (\".\") or underscore (\"_\")."
)
+INPUTS_LAYOUT_HSPACING = 4
+INPUTS_LAYOUT_VSPACING = 2
+
# Roles for instance views
INSTANCE_ID_ROLE = QtCore.Qt.UserRole + 1
SORT_VALUE_ROLE = QtCore.Qt.UserRole + 2
diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py
index 7754e4aa02..4b083d4bc8 100644
--- a/openpype/tools/publisher/control.py
+++ b/openpype/tools/publisher/control.py
@@ -163,7 +163,7 @@ class AssetDocsCache:
return copy.deepcopy(self._full_asset_docs_by_name[asset_name])
-class PublishReport:
+class PublishReportMaker:
"""Report for single publishing process.
Report keeps current state of publishing and currently processed plugin.
@@ -784,6 +784,13 @@ class PublishValidationErrors:
# Make sure the cached report is cleared
plugin_id = self._plugins_proxy.get_plugin_id(plugin)
+ if not error.title:
+ if hasattr(plugin, "label") and plugin.label:
+ plugin_label = plugin.label
+ else:
+ plugin_label = plugin.__name__
+ error.title = plugin_label
+
self._error_items.append(
ValidationErrorItem.from_result(plugin_id, error, instance)
)
@@ -1674,7 +1681,7 @@ class PublisherController(BasePublisherController):
# pyblish.api.Context
self._publish_context = None
# Pyblish report
- self._publish_report = PublishReport(self)
+ self._publish_report = PublishReportMaker(self)
# Store exceptions of validation error
self._publish_validation_errors = PublishValidationErrors()
diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py
index 3c559af259..a750d8d540 100644
--- a/openpype/tools/publisher/widgets/assets_widget.py
+++ b/openpype/tools/publisher/widgets/assets_widget.py
@@ -211,6 +211,10 @@ class AssetsDialog(QtWidgets.QDialog):
layout.addWidget(asset_view, 1)
layout.addLayout(btns_layout, 0)
+ controller.event_system.add_callback(
+ "controller.reset.finished", self._on_controller_reset
+ )
+
asset_view.double_clicked.connect(self._on_ok_clicked)
filter_input.textChanged.connect(self._on_filter_change)
ok_btn.clicked.connect(self._on_ok_clicked)
@@ -245,6 +249,10 @@ class AssetsDialog(QtWidgets.QDialog):
new_pos.setY(new_pos.y() - int(self.height() / 2))
self.move(new_pos)
+ def _on_controller_reset(self):
+ # Change reset enabled so model is reset on show event
+ self._soft_reset_enabled = True
+
def showEvent(self, event):
"""Refresh asset model on show."""
super(AssetsDialog, self).showEvent(event)
diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py
index 0734e1bc27..13715bc73c 100644
--- a/openpype/tools/publisher/widgets/card_view_widgets.py
+++ b/openpype/tools/publisher/widgets/card_view_widgets.py
@@ -9,7 +9,7 @@ Only one item can be selected at a time.
```
: Icon. Can have Warning icon when context is not right
┌──────────────────────┐
-│ Options │
+│ Context │
│ ────────── │
│ [x]│
│ [x]│
@@ -202,7 +202,7 @@ class ConvertorItemsGroupWidget(BaseGroupWidget):
class InstanceGroupWidget(BaseGroupWidget):
"""Widget wrapping instances under group."""
- active_changed = QtCore.Signal()
+ active_changed = QtCore.Signal(str, str, bool)
def __init__(self, group_icons, *args, **kwargs):
super(InstanceGroupWidget, self).__init__(*args, **kwargs)
@@ -253,13 +253,16 @@ class InstanceGroupWidget(BaseGroupWidget):
instance, group_icon, self
)
widget.selected.connect(self._on_widget_selection)
- widget.active_changed.connect(self.active_changed)
+ widget.active_changed.connect(self._on_active_changed)
self._widgets_by_id[instance.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1
self._update_ordered_item_ids()
+ def _on_active_changed(self, instance_id, value):
+ self.active_changed.emit(self.group_name, instance_id, value)
+
class CardWidget(BaseClickableFrame):
"""Clickable card used as bigger button."""
@@ -332,7 +335,7 @@ class ContextCardWidget(CardWidget):
icon_layout.addWidget(icon_widget)
layout = QtWidgets.QHBoxLayout(self)
- layout.setContentsMargins(0, 5, 10, 5)
+ layout.setContentsMargins(0, 2, 10, 2)
layout.addLayout(icon_layout, 0)
layout.addWidget(label_widget, 1)
@@ -363,7 +366,7 @@ class ConvertorItemCardWidget(CardWidget):
icon_layout.addWidget(icon_widget)
layout = QtWidgets.QHBoxLayout(self)
- layout.setContentsMargins(0, 5, 10, 5)
+ layout.setContentsMargins(0, 2, 10, 2)
layout.addLayout(icon_layout, 0)
layout.addWidget(label_widget, 1)
@@ -377,7 +380,7 @@ class ConvertorItemCardWidget(CardWidget):
class InstanceCardWidget(CardWidget):
"""Card widget representing instance."""
- active_changed = QtCore.Signal()
+ active_changed = QtCore.Signal(str, bool)
def __init__(self, instance, group_icon, parent):
super(InstanceCardWidget, self).__init__(parent)
@@ -424,7 +427,7 @@ class InstanceCardWidget(CardWidget):
top_layout.addWidget(expand_btn, 0)
layout = QtWidgets.QHBoxLayout(self)
- layout.setContentsMargins(0, 5, 10, 5)
+ layout.setContentsMargins(0, 2, 10, 2)
layout.addLayout(top_layout)
layout.addWidget(detail_widget)
@@ -445,6 +448,10 @@ class InstanceCardWidget(CardWidget):
def set_active_toggle_enabled(self, enabled):
self._active_checkbox.setEnabled(enabled)
+ @property
+ def is_active(self):
+ return self._active_checkbox.isChecked()
+
def set_active(self, new_value):
"""Set instance as active."""
checkbox_value = self._active_checkbox.isChecked()
@@ -515,7 +522,7 @@ class InstanceCardWidget(CardWidget):
return
self.instance["active"] = new_value
- self.active_changed.emit()
+ self.active_changed.emit(self._id, new_value)
def _on_expend_clicked(self):
self._set_expanded()
@@ -584,6 +591,45 @@ class InstanceCardView(AbstractInstanceView):
result.setWidth(width)
return result
+ def _toggle_instances(self, value):
+ if not self._active_toggle_enabled:
+ return
+
+ widgets = self._get_selected_widgets()
+ changed = False
+ for widget in widgets:
+ if not isinstance(widget, InstanceCardWidget):
+ continue
+
+ is_active = widget.is_active
+ if value == -1:
+ widget.set_active(not is_active)
+ changed = True
+ continue
+
+ _value = bool(value)
+ if is_active is not _value:
+ widget.set_active(_value)
+ changed = True
+
+ if changed:
+ self.active_changed.emit()
+
+ def keyPressEvent(self, event):
+ if event.key() == QtCore.Qt.Key_Space:
+ self._toggle_instances(-1)
+ return True
+
+ elif event.key() == QtCore.Qt.Key_Backspace:
+ self._toggle_instances(0)
+ return True
+
+ elif event.key() == QtCore.Qt.Key_Return:
+ self._toggle_instances(1)
+ return True
+
+ return super(InstanceCardView, self).keyPressEvent(event)
+
def _get_selected_widgets(self):
output = []
if (
@@ -742,7 +788,15 @@ class InstanceCardView(AbstractInstanceView):
for widget in self._widgets_by_group.values():
widget.update_instance_values()
- def _on_active_changed(self):
+ def _on_active_changed(self, group_name, instance_id, value):
+ group_widget = self._widgets_by_group[group_name]
+ instance_widget = group_widget.get_widget_by_item_id(instance_id)
+ if instance_widget.is_selected:
+ for widget in self._get_selected_widgets():
+ if isinstance(widget, InstanceCardWidget):
+ widget.set_active(value)
+ else:
+ self._select_item_clear(instance_id, group_name, instance_widget)
self.active_changed.emit()
def _on_widget_selection(self, instance_id, group_name, selection_type):
diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py
index ef9c5b98fe..30980af03d 100644
--- a/openpype/tools/publisher/widgets/create_widget.py
+++ b/openpype/tools/publisher/widgets/create_widget.py
@@ -22,6 +22,8 @@ from ..constants import (
CREATOR_IDENTIFIER_ROLE,
CREATOR_THUMBNAIL_ENABLED_ROLE,
CREATOR_SORT_ROLE,
+ INPUTS_LAYOUT_HSPACING,
+ INPUTS_LAYOUT_VSPACING,
)
SEPARATORS = ("---separator---", "---")
@@ -198,6 +200,8 @@ class CreateWidget(QtWidgets.QWidget):
variant_subset_layout = QtWidgets.QFormLayout(variant_subset_widget)
variant_subset_layout.setContentsMargins(0, 0, 0, 0)
+ variant_subset_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
+ variant_subset_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
variant_subset_layout.addRow("Variant", variant_widget)
variant_subset_layout.addRow("Subset", subset_name_input)
@@ -282,6 +286,9 @@ class CreateWidget(QtWidgets.QWidget):
thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create)
thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear)
+ controller.event_system.add_callback(
+ "main.window.closed", self._on_main_window_close
+ )
controller.event_system.add_callback(
"plugins.refresh.finished", self._on_plugins_refresh
)
@@ -316,6 +323,10 @@ class CreateWidget(QtWidgets.QWidget):
self._first_show = True
self._last_thumbnail_path = None
+ self._last_current_context_asset = None
+ self._last_current_context_task = None
+ self._use_current_context = True
+
@property
def current_asset_name(self):
return self._controller.current_asset_name
@@ -356,12 +367,39 @@ class CreateWidget(QtWidgets.QWidget):
if check_prereq:
self._invalidate_prereq()
+ def _on_main_window_close(self):
+ """Publisher window was closed."""
+
+ # Use current context on next refresh
+ self._use_current_context = True
+
def refresh(self):
+ current_asset_name = self._controller.current_asset_name
+ current_task_name = self._controller.current_task_name
+
# Get context before refresh to keep selection of asset and
# task widgets
asset_name = self._get_asset_name()
task_name = self._get_task_name()
+ # Replace by current context if last loaded context was
+ # 'current context' before reset
+ if (
+ self._use_current_context
+ or (
+ self._last_current_context_asset
+ and asset_name == self._last_current_context_asset
+ and task_name == self._last_current_context_task
+ )
+ ):
+ asset_name = current_asset_name
+ task_name = current_task_name
+
+ # Store values for future refresh
+ self._last_current_context_asset = current_asset_name
+ self._last_current_context_task = current_task_name
+ self._use_current_context = False
+
self._prereq_available = False
# Disable context widget so refresh of asset will use context asset
@@ -398,7 +436,10 @@ class CreateWidget(QtWidgets.QWidget):
prereq_available = False
creator_btn_tooltips.append("Creator is not selected")
- if self._context_change_is_enabled() and self._asset_name is None:
+ if (
+ self._context_change_is_enabled()
+ and self._get_asset_name() is None
+ ):
# QUESTION how to handle invalid asset?
prereq_available = False
creator_btn_tooltips.append("Context is not selected")
diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py
index 227ae7bda9..557e6559c8 100644
--- a/openpype/tools/publisher/widgets/list_view_widgets.py
+++ b/openpype/tools/publisher/widgets/list_view_widgets.py
@@ -11,7 +11,7 @@ selection can be enabled disabled using checkbox or keyboard key presses:
- Backspace - disable selection
```
-|- Options
+|- Context
|- [x]
| |- [x]
| |- [x]
@@ -486,6 +486,9 @@ class InstanceListView(AbstractInstanceView):
group_widget.set_expanded(expanded)
def _on_toggle_request(self, toggle):
+ if not self._active_toggle_enabled:
+ return
+
selected_instance_ids = self._instance_view.get_selected_instance_ids()
if toggle == -1:
active = None
@@ -1039,7 +1042,8 @@ class InstanceListView(AbstractInstanceView):
proxy_index = proxy_model.mapFromSource(select_indexes[0])
selection_model.setCurrentIndex(
proxy_index,
- selection_model.ClearAndSelect | selection_model.Rows
+ QtCore.QItemSelectionModel.ClearAndSelect
+ | QtCore.QItemSelectionModel.Rows
)
return
diff --git a/openpype/tools/publisher/widgets/precreate_widget.py b/openpype/tools/publisher/widgets/precreate_widget.py
index 3037a0e12d..3bf0bc3657 100644
--- a/openpype/tools/publisher/widgets/precreate_widget.py
+++ b/openpype/tools/publisher/widgets/precreate_widget.py
@@ -2,6 +2,8 @@ from qtpy import QtWidgets, QtCore
from openpype.tools.attribute_defs import create_widget_for_attr_def
+from ..constants import INPUTS_LAYOUT_HSPACING, INPUTS_LAYOUT_VSPACING
+
class PreCreateWidget(QtWidgets.QWidget):
def __init__(self, parent):
@@ -81,6 +83,8 @@ class AttributesWidget(QtWidgets.QWidget):
layout = QtWidgets.QGridLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
+ layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
+ layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
self._layout = layout
@@ -117,8 +121,16 @@ class AttributesWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
- if attr_def.label:
+ if attr_def.is_value_def and attr_def.label:
label_widget = QtWidgets.QLabel(attr_def.label, self)
+ tooltip = attr_def.tooltip
+ if tooltip:
+ label_widget.setToolTip(tooltip)
+ if attr_def.is_label_horizontal:
+ label_widget.setAlignment(
+ QtCore.Qt.AlignRight
+ | QtCore.Qt.AlignVCenter
+ )
self._layout.addWidget(
label_widget, row, 0, 1, expand_cols
)
diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py
index d2ce1fbcb2..cd1f1f5a96 100644
--- a/openpype/tools/publisher/widgets/widgets.py
+++ b/openpype/tools/publisher/widgets/widgets.py
@@ -9,7 +9,7 @@ import collections
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
-from openpype.lib.attribute_definitions import UnknownDef, UIDef
+from openpype.lib.attribute_definitions import UnknownDef
from openpype.tools.attribute_defs import create_widget_for_attr_def
from openpype.tools import resources
from openpype.tools.flickcharm import FlickCharm
@@ -36,6 +36,8 @@ from .icons import (
from ..constants import (
VARIANT_TOOLTIP,
ResetKeySequence,
+ INPUTS_LAYOUT_HSPACING,
+ INPUTS_LAYOUT_VSPACING,
)
@@ -1098,6 +1100,8 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
btns_layout.addWidget(cancel_btn)
main_layout = QtWidgets.QFormLayout(self)
+ main_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
+ main_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
main_layout.addRow("Variant", variant_input)
main_layout.addRow("Asset", asset_value_widget)
main_layout.addRow("Task", task_value_widget)
@@ -1346,6 +1350,8 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
content_layout.setColumnStretch(0, 0)
content_layout.setColumnStretch(1, 1)
content_layout.setAlignment(QtCore.Qt.AlignTop)
+ content_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
+ content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
row = 0
for attr_def, attr_instances, values in result:
@@ -1371,9 +1377,19 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
- label = attr_def.label or attr_def.key
+ label = None
+ if attr_def.is_value_def:
+ label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, self)
+ tooltip = attr_def.tooltip
+ if tooltip:
+ label_widget.setToolTip(tooltip)
+ if attr_def.is_label_horizontal:
+ label_widget.setAlignment(
+ QtCore.Qt.AlignRight
+ | QtCore.Qt.AlignVCenter
+ )
content_layout.addWidget(
label_widget, row, 0, 1, expand_cols
)
@@ -1474,6 +1490,8 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
attr_def_layout = QtWidgets.QGridLayout(attr_def_widget)
attr_def_layout.setColumnStretch(0, 0)
attr_def_layout.setColumnStretch(1, 1)
+ attr_def_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
+ attr_def_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.addWidget(attr_def_widget, 0)
@@ -1501,12 +1519,19 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
expand_cols = 1
col_num = 2 - expand_cols
- label = attr_def.label or attr_def.key
+ label = None
+ if attr_def.is_value_def:
+ label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, content_widget)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
+ if attr_def.is_label_horizontal:
+ label_widget.setAlignment(
+ QtCore.Qt.AlignRight
+ | QtCore.Qt.AlignVCenter
+ )
attr_def_layout.addWidget(
label_widget, row, 0, 1, expand_cols
)
@@ -1517,7 +1542,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
)
row += 1
- if isinstance(attr_def, UIDef):
+ if not attr_def.is_value_def:
continue
widget.value_changed.connect(self._input_value_changed)
diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py
index 8826e0f849..b3471163ae 100644
--- a/openpype/tools/publisher/window.py
+++ b/openpype/tools/publisher/window.py
@@ -46,6 +46,8 @@ class PublisherWindow(QtWidgets.QDialog):
def __init__(self, parent=None, controller=None, reset_on_show=None):
super(PublisherWindow, self).__init__(parent)
+ self.setObjectName("PublishWindow")
+
self.setWindowTitle("OpenPype publisher")
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
@@ -284,6 +286,9 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"publish.has_validated.changed", self._on_publish_validated_change
)
+ controller.event_system.add_callback(
+ "publish.finished.changed", self._on_publish_finished_change
+ )
controller.event_system.add_callback(
"publish.process.stopped", self._on_publish_stop
)
@@ -400,8 +405,12 @@ class PublisherWindow(QtWidgets.QDialog):
# TODO capture changes and ask user if wants to save changes on close
if not self._controller.host_context_has_changed:
self._save_changes(False)
+ self._comment_input.setText("") # clear comment
self._reset_on_show = True
self._controller.clear_thumbnail_temp_dir_path()
+ # Trigger custom event that should be captured only in UI
+ # - backend (controller) must not be dependent on this event topic!!!
+ self._controller.event_system.emit("main.window.closed", {}, "window")
super(PublisherWindow, self).closeEvent(event)
def leaveEvent(self, event):
@@ -433,15 +442,24 @@ class PublisherWindow(QtWidgets.QDialog):
event.accept()
return
- if event.matches(QtGui.QKeySequence.Save):
+ save_match = event.matches(QtGui.QKeySequence.Save)
+ if save_match == QtGui.QKeySequence.ExactMatch:
if not self._controller.publish_has_started:
self._save_changes(True)
event.accept()
return
- if ResetKeySequence.matches(
- QtGui.QKeySequence(event.key() | event.modifiers())
- ):
+ # PySide6 Support
+ if hasattr(event, "keyCombination"):
+ reset_match_result = ResetKeySequence.matches(
+ QtGui.QKeySequence(event.keyCombination())
+ )
+ else:
+ reset_match_result = ResetKeySequence.matches(
+ QtGui.QKeySequence(event.modifiers() | event.key())
+ )
+
+ if reset_match_result == QtGui.QKeySequence.ExactMatch:
if not self.controller.publish_is_running:
self.reset()
event.accept()
@@ -777,6 +795,11 @@ class PublisherWindow(QtWidgets.QDialog):
if event["value"]:
self._validate_btn.setEnabled(False)
+ def _on_publish_finished_change(self, event):
+ if event["value"]:
+ # Successful publish, remove comment from UI
+ self._comment_input.setText("")
+
def _on_publish_stop(self):
self._set_publish_overlay_visibility(False)
self._reset_btn.setEnabled(True)
diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py
index 63d2945145..5cc849bb9e 100644
--- a/openpype/tools/sceneinventory/model.py
+++ b/openpype/tools/sceneinventory/model.py
@@ -199,90 +199,103 @@ class InventoryModel(TreeModel):
"""Refresh the model"""
host = registered_host()
- if not items: # for debugging or testing, injecting items from outside
+ # for debugging or testing, injecting items from outside
+ if items is None:
if isinstance(host, ILoadHost):
items = host.get_containers()
- else:
+ elif hasattr(host, "ls"):
items = host.ls()
+ else:
+ items = []
self.clear()
-
- if self._hierarchy_view and selected:
- if not hasattr(host.pipeline, "update_hierarchy"):
- # If host doesn't support hierarchical containers, then
- # cherry-pick only.
- self.add_items((item for item in items
- if item["objectName"] in selected))
- return
-
- # Update hierarchy info for all containers
- items_by_name = {item["objectName"]: item
- for item in host.pipeline.update_hierarchy(items)}
-
- selected_items = set()
-
- def walk_children(names):
- """Select containers and extend to chlid containers"""
- for name in [n for n in names if n not in selected_items]:
- selected_items.add(name)
- item = items_by_name[name]
- yield item
-
- for child in walk_children(item["children"]):
- yield child
-
- items = list(walk_children(selected)) # Cherry-picked and extended
-
- # Cut unselected upstream containers
- for item in items:
- if not item.get("parent") in selected_items:
- # Parent not in selection, this is root item.
- item["parent"] = None
-
- parents = [self._root_item]
-
- # The length of `items` array is the maximum depth that a
- # hierarchy could be.
- # Take this as an easiest way to prevent looping forever.
- maximum_loop = len(items)
- count = 0
- while items:
- if count > maximum_loop:
- self.log.warning("Maximum loop count reached, possible "
- "missing parent node.")
- break
-
- _parents = list()
- for parent in parents:
- _unparented = list()
-
- def _children():
- """Child item provider"""
- for item in items:
- if item.get("parent") == parent.get("objectName"):
- # (NOTE)
- # Since `self._root_node` has no "objectName"
- # entry, it will be paired with root item if
- # the value of key "parent" is None, or not
- # having the key.
- yield item
- else:
- # Not current parent's child, try next
- _unparented.append(item)
-
- self.add_items(_children(), parent)
-
- items[:] = _unparented
-
- # Parents of next level
- for group_node in parent.children():
- _parents += group_node.children()
-
- parents[:] = _parents
- count += 1
-
- else:
+ if not selected or not self._hierarchy_view:
self.add_items(items)
+ return
+
+ if (
+ not hasattr(host, "pipeline")
+ or not hasattr(host.pipeline, "update_hierarchy")
+ ):
+ # If host doesn't support hierarchical containers, then
+ # cherry-pick only.
+ self.add_items((
+ item
+ for item in items
+ if item["objectName"] in selected
+ ))
+ return
+
+ # TODO find out what this part does. Function 'update_hierarchy' is
+ # available only in 'blender' at this moment.
+
+ # Update hierarchy info for all containers
+ items_by_name = {
+ item["objectName"]: item
+ for item in host.pipeline.update_hierarchy(items)
+ }
+
+ selected_items = set()
+
+ def walk_children(names):
+ """Select containers and extend to chlid containers"""
+ for name in [n for n in names if n not in selected_items]:
+ selected_items.add(name)
+ item = items_by_name[name]
+ yield item
+
+ for child in walk_children(item["children"]):
+ yield child
+
+ items = list(walk_children(selected)) # Cherry-picked and extended
+
+ # Cut unselected upstream containers
+ for item in items:
+ if not item.get("parent") in selected_items:
+ # Parent not in selection, this is root item.
+ item["parent"] = None
+
+ parents = [self._root_item]
+
+ # The length of `items` array is the maximum depth that a
+ # hierarchy could be.
+ # Take this as an easiest way to prevent looping forever.
+ maximum_loop = len(items)
+ count = 0
+ while items:
+ if count > maximum_loop:
+ self.log.warning("Maximum loop count reached, possible "
+ "missing parent node.")
+ break
+
+ _parents = list()
+ for parent in parents:
+ _unparented = list()
+
+ def _children():
+ """Child item provider"""
+ for item in items:
+ if item.get("parent") == parent.get("objectName"):
+ # (NOTE)
+ # Since `self._root_node` has no "objectName"
+ # entry, it will be paired with root item if
+ # the value of key "parent" is None, or not
+ # having the key.
+ yield item
+ else:
+ # Not current parent's child, try next
+ _unparented.append(item)
+
+ self.add_items(_children(), parent)
+
+ items[:] = _unparented
+
+ # Parents of next level
+ for group_node in parent.children():
+ _parents += group_node.children()
+
+ parents[:] = _parents
+ count += 1
def add_items(self, items, parent=None):
"""Add the items to the model.
diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py
index 3279be6094..73d33392b9 100644
--- a/openpype/tools/sceneinventory/view.py
+++ b/openpype/tools/sceneinventory/view.py
@@ -791,7 +791,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
else:
version_str = version
- dialog = QtWidgets.QMessageBox()
+ dialog = QtWidgets.QMessageBox(self)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle("Update failed")
diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py
index 89424fd746..6ee1c0d38e 100644
--- a/openpype/tools/sceneinventory/window.py
+++ b/openpype/tools/sceneinventory/window.py
@@ -107,8 +107,8 @@ class SceneInventoryWindow(QtWidgets.QDialog):
view.hierarchy_view_changed.connect(
self._on_hierarchy_view_change
)
- view.data_changed.connect(self.refresh)
- refresh_button.clicked.connect(self.refresh)
+ view.data_changed.connect(self._on_refresh_request)
+ refresh_button.clicked.connect(self._on_refresh_request)
update_all_button.clicked.connect(self._on_update_all)
self._update_all_button = update_all_button
@@ -139,6 +139,11 @@ class SceneInventoryWindow(QtWidgets.QDialog):
"""
+ def _on_refresh_request(self):
+ """Signal callback to trigger 'refresh' without any arguments."""
+
+ self.refresh()
+
def refresh(self, items=None):
with preserve_expanded_rows(
tree_view=self._view,
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
index 4292e2d726..4149763f80 100644
--- a/openpype/tools/utils/__init__.py
+++ b/openpype/tools/utils/__init__.py
@@ -1,6 +1,7 @@
from .widgets import (
FocusSpinBox,
FocusDoubleSpinBox,
+ ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
BaseClickableFrame,
@@ -38,6 +39,7 @@ from .overlay_messages import (
__all__ = (
"FocusSpinBox",
"FocusDoubleSpinBox",
+ "ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"BaseClickableFrame",
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
index b416c56797..bae89aeb09 100644
--- a/openpype/tools/utils/widgets.py
+++ b/openpype/tools/utils/widgets.py
@@ -41,7 +41,28 @@ class FocusDoubleSpinBox(QtWidgets.QDoubleSpinBox):
super(FocusDoubleSpinBox, self).wheelEvent(event)
-class CustomTextComboBox(QtWidgets.QComboBox):
+class ComboBox(QtWidgets.QComboBox):
+ """Base of combobox with pre-implement changes used in tools.
+
+ Combobox is using styled delegate by default so stylesheets are propagated.
+
+ Items are not changed on scroll until the combobox is in focus.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(ComboBox, self).__init__(*args, **kwargs)
+ delegate = QtWidgets.QStyledItemDelegate()
+ self.setItemDelegate(delegate)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ self._delegate = delegate
+
+ def wheelEvent(self, event):
+ if self.hasFocus():
+ return super(ComboBox, self).wheelEvent(event)
+
+
+class CustomTextComboBox(ComboBox):
"""Combobox which can have different text showed."""
def __init__(self, *args, **kwargs):
@@ -253,6 +274,9 @@ class PixmapLabel(QtWidgets.QLabel):
self._empty_pixmap = QtGui.QPixmap(0, 0)
self._source_pixmap = pixmap
+ self._last_width = 0
+ self._last_height = 0
+
def set_source_pixmap(self, pixmap):
"""Change source image."""
self._source_pixmap = pixmap
@@ -263,6 +287,12 @@ class PixmapLabel(QtWidgets.QLabel):
size += size % 2
return size, size
+ def minimumSizeHint(self):
+ width, height = self._get_pix_size()
+ if width != self._last_width or height != self._last_height:
+ self._set_resized_pix()
+ return QtCore.QSize(width, height)
+
def _set_resized_pix(self):
if self._source_pixmap is None:
self.setPixmap(self._empty_pixmap)
@@ -276,6 +306,8 @@ class PixmapLabel(QtWidgets.QLabel):
QtCore.Qt.SmoothTransformation
)
)
+ self._last_width = width
+ self._last_height = height
def resizeEvent(self, event):
self._set_resized_pix()
diff --git a/openpype/version.py b/openpype/version.py
index 1d41f1aa5d..7df154fe1e 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.15.4"
+__version__ = "3.15.7-nightly.2"
diff --git a/pyproject.toml b/pyproject.toml
index b97ad8923c..003f6cf2d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.15.4" # OpenPype
+version = "3.15.6" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py
index d372efcb9a..0e9cd3b00d 100644
--- a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py
+++ b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py
@@ -9,6 +9,9 @@ log = logging.getLogger("test_publish_in_aftereffects")
class TestDeadlinePublishInAfterEffectsMultiComposition(AEDeadlinePublishTestClass): # noqa
"""est case for DL publishing in AfterEffects with multiple compositions.
+ Workfile contains 2 prepared `render` instances. First has review set,
+ second doesn't.
+
Uses generic TestCase to prepare fixtures for test data, testing DBs,
env vars.
@@ -68,7 +71,7 @@ class TestDeadlinePublishInAfterEffectsMultiComposition(AEDeadlinePublishTestCla
name="renderTest_taskMain2"))
failures.append(
- DBAssert.count_of_types(dbcon, "representation", 7))
+ DBAssert.count_of_types(dbcon, "representation", 5))
additional_args = {"context.subset": "workfileTest_task",
"context.ext": "aep"}
@@ -105,13 +108,13 @@ class TestDeadlinePublishInAfterEffectsMultiComposition(AEDeadlinePublishTestCla
additional_args = {"context.subset": "renderTest_taskMain2",
"name": "thumbnail"}
failures.append(
- DBAssert.count_of_types(dbcon, "representation", 1,
+ DBAssert.count_of_types(dbcon, "representation", 0,
additional_args=additional_args))
additional_args = {"context.subset": "renderTest_taskMain2",
"name": "png_exr"}
failures.append(
- DBAssert.count_of_types(dbcon, "representation", 1,
+ DBAssert.count_of_types(dbcon, "representation", 0,
additional_args=additional_args))
assert not any(failures)
diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py
new file mode 100644
index 0000000000..1594b36dec
--- /dev/null
+++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop_auto_image.py
@@ -0,0 +1,93 @@
+import logging
+
+from tests.lib.assert_classes import DBAssert
+from tests.integration.hosts.photoshop.lib import PhotoshopTestClass
+
+log = logging.getLogger("test_publish_in_photoshop")
+
+
+class TestPublishInPhotoshopAutoImage(PhotoshopTestClass):
+ """Test for publish in Phohoshop with different review configuration.
+
+ Workfile contains 3 layers, auto image and review instances created.
+
+ Test contains updates to Settings!!!
+
+ """
+ PERSIST = True
+
+ TEST_FILES = [
+ ("1iLF6aNI31qlUCD1rGg9X9eMieZzxL-rc",
+ "test_photoshop_publish_auto_image.zip", "")
+ ]
+
+ APP_GROUP = "photoshop"
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
+
+ APP_NAME = "{}/{}".format(APP_GROUP, APP_VARIANT)
+
+ TIMEOUT = 120 # publish timeout
+
+ def test_db_asserts(self, dbcon, publish_finished):
+ """Host and input data dependent expected results in DB."""
+ print("test_db_asserts")
+ failures = []
+
+ failures.append(DBAssert.count_of_types(dbcon, "version", 3))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1}))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 0,
+ name="imageMainForeground"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 0,
+ name="imageMainBackground"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="workfileTest_task"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 5))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 0,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 0,
+ additional_args=additional_args))
+
+ # review from image
+ additional_args = {"context.subset": "imageBeautyMain",
+ "context.ext": "jpg",
+ "name": "jpg_jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageBeautyMain",
+ "context.ext": "jpg",
+ "name": "jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "review"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ assert not any(failures)
+
+
+if __name__ == "__main__":
+ test_case = TestPublishInPhotoshopAutoImage()
diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py
new file mode 100644
index 0000000000..64b6868d7c
--- /dev/null
+++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop_review.py
@@ -0,0 +1,111 @@
+import logging
+
+from tests.lib.assert_classes import DBAssert
+from tests.integration.hosts.photoshop.lib import PhotoshopTestClass
+
+log = logging.getLogger("test_publish_in_photoshop")
+
+
+class TestPublishInPhotoshopImageReviews(PhotoshopTestClass):
+ """Test for publish in Phohoshop with different review configuration.
+
+ Workfile contains 2 image instance, one has review flag, second doesn't.
+
+ Regular `review` family is disabled.
+
+ Expected result is to `imageMainForeground` to have additional file with
+ review, `imageMainBackground` without. No separate `review` family.
+
+ `test_project_test_asset_imageMainForeground_v001_jpg.jpg` is expected name
+ of imageForeground review, `_jpg` suffix is needed to differentiate between
+ image and review file.
+
+ """
+ PERSIST = True
+
+ TEST_FILES = [
+ ("12WGbNy9RJ3m9jlnk0Ib9-IZmONoxIz_p",
+ "test_photoshop_publish_review.zip", "")
+ ]
+
+ APP_GROUP = "photoshop"
+ # keep empty to locate latest installed variant or explicit
+ APP_VARIANT = ""
+
+ APP_NAME = "{}/{}".format(APP_GROUP, APP_VARIANT)
+
+ TIMEOUT = 120 # publish timeout
+
+ def test_db_asserts(self, dbcon, publish_finished):
+ """Host and input data dependent expected results in DB."""
+ print("test_db_asserts")
+ failures = []
+
+ failures.append(DBAssert.count_of_types(dbcon, "version", 3))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1}))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="imageMainForeground"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="imageMainBackground"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "subset", 1,
+ name="workfileTest_task"))
+
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 6))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 2,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainForeground",
+ "context.ext": "jpg",
+ "context.representation": "jpg_jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "png"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 1,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "imageMainBackground",
+ "context.ext": "jpg",
+ "context.representation": "jpg_jpg"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 0,
+ additional_args=additional_args))
+
+ additional_args = {"context.subset": "review"}
+ failures.append(
+ DBAssert.count_of_types(dbcon, "representation", 0,
+ additional_args=additional_args))
+
+ assert not any(failures)
+
+
+if __name__ == "__main__":
+ test_case = TestPublishInPhotoshopImageReviews()
diff --git a/tests/unit/openpype/plugins/publish/test_validate_sequence_frames.py b/tests/unit/openpype/plugins/publish/test_validate_sequence_frames.py
index 58d9de011d..17e47c9f64 100644
--- a/tests/unit/openpype/plugins/publish/test_validate_sequence_frames.py
+++ b/tests/unit/openpype/plugins/publish/test_validate_sequence_frames.py
@@ -180,5 +180,23 @@ class TestValidateSequenceFrames(BaseTest):
plugin.process(instance)
assert ("Missing frames: [1002]" in str(excinfo.value))
+ def test_validate_sequence_frames_slate(self, instance, plugin):
+ representations = [
+ {
+ "ext": "exr",
+ "files": [
+ "Main_beauty.1000.exr",
+ "Main_beauty.1001.exr",
+ "Main_beauty.1002.exr",
+ "Main_beauty.1003.exr"
+ ]
+ }
+ ]
+ instance.data["slate"] = True
+ instance.data["representations"] = representations
+ instance.data["frameEnd"] = 1003
+
+ plugin.process(instance)
+
test_case = TestValidateSequenceFrames()
diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md
new file mode 100644
index 0000000000..de684f01d2
--- /dev/null
+++ b/website/docs/admin_hosts_photoshop.md
@@ -0,0 +1,127 @@
+---
+id: admin_hosts_photoshop
+title: Photoshop Settings
+sidebar_label: Photoshop
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Photoshop settings
+
+There is a couple of settings that could configure publishing process for **Photoshop**.
+All of them are Project based, eg. each project could have different configuration.
+
+Location: Settings > Project > Photoshop
+
+
+
+## Color Management (ImageIO)
+
+Placeholder for Color Management. Currently not implemented yet.
+
+## Creator plugins
+
+Contains configurable items for creators used during publishing from Photoshop.
+
+### Create Image
+
+Provides list of [variants](artist_concepts.md#variant) that will be shown to an artist in Publisher. Default value `Main`.
+
+### Create Flatten Image
+
+Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will
+produce flatten image from all visible layers in a workfile.
+
+- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`)
+- Review - should be separate review created for this instance
+
+### Create Review
+
+Creates single `review` instance automatically. This allows artists to disable it if needed.
+
+### Create Workfile
+
+Creates single `workfile` instance automatically. This allows artists to disable it if needed.
+
+## Publish plugins
+
+Contains configurable items for publish plugins used during publishing from Photoshop.
+
+### Collect Color Coded Instances
+
+Used only in remote publishing!
+
+Allows to create automatically `image` instances for configurable highlight color set on layer or group in the workfile.
+
+#### Create flatten image
+ - Flatten with images - produce additional `image` with all published `image` instances merged
+ - Flatten only - produce only merged `image` instance
+ - No - produce only separate `image` instances
+
+#### Subset template for flatten image
+
+Template used to create subset name automatically (example `image{layer}Main` - uses layer name in subset name)
+
+### Collect Review
+
+Disable if no review should be created
+
+### Collect Version
+
+If enabled it will push version from workfile name to all published items. Eg. if artist is publishing `test_asset_workfile_v005.psd`
+produced `image` and `review` files will contain `v005` (even if some previous version were skipped for particular family).
+
+### Validate Containers
+
+Checks if all imported assets to the workfile through `Loader` are in latest version. Limits cases that older version of asset would be used.
+
+If enabled, artist might still decide to disable validation for each publish (for special use cases).
+Limit this optionality by toggling `Optional`.
+`Active` toggle denotes that by default artists sees that optional validation as enabled.
+
+### Validate naming of subsets and layers
+
+Subset cannot contain invalid characters or extract to file would fail
+
+#### Regex pattern of invalid characters
+
+Contains weird characters like `/`, `/`, these might cause an issue when file (which contains subset name) is created on OS disk.
+
+#### Replacement character
+
+Replace all offending characters with this one. `_` is default.
+
+### Extract Image
+
+Controls extension formats of published instances of `image` family. `png` and `jpg` are by default.
+
+### Extract Review
+
+Controls output definitions of extracted reviews to upload on Asset Management (AM).
+
+#### Makes an image sequence instead of flatten image
+
+If multiple `image` instances are produced, glue created images into image sequence (`mov`) to review all of them separetely.
+Without it only flatten image would be produced.
+
+#### Maximum size of sources for review
+
+Set Byte limit for review file. Applicable if gigantic `image` instances are produced, full image size is unnecessary to upload to AM.
+
+#### Extract jpg Options
+
+Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults.
+
+#### Extract mov Options
+
+Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults.
+
+
+### Workfile Builder
+
+Allows to open prepared workfile for an artist when no workfile exists. Useful to share standards, additional helpful content in the workfile.
+
+Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task.
+Workfile template must be accessible for all artists.
+(Currently not handled by [SiteSync](module_site_sync.md))
\ No newline at end of file
diff --git a/website/docs/artist_hosts_substancepainter.md b/website/docs/artist_hosts_substancepainter.md
new file mode 100644
index 0000000000..86bcbba82e
--- /dev/null
+++ b/website/docs/artist_hosts_substancepainter.md
@@ -0,0 +1,107 @@
+---
+id: artist_hosts_substancepainter
+title: Substance Painter
+sidebar_label: Substance Painter
+---
+
+## OpenPype global tools
+
+- [Work Files](artist_tools.md#workfiles)
+- [Load](artist_tools.md#loader)
+- [Manage (Inventory)](artist_tools.md#inventory)
+- [Publish](artist_tools.md#publisher)
+- [Library Loader](artist_tools.md#library-loader)
+
+## Working with OpenPype in Substance Painter
+
+The Substance Painter OpenPype integration allows you to:
+
+- Set the project mesh and easily keep it in sync with updates of the model
+- Easily export your textures as versioned publishes for others to load and update.
+
+## Setting the project mesh
+
+Substance Painter requires a project file to have a mesh path configured.
+As such, you can't start a workfile without choosing a mesh path.
+
+To start a new project using a published model you can _without an open project_
+use OpenPype > Load.. > Load Mesh on a supported publish. This will prompt you
+with a New Project prompt preset to that particular mesh file.
+
+If you already have a project open, you can also replace (reload) your mesh
+using the same Load Mesh functionality.
+
+After having the project mesh loaded or reloaded through the loader
+tool the mesh will be _managed_ by OpenPype. For example, you'll be notified
+on workfile open whether the mesh in your workfile is outdated. You can also
+set it to specific version using OpenPype > Manage.. where you can right click
+on the project mesh to perform _Set Version_
+
+:::info
+A Substance Painter project will always have only one mesh set. Whenever you
+trigger _Load Mesh_ from the loader this will **replace** your currently loaded
+mesh for your open project.
+:::
+
+## Publishing textures
+
+To publish your textures we must first create a `textureSet`
+publish instance.
+
+To create a **TextureSet instance** we will use OpenPype's publisher tool. Go
+to **OpenPype → Publish... → TextureSet**
+
+The texture set instance will define what Substance Painter export template (`.spexp`) to
+use and thus defines what texture maps will be exported from your workfile. This
+can be set with the **Output Template** attribute on the instance.
+
+:::info
+The TextureSet instance gets saved with your Substance Painter project. As such,
+you will only need to configure this once for your workfile. Next time you can
+just click **OpenPype → Publish...** and start publishing directly with the
+same settings.
+:::
+
+#### Publish per output map of the Substance Painter preset
+
+The Texture Set instance generates a publish per output map that is defined in
+the Substance Painter's export preset. For example a publish from a default
+PBR Metallic Roughness texture set results in six separate published subsets
+(if all the channels exist in your file).
+
+
+
+When publishing for example a texture set with variant **Main** six instances will
+be published with the variants:
+- Main.**BaseColor**
+- Main.**Emissive**
+- Main.**Height**
+- Main.**Metallic**
+- Main.**Normal**
+- Main.**Roughness**
+
+The bold output map name for the publish is based on the string that is pulled
+from the what is considered to be the static part of the filename templates in
+the export preset. The tokens like `$mesh` and `(_$colorSpace)` are ignored.
+So `$mesh_$textureSet_BaseColor(_$colorSpace)(.$udim)` becomes `BaseColor`.
+
+An example output for PBR Metallic Roughness would be:
+
+
+
+## Known issues
+
+#### Can't see the OpenPype menu?
+
+If you're unable to see the OpenPype top level menu in Substance Painter make
+sure you have launched Substance Painter through OpenPype and that the OpenPype
+Integration plug-in is loaded inside Substance Painter: **Python > openpype_plugin**
+
+#### Substance Painter + Steam
+
+Running the steam version of Substance Painter within OpenPype will require you
+to close the Steam executable before launching Substance Painter through OpenPype.
+Otherwise the Substance Painter process is launched using Steam's existing
+environment and thus will not be able to pick up the pipeline integration.
+
+This appears to be a limitation of how Steam works.
\ No newline at end of file
diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png
new file mode 100644
index 0000000000..aaa6ecbed7
Binary files /dev/null and b/website/docs/assets/admin_hosts_photoshop_settings.png differ
diff --git a/website/docs/assets/substancepainter_pbrmetallicroughness_export_preset.png b/website/docs/assets/substancepainter_pbrmetallicroughness_export_preset.png
new file mode 100644
index 0000000000..35a4545f83
Binary files /dev/null and b/website/docs/assets/substancepainter_pbrmetallicroughness_export_preset.png differ
diff --git a/website/docs/assets/substancepainter_pbrmetallicroughness_published.png b/website/docs/assets/substancepainter_pbrmetallicroughness_published.png
new file mode 100644
index 0000000000..15b0e5b876
Binary files /dev/null and b/website/docs/assets/substancepainter_pbrmetallicroughness_published.png differ
diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md
index 3e5794579c..68f56cb548 100644
--- a/website/docs/module_site_sync.md
+++ b/website/docs/module_site_sync.md
@@ -7,80 +7,112 @@ sidebar_label: Site Sync
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
+Site Sync allows users and studios to synchronize published assets between
+multiple 'sites'. Site denotes a storage location,
+which could be a physical disk, server, cloud storage. To be able to use site
+sync, it first needs to be configured.
-:::warning
-**This feature is** currently **in a beta stage** and it is not recommended to rely on it fully for production.
-:::
-
-Site Sync allows users and studios to synchronize published assets between multiple 'sites'. Site denotes a storage location,
-which could be a physical disk, server, cloud storage. To be able to use site sync, it first needs to be configured.
-
-The general idea is that each user acts as an individual site and can download and upload any published project files when they are needed. that way, artist can have access to the whole project, but only every store files that are relevant to them on their home workstation.
+The general idea is that each user acts as an individual site and can download
+and upload any published project files when they are needed. that way, artist
+can have access to the whole project, but only every store files that are
+relevant to them on their home workstation.
:::note
-At the moment site sync is only able to deal with publishes files. No workfiles will be synchronized unless they are published. We are working on making workfile synchronization possible as well.
+At the moment site sync is only able to deal with publishes files. No workfiles
+will be synchronized unless they are published. We are working on making
+workfile synchronization possible as well.
:::
## System Settings
-To use synchronization, *Site Sync* needs to be enabled globally in **OpenPype Settings/System/Modules/Site Sync**.
+To use synchronization, *Site Sync* needs to be enabled globally in **OpenPype
+Settings/System/Modules/Site Sync**.

-### Sites
+### Sites
By default there are two sites created for each OpenPype installation:
-- **studio** - default site - usually a centralized mounted disk accessible to all artists. Studio site is used if Site Sync is disabled.
-- **local** - each workstation or server running OpenPype Tray receives its own with unique site name. Workstation refers to itself as "local"however all other sites will see it under it's unique ID.
-Artists can explore their site ID by opening OpenPype Info tool by clicking on a version number in the tray app.
+- **studio** - default site - usually a centralized mounted disk accessible to
+ all artists. Studio site is used if Site Sync is disabled.
+- **local** - each workstation or server running OpenPype Tray receives its own
+ with unique site name. Workstation refers to itself as "local"however all
+ other sites will see it under it's unique ID.
-Many different sites can be created and configured on the system level, and some or all can be assigned to each project.
+Artists can explore their site ID by opening OpenPype Info tool by clicking on
+a version number in the tray app.
-Each OpenPype Tray app works with two sites at one time. (Sites can be the same, and no syncing is done in this setup).
+Many different sites can be created and configured on the system level, and
+some or all can be assigned to each project.
-Sites could be configured differently per project basis.
+Each OpenPype Tray app works with two sites at one time. (Sites can be the
+same, and no syncing is done in this setup).
-Each new site needs to be created first in `System Settings`. Most important feature of site is its Provider, select one from already prepared Providers.
+Sites could be configured differently per project basis.
-#### Alternative sites
+Each new site needs to be created first in `System Settings`. Most important
+feature of site is its Provider, select one from already prepared Providers.
+
+#### Alternative sites
This attribute is meant for special use cases only.
-One of the use cases is sftp site vendoring (exposing) same data as regular site (studio). Each site is accessible for different audience. 'studio' for artists in a studio via shared disk, 'sftp' for externals via sftp server with mounted 'studio' drive.
+One of the use cases is sftp site vendoring (exposing) same data as regular
+site (studio). Each site is accessible for different audience. 'studio' for
+artists in a studio via shared disk, 'sftp' for externals via sftp server with
+mounted 'studio' drive.
-Change of file status on one site actually means same change on 'alternate' site occurred too. (eg. artists publish to 'studio', 'sftp' is using
-same location >> file is accessible on 'sftp' site right away, no need to sync it anyhow.)
+Change of file status on one site actually means same change on 'alternate'
+site occurred too. (eg. artists publish to 'studio', 'sftp' is using
+same location >> file is accessible on 'sftp' site right away, no need to sync
+it anyhow.)
##### Example
+

-Admin created new `sftp` site which is handled by `SFTP` provider. Somewhere in the studio SFTP server is deployed on a machine that has access to `studio` drive.
+Admin created new `sftp` site which is handled by `SFTP` provider. Somewhere in
+the studio SFTP server is deployed on a machine that has access to `studio`
+drive.
Alternative sites work both way:
+
- everything published to `studio` is accessible on a `sftp` site too
-- everything published to `sftp` (most probably via artist's local disk - artists publishes locally, representation is marked to be synced to `sftp`. Immediately after it is synced, it is marked to be available on `studio` too for artists in the studio to use.)
+- everything published to `sftp` (most probably via artist's local disk -
+ artists publishes locally, representation is marked to be synced to `sftp`.
+ Immediately after it is synced, it is marked to be available on `studio` too
+ for artists in the studio to use.)
## Project Settings
-Sites need to be made available for each project. Of course this is possible to do on the default project as well, in which case all other projects will inherit these settings until overridden explicitly.
+Sites need to be made available for each project. Of course this is possible to
+do on the default project as well, in which case all other projects will
+inherit these settings until overridden explicitly.
You'll find the setting in **Settings/Project/Global/Site Sync**
-The attributes that can be configured will vary between sites and their providers.
+The attributes that can be configured will vary between sites and their
+providers.
## Local settings
-Each user should configure root folder for their 'local' site via **Local Settings** in OpenPype Tray. This folder will be used for all files that the user publishes or downloads while working on a project. Artist has the option to set the folder as "default"in which case it is used for all the projects, or it can be set on a project level individually.
+Each user should configure root folder for their 'local' site via **Local
+Settings** in OpenPype Tray. This folder will be used for all files that the
+user publishes or downloads while working on a project. Artist has the option
+to set the folder as "default"in which case it is used for all the projects, or
+it can be set on a project level individually.
-Artists can also override which site they use as active and remote if need be.
+Artists can also override which site they use as active and remote if need be.

-
## Providers
-Each site implements a so called `provider` which handles most common operations (list files, copy files etc.) and provides interface with a particular type of storage. (disk, gdrive, aws, etc.)
-Multiple configured sites could share the same provider with different settings (multiple mounted disks - each disk can be a separate site, while
+Each site implements a so called `provider` which handles most common
+operations (list files, copy files etc.) and provides interface with a
+particular type of storage. (disk, gdrive, aws, etc.)
+Multiple configured sites could share the same provider with different
+settings (multiple mounted disks - each disk can be a separate site, while
all share the same provider).
**Currently implemented providers:**
@@ -89,21 +121,30 @@ all share the same provider).
Handles files stored on disk storage.
-Local drive provider is the most basic one that is used for accessing all standard hard disk storage scenarios. It will work with any storage that can be mounted on your system in a standard way. This could correspond to a physical external hard drive, network mounted storage, internal drive or even VPN connected network drive. It doesn't care about how the drive is mounted, but you must be able to point to it with a simple directory path.
+Local drive provider is the most basic one that is used for accessing all
+standard hard disk storage scenarios. It will work with any storage that can be
+mounted on your system in a standard way. This could correspond to a physical
+external hard drive, network mounted storage, internal drive or even VPN
+connected network drive. It doesn't care about how the drive is mounted, but
+you must be able to point to it with a simple directory path.
Default sites `local` and `studio` both use local drive provider.
-
### Google Drive
-Handles files on Google Drive (this). GDrive is provided as a production example for implementing other cloud providers
+Handles files on Google Drive (this). GDrive is provided as a production
+example for implementing other cloud providers
-Let's imagine a small globally distributed studio which wants all published work for all their freelancers uploaded to Google Drive folder.
+Let's imagine a small globally distributed studio which wants all published
+work for all their freelancers uploaded to Google Drive folder.
For this use case admin needs to configure:
-- how many times it tries to synchronize file in case of some issue (network, permissions)
+
+- how many times it tries to synchronize file in case of some issue (network,
+ permissions)
- how often should synchronization check for new assets
-- sites for synchronization - 'local' and 'gdrive' (this can be overridden in local settings)
+- sites for synchronization - 'local' and 'gdrive' (this can be overridden in
+ local settings)
- user credentials
- root folder location on Google Drive side
@@ -111,30 +152,43 @@ Configuration would look like this:

-*Site Sync* for Google Drive works using its API: https://developers.google.com/drive/api/v3/about-sdk
+*Site Sync* for Google Drive works using its
+API: https://developers.google.com/drive/api/v3/about-sdk
-To configure Google Drive side you would need to have access to Google Cloud Platform project: https://console.cloud.google.com/
+To configure Google Drive side you would need to have access to Google Cloud
+Platform project: https://console.cloud.google.com/
To get working connection to Google Drive there are some necessary steps:
-- first you need to enable GDrive API: https://developers.google.com/drive/api/v3/enable-drive-api
-- next you need to create user, choose **Service Account** (for basic configuration no roles for account are necessary)
+
+- first you need to enable GDrive
+ API: https://developers.google.com/drive/api/v3/enable-drive-api
+- next you need to create user, choose **Service Account** (for basic
+ configuration no roles for account are necessary)
- add new key for created account and download .json file with credentials
-- share destination folder on the Google Drive with created account (directly in GDrive web application)
-- add new site back in OpenPype Settings, name as you want, provider needs to be 'gdrive'
+- share destination folder on the Google Drive with created account (directly
+ in GDrive web application)
+- add new site back in OpenPype Settings, name as you want, provider needs to
+ be 'gdrive'
- distribute credentials file via shared mounted disk location
:::note
-If you are using regular personal GDrive for testing don't forget adding `/My Drive` as the prefix in root configuration. Business accounts and share drives don't need this.
+If you are using regular personal GDrive for testing don't forget
+adding `/My Drive` as the prefix in root configuration. Business accounts and
+share drives don't need this.
:::
### SFTP
-SFTP provider is used to connect to SFTP server. Currently authentication with `user:password` or `user:ssh key` is implemented.
-Please provide only one combination, don't forget to provide password for ssh key if ssh key was created with a passphrase.
+SFTP provider is used to connect to SFTP server. Currently authentication
+with `user:password` or `user:ssh key` is implemented.
+Please provide only one combination, don't forget to provide password for ssh
+key if ssh key was created with a passphrase.
-(SFTP connection could be a bit finicky, use FileZilla or WinSCP for testing connection, it will be mush faster.)
+(SFTP connection could be a bit finicky, use FileZilla or WinSCP for testing
+connection, it will be mush faster.)
-Beware that ssh key expects OpenSSH format (`.pem`) not a Putty format (`.ppk`)!
+Beware that ssh key expects OpenSSH format (`.pem`) not a Putty
+format (`.ppk`)!
#### How to set SFTP site
@@ -143,60 +197,101 @@ Beware that ssh key expects OpenSSH format (`.pem`) not a Putty format (`.ppk`)!

-- In Projects setting enable Site Sync (on default project - all project will be synched, or on specific project)
-- Configure SFTP connection and destination folder on a SFTP server (in screenshot `/upload`)
+- In Projects setting enable Site Sync (on default project - all project will
+ be synched, or on specific project)
+- Configure SFTP connection and destination folder on a SFTP server (in
+ screenshot `/upload`)

-
-- if you want to force syncing between local and sftp site for all users, use combination `active site: local`, `remote site: NAME_OF_SFTP_SITE`
-- if you want to allow only specific users to use SFTP syncing (external users, not located in the office), use `active site: studio`, `remote site: studio`.
+
+- if you want to force syncing between local and sftp site for all users, use
+ combination `active site: local`, `remote site: NAME_OF_SFTP_SITE`
+- if you want to allow only specific users to use SFTP syncing (external users,
+ not located in the office), use `active site: studio`, `remote site: studio`.

-- Each artist can decide and configure syncing from his/her local to SFTP via `Local Settings`
+- Each artist can decide and configure syncing from his/her local to SFTP
+ via `Local Settings`

-
+
### Custom providers
-If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template.
+If a studio needs to use other services for cloud storage, or want to implement
+totally different storage providers, they can do so by writing their own
+provider plugin. We're working on a developer documentation, however, for now
+we recommend looking at `abstract_provider.py`and `gdrive.py`
+inside `openpype/modules/sync_server/providers` and using it as a template.
### Running Site Sync in background
-Site Sync server synchronizes new published files from artist machine into configured remote location by default.
+Site Sync server synchronizes new published files from artist machine into
+configured remote location by default.
-There might be a use case where you need to synchronize between "non-artist" sites, for example between studio site and cloud. In this case
-you need to run Site Sync as a background process from a command line (via service etc) 24/7.
+There might be a use case where you need to synchronize between "non-artist"
+sites, for example between studio site and cloud. In this case
+you need to run Site Sync as a background process from a command line (via
+service etc) 24/7.
-To configure all sites where all published files should be synced eventually you need to configure `project_settings/global/sync_server/config/always_accessible_on` property in Settings (per project) first.
+To configure all sites where all published files should be synced eventually
+you need to
+configure `project_settings/global/sync_server/config/always_accessible_on`
+property in Settings (per project) first.

This is an example of:
+
- Site Sync is enabled for a project
-- default active and remote sites are set to `studio` - eg. standard process: everyone is working in a studio, publishing to shared location etc.
-- (but this also allows any of the artists to work remotely, they would change their active site in their own Local Settings to `local` and configure local root.
- This would result in everything artist publishes is saved first onto his local folder AND synchronized to `studio` site eventually.)
+- default active and remote sites are set to `studio` - eg. standard process:
+ everyone is working in a studio, publishing to shared location etc.
+- (but this also allows any of the artists to work remotely, they would change
+ their active site in their own Local Settings to `local` and configure local
+ root.
+ This would result in everything artist publishes is saved first onto his
+ local folder AND synchronized to `studio` site eventually.)
- everything exported must also be eventually uploaded to `sftp` site
-This eventual synchronization between `studio` and `sftp` sites must be physically handled by background process.
+This eventual synchronization between `studio` and `sftp` sites must be
+physically handled by background process.
-As current implementation relies heavily on Settings and Local Settings, background process for a specific site ('studio' for example) must be configured via Tray first to `syncserver` command to work.
+As current implementation relies heavily on Settings and Local Settings,
+background process for a specific site ('studio' for example) must be
+configured via Tray first to `syncserver` command to work.
To do this:
-- run OP `Tray` with environment variable OPENPYPE_LOCAL_ID set to name of active (source) site. In most use cases it would be studio (for cases of backups of everything published to studio site to different cloud site etc.)
+- run OP `Tray` with environment variable OPENPYPE_LOCAL_ID set to name of
+ active (source) site. In most use cases it would be studio (for cases of
+ backups of everything published to studio site to different cloud site etc.)
- start `Tray`
-- check `Local ID` in information dialog after clicking on version number in the Tray
+- check `Local ID` in information dialog after clicking on version number in
+ the Tray
- open `Local Settings` in the `Tray`
- configure for each project necessary active site and remote site
- close `Tray`
- run OP from a command line with `syncserver` and `--active_site` arguments
-
-This is an example how to trigger background syncing process where active (source) site is `studio`.
-(It is expected that OP is installed on a machine, `openpype_console` is on PATH. If not, add full path to executable.
+This is an example how to trigger background syncing process where active (
+source) site is `studio`.
+(It is expected that OP is installed on a machine, `openpype_console` is on
+PATH. If not, add full path to executable.
)
+
```shell
openpype_console syncserver --active_site studio
-```
\ No newline at end of file
+```
+
+### Syncing of last published workfile
+
+Some DCC might have enabled
+in `project_setting/global/tools/Workfiles/last_workfile_on_startup`, eg. open
+DCC with last opened workfile.
+
+Flag `use_last_published_workfile` tells that last published workfile should be
+used if no workfile is present locally.
+This use case could happen if artists starts working on new task locally,
+doesn't have any workfile present. In that case last published will be
+synchronized locally and its version bumped by 1 (as workfile's version is
+always +1 from published version).
\ No newline at end of file
diff --git a/website/sidebars.js b/website/sidebars.js
index 93887e00f6..4874782197 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -126,6 +126,7 @@ module.exports = {
"admin_hosts_nuke",
"admin_hosts_resolve",
"admin_hosts_harmony",
+ "admin_hosts_photoshop",
"admin_hosts_aftereffects",
"admin_hosts_tvpaint"
],