Merge branch 'develop' into enhancement/OP-2956_move-host-install

This commit is contained in:
Jakub Trllo 2022-04-06 13:59:06 +02:00
commit 2d9b13c2dd
56 changed files with 1204 additions and 753 deletions

View file

@ -1,34 +1,79 @@
# Changelog
## [3.9.2-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.9.3-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.2...HEAD)
### 📖 Documentation
- Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951)
- Website Docs: Manager Ftrack fix broken links [\#2979](https://github.com/pypeclub/OpenPype/pull/2979)
**🆕 New features**
- Multiverse: First PR [\#2908](https://github.com/pypeclub/OpenPype/pull/2908)
- Publishing textures for Unreal [\#2988](https://github.com/pypeclub/OpenPype/pull/2988)
- Maya to Unreal \> Static and Skeletal Meshes [\#2978](https://github.com/pypeclub/OpenPype/pull/2978)
**🚀 Enhancements**
- Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945)
- NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943)
- Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925)
- General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923)
- CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919)
- Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916)
- Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911)
- Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906)
- Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903)
- Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901)
- Nuke: add concurrency attr to deadline job [\#3005](https://github.com/pypeclub/OpenPype/pull/3005)
- Workfiles tool: Save as published workfiles [\#2937](https://github.com/pypeclub/OpenPype/pull/2937)
**🐛 Bug fixes**
- Ftrack: multiple reviewable componets [\#3012](https://github.com/pypeclub/OpenPype/pull/3012)
- Tray publisher: Fixes after code movement [\#3010](https://github.com/pypeclub/OpenPype/pull/3010)
- Nuke: fixing unicode type detection in effect loaders [\#3002](https://github.com/pypeclub/OpenPype/pull/3002)
- Nuke: removing redundant Ftrack asset when farm publishing [\#2996](https://github.com/pypeclub/OpenPype/pull/2996)
**Merged pull requests:**
- General: adding limitations for pyright [\#2994](https://github.com/pypeclub/OpenPype/pull/2994)
## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.2-nightly.4...3.9.2)
### 📖 Documentation
- Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999)
- Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951)
- Documentation: New publisher develop docs [\#2896](https://github.com/pypeclub/OpenPype/pull/2896)
**🆕 New features**
- nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992)
- Multiverse: Initial Support [\#2908](https://github.com/pypeclub/OpenPype/pull/2908)
**🚀 Enhancements**
- Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001)
- TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000)
- Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985)
- General: `METADATA\_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980)
- General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975)
- Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967)
- Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945)
- NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943)
- TVPaint: Extractor to convert PNG into EXR [\#2942](https://github.com/pypeclub/OpenPype/pull/2942)
- Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925)
- General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923)
- Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911)
- Flame: support for comment with xml attribute overrides [\#2892](https://github.com/pypeclub/OpenPype/pull/2892)
**🐛 Bug fixes**
- Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004)
- Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998)
- PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991)
- Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990)
- AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989)
- Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986)
- Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981)
- Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969)
- Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965)
- General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958)
- Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956)
- General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950)
- LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949)
- nuke: python3 compatibility issue with `iteritems` [\#2948](https://github.com/pypeclub/OpenPype/pull/2948)
- General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947)
@ -39,12 +84,11 @@
- Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934)
- Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932)
- General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926)
- Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924)
- Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905)
- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875)
- Flame: centos related debugging [\#2922](https://github.com/pypeclub/OpenPype/pull/2922)
**🔀 Refactored code**
- General: Move plugins register and discover [\#2935](https://github.com/pypeclub/OpenPype/pull/2935)
- General: Move Attribute Definitions from pipeline [\#2931](https://github.com/pypeclub/OpenPype/pull/2931)
- General: Removed silo references and terminal splash [\#2927](https://github.com/pypeclub/OpenPype/pull/2927)
- General: Move pipeline constants to OpenPype [\#2918](https://github.com/pypeclub/OpenPype/pull/2918)
@ -53,6 +97,9 @@
**Merged pull requests:**
- Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973)
- Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954)
- Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953)
- Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952)
## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18)
@ -62,8 +109,8 @@
**🚀 Enhancements**
- General: Change how OPENPYPE\_DEBUG value is handled [\#2907](https://github.com/pypeclub/OpenPype/pull/2907)
- Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903)
- nuke: imageio adding ocio config version 1.2 [\#2897](https://github.com/pypeclub/OpenPype/pull/2897)
- Flame: support for comment with xml attribute overrides [\#2892](https://github.com/pypeclub/OpenPype/pull/2892)
- Nuke: ExtractReviewSlate can handle more codes and profiles [\#2879](https://github.com/pypeclub/OpenPype/pull/2879)
- Flame: sequence used for reference video [\#2869](https://github.com/pypeclub/OpenPype/pull/2869)
@ -77,7 +124,6 @@
- General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885)
- General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884)
- Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874)
- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826)
**🔀 Refactored code**
@ -88,10 +134,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.0-nightly.9...3.9.0)
**Deprecated:**
- AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845)
### 📖 Documentation
- Documentation: Change Photoshop & AfterEffects plugin path [\#2878](https://github.com/pypeclub/OpenPype/pull/2878)
@ -100,12 +142,6 @@
- General: Subset name filtering in ExtractReview outpus [\#2872](https://github.com/pypeclub/OpenPype/pull/2872)
- NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867)
- NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863)
- TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859)
- New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841)
- Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837)
- Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836)
- General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822)
**🐛 Bug fixes**
@ -113,35 +149,10 @@
- Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868)
- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866)
- General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864)
- General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860)
- WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858)
- New Publisher: Error dialog got right styles [\#2857](https://github.com/pypeclub/OpenPype/pull/2857)
- General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855)
- Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853)
- WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852)
- WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851)
- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847)
- Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842)
- Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840)
- Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832)
- Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828)
- Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827)
- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825)
- Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824)
- General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821)
- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820)
- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819)
- Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818)
**🔀 Refactored code**
- Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876)
- General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854)
- General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848)
- General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846)
- General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839)
- Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829)
- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823)
## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07)

View file

@ -29,12 +29,12 @@ def add_implementation_envs(env, _app):
env.get("OPENPYPE_BLENDER_USER_SCRIPTS") or ""
)
for path in openpype_blender_user_scripts.split(os.pathsep):
if path and os.path.exists(path):
if path:
previous_user_scripts.add(os.path.normpath(path))
blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or ""
for path in blender_user_scripts.split(os.pathsep):
if path and os.path.exists(path):
if path:
previous_user_scripts.add(os.path.normpath(path))
# Remove implementation path from user script paths as is set to

View file

@ -10,7 +10,7 @@ def add_implementation_envs(env, _app):
]
old_hiero_path = env.get("HIERO_PLUGIN_PATH") or ""
for path in old_hiero_path.split(os.pathsep):
if not path or not os.path.exists(path):
if not path:
continue
norm_path = os.path.normpath(path)

View file

@ -15,7 +15,7 @@ def add_implementation_envs(env, _app):
old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or ""
for path in old_houdini_path.split(os.pathsep):
if not path or not os.path.exists(path):
if not path:
continue
norm_path = os.path.normpath(path)
@ -23,7 +23,7 @@ def add_implementation_envs(env, _app):
new_houdini_path.append(norm_path)
for path in old_houdini_menu_path.split(os.pathsep):
if not path or not os.path.exists(path):
if not path:
continue
norm_path = os.path.normpath(path)

View file

@ -9,7 +9,7 @@ def add_implementation_envs(env, _app):
]
old_python_path = env.get("PYTHONPATH") or ""
for path in old_python_path.split(os.pathsep):
if not path or not os.path.exists(path):
if not path:
continue
norm_path = os.path.normpath(path)

View file

@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
"""Tools to work with FBX."""
import logging
from pyblish.api import Instance
from maya import cmds # noqa
import maya.mel as mel # noqa
class FBXExtractor:
"""Extract FBX from Maya.
This extracts reproducible FBX exports ignoring any of the settings set
on the local machine in the FBX export options window.
All export settings are applied with the `FBXExport*` commands prior
to the `FBXExport` call itself. The options can be overridden with
their
nice names as seen in the "options" property on this class.
For more information on FBX exports see:
- https://knowledge.autodesk.com/support/maya/learn-explore/caas
/CloudHelp/cloudhelp/2016/ENU/Maya/files/GUID-6CCE943A-2ED4-4CEE-96D4
-9CB19C28F4E0-htm.html
- http://forums.cgsociety.org/archive/index.php?t-1032853.html
- https://groups.google.com/forum/#!msg/python_inside_maya/cLkaSo361oE
/LKs9hakE28kJ
"""
@property
def options(self):
"""Overridable options for FBX Export
Given in the following format
- {NAME: EXPECTED TYPE}
If the overridden option's type does not match,
the option is not included and a warning is logged.
"""
return {
"cameras": bool,
"smoothingGroups": bool,
"hardEdges": bool,
"tangents": bool,
"smoothMesh": bool,
"instances": bool,
# "referencedContainersContent": bool, # deprecated in Maya 2016+
"bakeComplexAnimation": int,
"bakeComplexStart": int,
"bakeComplexEnd": int,
"bakeComplexStep": int,
"bakeResampleAnimation": bool,
"animationOnly": bool,
"useSceneName": bool,
"quaternion": str, # "euler"
"shapes": bool,
"skins": bool,
"constraints": bool,
"lights": bool,
"embeddedTextures": bool,
"inputConnections": bool,
"upAxis": str, # x, y or z,
"triangulate": bool
}
@property
def default_options(self):
"""The default options for FBX extraction.
This includes shapes, skins, constraints, lights and incoming
connections and exports with the Y-axis as up-axis.
By default this uses the time sliders start and end time.
"""
start_frame = int(cmds.playbackOptions(query=True,
animationStartTime=True))
end_frame = int(cmds.playbackOptions(query=True,
animationEndTime=True))
return {
"cameras": False,
"smoothingGroups": True,
"hardEdges": False,
"tangents": False,
"smoothMesh": True,
"instances": False,
"bakeComplexAnimation": True,
"bakeComplexStart": start_frame,
"bakeComplexEnd": end_frame,
"bakeComplexStep": 1,
"bakeResampleAnimation": True,
"animationOnly": False,
"useSceneName": False,
"quaternion": "euler",
"shapes": True,
"skins": True,
"constraints": False,
"lights": True,
"embeddedTextures": False,
"inputConnections": True,
"upAxis": "y",
"triangulate": False
}
def __init__(self, log=None):
# Ensure FBX plug-in is loaded
self.log = log or logging.getLogger(self.__class__.__name__)
cmds.loadPlugin("fbxmaya", quiet=True)
def parse_overrides(self, instance, options):
"""Inspect data of instance to determine overridden options
An instance may supply any of the overridable options
as data, the option is then added to the extraction.
"""
for key in instance.data:
if key not in self.options:
continue
# Ensure the data is of correct type
value = instance.data[key]
if not isinstance(value, self.options[key]):
self.log.warning(
"Overridden attribute {key} was of "
"the wrong type: {invalid_type} "
"- should have been {valid_type}".format(
key=key,
invalid_type=type(value).__name__,
valid_type=self.options[key].__name__))
continue
options[key] = value
return options
def set_options_from_instance(self, instance):
# type: (Instance) -> None
"""Sets FBX export options from data in the instance.
Args:
instance (Instance): Instance data.
"""
# Parse export options
options = self.default_options
options = self.parse_overrides(instance, options)
self.log.info("Export options: {0}".format(options))
# Collect the start and end including handles
start = instance.data.get("frameStartHandle") or \
instance.context.data.get("frameStartHandle")
end = instance.data.get("frameEndHandle") or \
instance.context.data.get("frameEndHandle")
options['bakeComplexStart'] = start
options['bakeComplexEnd'] = end
# First apply the default export settings to be fully consistent
# each time for successive publishes
mel.eval("FBXResetExport")
# Apply the FBX overrides through MEL since the commands
# only work correctly in MEL according to online
# available discussions on the topic
_iteritems = getattr(options, "iteritems", options.items)
for option, value in _iteritems():
key = option[0].upper() + option[1:] # uppercase first letter
# Boolean must be passed as lower-case strings
# as to MEL standards
if isinstance(value, bool):
value = str(value).lower()
template = "FBXExport{0} {1}" if key == "UpAxis" else \
"FBXExport{0} -v {1}" # noqa
cmd = template.format(key, value)
self.log.info(cmd)
mel.eval(cmd)
# Never show the UI or generate a log
mel.eval("FBXExportShowUI -v false")
mel.eval("FBXExportGenerateLog -v false")
@staticmethod
def export(members, path):
# type: (list, str) -> None
"""Export members as FBX with given path.
Args:
members (list): List of members to export.
path (str): Path to use for export.
"""
cmds.select(members, r=True, noExpand=True)
mel.eval('FBXExport -f "{}" -s'.format(path))

View file

@ -3139,11 +3139,20 @@ def set_colorspace():
@contextlib.contextmanager
def root_parent(nodes):
# type: (list) -> list
def parent_nodes(nodes, parent=None):
# type: (list, str) -> list
"""Context manager to un-parent provided nodes and return them back."""
import pymel.core as pm # noqa
parent_node = None
delete_parent = False
if parent:
if not cmds.objExists(parent):
parent_node = pm.createNode("transform", n=parent, ss=False)
delete_parent = True
else:
parent_node = pm.PyNode(parent)
node_parents = []
for node in nodes:
n = pm.PyNode(node)
@ -3154,9 +3163,14 @@ def root_parent(nodes):
node_parents.append((n, root))
try:
for node in node_parents:
node[0].setParent(world=True)
if not parent:
node[0].setParent(world=True)
else:
node[0].setParent(parent_node)
yield
finally:
for node in node_parents:
if node[1]:
node[0].setParent(node[1])
if delete_parent:
pm.delete(parent_node)

View file

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Creator for Unreal Skeletal Meshes."""
from openpype.hosts.maya.api import plugin, lib
from avalon.api import Session
from maya import cmds # noqa
class CreateUnrealSkeletalMesh(plugin.Creator):
"""Unreal Static Meshes with collisions."""
name = "staticMeshMain"
label = "Unreal - Skeletal Mesh"
family = "skeletalMesh"
icon = "thumbs-up"
dynamic_subset_keys = ["asset"]
joint_hints = []
def __init__(self, *args, **kwargs):
"""Constructor."""
super(CreateUnrealSkeletalMesh, self).__init__(*args, **kwargs)
@classmethod
def get_dynamic_data(
cls, variant, task_name, asset_id, project_name, host_name
):
dynamic_data = super(CreateUnrealSkeletalMesh, cls).get_dynamic_data(
variant, task_name, asset_id, project_name, host_name
)
dynamic_data["asset"] = Session.get("AVALON_ASSET")
return dynamic_data
def process(self):
self.name = "{}_{}".format(self.family, self.name)
with lib.undo_chunk():
instance = super(CreateUnrealSkeletalMesh, self).process()
content = cmds.sets(instance, query=True)
# empty set and process its former content
cmds.sets(content, rm=instance)
geometry_set = cmds.sets(name="geometry_SET", empty=True)
joints_set = cmds.sets(name="joints_SET", empty=True)
cmds.sets([geometry_set, joints_set], forceElement=instance)
members = cmds.ls(content) or []
for node in members:
if node in self.joint_hints:
cmds.sets(node, forceElement=joints_set)
else:
cmds.sets(node, forceElement=geometry_set)

View file

@ -10,7 +10,7 @@ class CreateUnrealStaticMesh(plugin.Creator):
"""Unreal Static Meshes with collisions."""
name = "staticMeshMain"
label = "Unreal - Static Mesh"
family = "unrealStaticMesh"
family = "staticMesh"
icon = "cube"
dynamic_subset_keys = ["asset"]
@ -28,10 +28,10 @@ class CreateUnrealStaticMesh(plugin.Creator):
variant, task_name, asset_id, project_name, host_name
)
dynamic_data["asset"] = Session.get("AVALON_ASSET")
return dynamic_data
def process(self):
self.name = "{}_{}".format(self.family, self.name)
with lib.undo_chunk():
instance = super(CreateUnrealStaticMesh, self).process()
content = cmds.sets(instance, query=True)

View file

@ -22,7 +22,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
"camera",
"rig",
"camerarig",
"xgen"]
"xgen",
"staticMesh"]
representations = ["ma", "abc", "fbx", "mb"]
label = "Reference"

View file

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
"""Cleanup leftover nodes."""
from maya import cmds # noqa
import pyblish.api
class CleanNodesUp(pyblish.api.InstancePlugin):
"""Cleans up the staging directory after a successful publish.
This will also clean published renders and delete their parent directories.
"""
order = pyblish.api.IntegratorOrder + 10
label = "Clean Nodes"
optional = True
active = True
def process(self, instance):
if not instance.data.get("cleanNodes"):
self.log.info("Nothing to clean.")
return
nodes_to_clean = instance.data.pop("cleanNodes", [])
self.log.info("Removing {} nodes".format(len(nodes_to_clean)))
for node in nodes_to_clean:
try:
cmds.delete(node)
except ValueError:
# object might be already deleted, don't complain about it
pass

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from maya import cmds # noqa
import pyblish.api
class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin):
"""Collect Unreal Skeletal Mesh."""
order = pyblish.api.CollectorOrder + 0.2
label = "Collect Unreal Skeletal Meshes"
families = ["skeletalMesh"]
def process(self, instance):
frame = cmds.currentTime(query=True)
instance.data["frameStart"] = frame
instance.data["frameEnd"] = frame
geo_sets = [
i for i in instance[:]
if i.lower().startswith("geometry_set")
]
joint_sets = [
i for i in instance[:]
if i.lower().startswith("joints_set")
]
instance.data["geometry"] = []
instance.data["joints"] = []
for geo_set in geo_sets:
geo_content = cmds.ls(cmds.sets(geo_set, query=True), long=True)
if geo_content:
instance.data["geometry"] += geo_content
for join_set in joint_sets:
join_content = cmds.ls(cmds.sets(join_set, query=True), long=True)
if join_content:
instance.data["joints"] += join_content

View file

@ -1,38 +1,36 @@
# -*- coding: utf-8 -*-
from maya import cmds
from maya import cmds # noqa
import pyblish.api
from pprint import pformat
class CollectUnrealStaticMesh(pyblish.api.InstancePlugin):
"""Collect Unreal Static Mesh
Ensures always only a single frame is extracted (current frame). This
also sets correct FBX options for later extraction.
"""
"""Collect Unreal Static Mesh."""
order = pyblish.api.CollectorOrder + 0.2
label = "Collect Unreal Static Meshes"
families = ["unrealStaticMesh"]
families = ["staticMesh"]
def process(self, instance):
# add fbx family to trigger fbx extractor
instance.data["families"].append("fbx")
# take the name from instance (without the `S_` prefix)
instance.data["staticMeshCombinedName"] = instance.name[2:]
geometry_set = [i for i in instance if i == "geometry_SET"]
instance.data["membersToCombine"] = cmds.sets(
geometry_set = [
i for i in instance
if i.startswith("geometry_SET")
]
instance.data["geometryMembers"] = cmds.sets(
geometry_set, query=True)
collision_set = [i for i in instance if i == "collisions_SET"]
self.log.info("geometry: {}".format(
pformat(instance.data.get("geometryMembers"))))
collision_set = [
i for i in instance
if i.startswith("collisions_SET")
]
instance.data["collisionMembers"] = cmds.sets(
collision_set, query=True)
# set fbx overrides on instance
instance.data["smoothingGroups"] = True
instance.data["smoothMesh"] = True
instance.data["triangulate"] = True
self.log.info("collisions: {}".format(
pformat(instance.data.get("collisionMembers"))))
frame = cmds.currentTime(query=True)
instance.data["frameStart"] = frame

View file

@ -5,152 +5,29 @@ from maya import cmds # noqa
import maya.mel as mel # noqa
import pyblish.api
import openpype.api
from openpype.hosts.maya.api.lib import (
root_parent,
maintained_selection
)
from openpype.hosts.maya.api.lib import maintained_selection
from openpype.hosts.maya.api import fbx
class ExtractFBX(openpype.api.Extractor):
"""Extract FBX from Maya.
This extracts reproducible FBX exports ignoring any of the settings set
on the local machine in the FBX export options window.
All export settings are applied with the `FBXExport*` commands prior
to the `FBXExport` call itself. The options can be overridden with their
nice names as seen in the "options" property on this class.
For more information on FBX exports see:
- https://knowledge.autodesk.com/support/maya/learn-explore/caas
/CloudHelp/cloudhelp/2016/ENU/Maya/files/GUID-6CCE943A-2ED4-4CEE-96D4
-9CB19C28F4E0-htm.html
- http://forums.cgsociety.org/archive/index.php?t-1032853.html
- https://groups.google.com/forum/#!msg/python_inside_maya/cLkaSo361oE
/LKs9hakE28kJ
This extracts reproducible FBX exports ignoring any of the
settings set on the local machine in the FBX export options window.
"""
order = pyblish.api.ExtractorOrder
label = "Extract FBX"
families = ["fbx"]
@property
def options(self):
"""Overridable options for FBX Export
Given in the following format
- {NAME: EXPECTED TYPE}
If the overridden option's type does not match,
the option is not included and a warning is logged.
"""
return {
"cameras": bool,
"smoothingGroups": bool,
"hardEdges": bool,
"tangents": bool,
"smoothMesh": bool,
"instances": bool,
# "referencedContainersContent": bool, # deprecated in Maya 2016+
"bakeComplexAnimation": int,
"bakeComplexStart": int,
"bakeComplexEnd": int,
"bakeComplexStep": int,
"bakeResampleAnimation": bool,
"animationOnly": bool,
"useSceneName": bool,
"quaternion": str, # "euler"
"shapes": bool,
"skins": bool,
"constraints": bool,
"lights": bool,
"embeddedTextures": bool,
"inputConnections": bool,
"upAxis": str, # x, y or z,
"triangulate": bool
}
@property
def default_options(self):
"""The default options for FBX extraction.
This includes shapes, skins, constraints, lights and incoming
connections and exports with the Y-axis as up-axis.
By default this uses the time sliders start and end time.
"""
start_frame = int(cmds.playbackOptions(query=True,
animationStartTime=True))
end_frame = int(cmds.playbackOptions(query=True,
animationEndTime=True))
return {
"cameras": False,
"smoothingGroups": False,
"hardEdges": False,
"tangents": False,
"smoothMesh": False,
"instances": False,
"bakeComplexAnimation": True,
"bakeComplexStart": start_frame,
"bakeComplexEnd": end_frame,
"bakeComplexStep": 1,
"bakeResampleAnimation": True,
"animationOnly": False,
"useSceneName": False,
"quaternion": "euler",
"shapes": True,
"skins": True,
"constraints": False,
"lights": True,
"embeddedTextures": True,
"inputConnections": True,
"upAxis": "y",
"triangulate": False
}
def parse_overrides(self, instance, options):
"""Inspect data of instance to determine overridden options
An instance may supply any of the overridable options
as data, the option is then added to the extraction.
"""
for key in instance.data:
if key not in self.options:
continue
# Ensure the data is of correct type
value = instance.data[key]
if not isinstance(value, self.options[key]):
self.log.warning(
"Overridden attribute {key} was of "
"the wrong type: {invalid_type} "
"- should have been {valid_type}".format(
key=key,
invalid_type=type(value).__name__,
valid_type=self.options[key].__name__))
continue
options[key] = value
return options
def process(self, instance):
# Ensure FBX plug-in is loaded
cmds.loadPlugin("fbxmaya", quiet=True)
fbx_exporter = fbx.FBXExtractor(log=self.log)
# Define output path
stagingDir = self.staging_dir(instance)
staging_dir = self.staging_dir(instance)
filename = "{0}.fbx".format(instance.name)
path = os.path.join(stagingDir, filename)
path = os.path.join(staging_dir, filename)
# The export requires forward slashes because we need
# to format it into a string in a mel expression
@ -162,54 +39,13 @@ class ExtractFBX(openpype.api.Extractor):
self.log.info("Members: {0}".format(members))
self.log.info("Instance: {0}".format(instance[:]))
# Parse export options
options = self.default_options
options = self.parse_overrides(instance, options)
self.log.info("Export options: {0}".format(options))
# Collect the start and end including handles
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
options['bakeComplexStart'] = start
options['bakeComplexEnd'] = end
# First apply the default export settings to be fully consistent
# each time for successive publishes
mel.eval("FBXResetExport")
# Apply the FBX overrides through MEL since the commands
# only work correctly in MEL according to online
# available discussions on the topic
_iteritems = getattr(options, "iteritems", options.items)
for option, value in _iteritems():
key = option[0].upper() + option[1:] # uppercase first letter
# Boolean must be passed as lower-case strings
# as to MEL standards
if isinstance(value, bool):
value = str(value).lower()
template = "FBXExport{0} {1}" if key == "UpAxis" else "FBXExport{0} -v {1}" # noqa
cmd = template.format(key, value)
self.log.info(cmd)
mel.eval(cmd)
# Never show the UI or generate a log
mel.eval("FBXExportShowUI -v false")
mel.eval("FBXExportGenerateLog -v false")
fbx_exporter.set_options_from_instance(instance)
# Export
if "unrealStaticMesh" in instance.data["families"]:
with maintained_selection():
with root_parent(members):
self.log.info("Un-parenting: {}".format(members))
cmds.select(members, r=1, noExpand=True)
mel.eval('FBXExport -f "{}" -s'.format(path))
else:
with maintained_selection():
cmds.select(members, r=1, noExpand=True)
mel.eval('FBXExport -f "{}" -s'.format(path))
with maintained_selection():
fbx_exporter.export(members, path)
cmds.select(members, r=1, noExpand=True)
mel.eval('FBXExport -f "{}" -s'.format(path))
if "representations" not in instance.data:
instance.data["representations"] = []
@ -218,7 +54,7 @@ class ExtractFBX(openpype.api.Extractor):
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": stagingDir,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)

View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""Create Unreal Skeletal Mesh data to be extracted as FBX."""
import os
from contextlib import contextmanager
from maya import cmds # noqa
import pyblish.api
import openpype.api
from openpype.hosts.maya.api import fbx
@contextmanager
def renamed(original_name, renamed_name):
# type: (str, str) -> None
try:
cmds.rename(original_name, renamed_name)
yield
finally:
cmds.rename(renamed_name, original_name)
class ExtractUnrealSkeletalMesh(openpype.api.Extractor):
"""Extract Unreal Skeletal Mesh as FBX from Maya. """
order = pyblish.api.ExtractorOrder - 0.1
label = "Extract Unreal Skeletal Mesh"
families = ["skeletalMesh"]
def process(self, instance):
fbx_exporter = fbx.FBXExtractor(log=self.log)
# Define output path
staging_dir = self.staging_dir(instance)
filename = "{0}.fbx".format(instance.name)
path = os.path.join(staging_dir, filename)
geo = instance.data.get("geometry")
joints = instance.data.get("joints")
to_extract = geo + joints
# The export requires forward slashes because we need
# to format it into a string in a mel expression
path = path.replace('\\', '/')
self.log.info("Extracting FBX to: {0}".format(path))
self.log.info("Members: {0}".format(to_extract))
self.log.info("Instance: {0}".format(instance[:]))
fbx_exporter.set_options_from_instance(instance)
# This magic is done for variants. To let Unreal merge correctly
# existing data, top node must have the same name. So for every
# variant we extract we need to rename top node of the rig correctly.
# It is finally done in context manager so it won't affect current
# scene.
# we rely on hierarchy under one root.
original_parent = to_extract[0].split("|")[1]
parent_node = instance.data.get("asset")
renamed_to_extract = []
for node in to_extract:
node_path = node.split("|")
node_path[1] = parent_node
renamed_to_extract.append("|".join(node_path))
with renamed(original_parent, parent_node):
self.log.info("Extracting: {}".format(renamed_to_extract, path))
fbx_exporter.export(renamed_to_extract, path)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)
self.log.info("Extract FBX successful to: {0}".format(path))

View file

@ -1,33 +1,61 @@
# -*- coding: utf-8 -*-
"""Create Unreal Static Mesh data to be extracted as FBX."""
import openpype.api
import pyblish.api
import os
from maya import cmds # noqa
import pyblish.api
import openpype.api
from openpype.hosts.maya.api.lib import (
parent_nodes,
maintained_selection
)
from openpype.hosts.maya.api import fbx
class ExtractUnrealStaticMesh(openpype.api.Extractor):
"""Extract FBX from Maya. """
"""Extract Unreal Static Mesh as FBX from Maya. """
order = pyblish.api.ExtractorOrder - 0.1
label = "Extract Unreal Static Mesh"
families = ["unrealStaticMesh"]
families = ["staticMesh"]
def process(self, instance):
to_combine = instance.data.get("membersToCombine")
static_mesh_name = instance.data.get("staticMeshCombinedName")
self.log.info(
"merging {} into {}".format(
" + ".join(to_combine), static_mesh_name))
duplicates = cmds.duplicate(to_combine, ic=True)
cmds.polyUnite(
*duplicates,
n=static_mesh_name, ch=False)
members = instance.data.get("geometryMembers", [])
if instance.data.get("collisionMembers"):
members = members + instance.data.get("collisionMembers")
if not instance.data.get("cleanNodes"):
instance.data["cleanNodes"] = []
fbx_exporter = fbx.FBXExtractor(log=self.log)
instance.data["cleanNodes"].append(static_mesh_name)
instance.data["cleanNodes"] += duplicates
# Define output path
staging_dir = self.staging_dir(instance)
filename = "{0}.fbx".format(instance.name)
path = os.path.join(staging_dir, filename)
instance.data["setMembers"] = [static_mesh_name]
instance.data["setMembers"] += instance.data["collisionMembers"]
# The export requires forward slashes because we need
# to format it into a string in a mel expression
path = path.replace('\\', '/')
self.log.info("Extracting FBX to: {0}".format(path))
self.log.info("Members: {0}".format(members))
self.log.info("Instance: {0}".format(instance[:]))
fbx_exporter.set_options_from_instance(instance)
with maintained_selection():
with parent_nodes(members):
self.log.info("Un-parenting: {}".format(members))
fbx_exporter.export(members, path)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)
self.log.info("Extract FBX successful to: {0}".format(path))

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Skeletal Mesh Top Node</title>
<description>## Skeletal meshes needs common root
Skeletal meshes and their joints must be under one common root.
### How to repair?
Make sure all geometry and joints resides under same root.
</description>
</error>
</root>

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
from maya import cmds
class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin):
"""Validates that nodes has common root."""
order = openpype.api.ValidateContentsOrder
hosts = ["maya"]
families = ["skeletalMesh"]
label = "Skeletal Mesh Top Node"
def process(self, instance):
geo = instance.data.get("geometry")
joints = instance.data.get("joints")
joints_parents = cmds.ls(joints, long=True)
geo_parents = cmds.ls(geo, long=True)
parents_set = {
parent.split("|")[1] for parent in (joints_parents + geo_parents)
}
if len(set(parents_set)) != 1:
raise PublishXmlValidationError(
self,
"Multiple roots on geometry or joints."
)

View file

@ -10,10 +10,11 @@ class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin):
order = openpype.api.ValidateMeshOrder
hosts = ["maya"]
families = ["unrealStaticMesh"]
families = ["staticMesh"]
category = "geometry"
label = "Mesh is Triangulated"
actions = [openpype.hosts.maya.api.action.SelectInvalidAction]
active = False
@classmethod
def get_invalid(cls, instance):

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Validator for correct naming of Static Meshes."""
from maya import cmds # noqa
import pyblish.api
import openpype.api
@ -52,8 +52,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
optional = True
order = openpype.api.ValidateContentsOrder
hosts = ["maya"]
families = ["unrealStaticMesh"]
label = "Unreal StaticMesh Name"
families = ["staticMesh"]
label = "Unreal Static Mesh Name"
actions = [openpype.hosts.maya.api.action.SelectInvalidAction]
regex_mesh = r"(?P<renderName>.*))"
regex_collision = r"(?P<renderName>.*)"
@ -72,15 +72,13 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
["collision_prefixes"]
)
combined_geometry_name = instance.data.get(
"staticMeshCombinedName", None)
if cls.validate_mesh:
# compile regex for testing names
regex_mesh = "{}{}".format(
("_" + cls.static_mesh_prefix) or "", cls.regex_mesh
)
sm_r = re.compile(regex_mesh)
if not sm_r.match(combined_geometry_name):
if not sm_r.match(instance.data.get("subset")):
cls.log.error("Mesh doesn't comply with name validation.")
return True
@ -91,7 +89,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
cls.log.warning("No collision objects to validate.")
return False
regex_collision = "{}{}".format(
regex_collision = "{}{}_(\\d+)".format(
"(?P<prefix>({}))_".format(
"|".join("{0}".format(p) for p in collision_prefixes)
) or "", cls.regex_collision
@ -99,6 +97,9 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
cl_r = re.compile(regex_collision)
mesh_name = "{}{}".format(instance.data["asset"],
instance.data.get("variant", []))
for obj in collision_set:
cl_m = cl_r.match(obj)
if not cl_m:
@ -107,7 +108,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
else:
expected_collision = "{}_{}".format(
cl_m.group("prefix"),
combined_geometry_name
mesh_name
)
if not obj.startswith(expected_collision):
@ -116,11 +117,11 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin):
"Collision object name doesn't match "
"static mesh name"
)
cls.log.error("{}_{} != {}_{}".format(
cls.log.error("{}_{} != {}_{}*".format(
cl_m.group("prefix"),
cl_m.group("renderName"),
cl_m.group("prefix"),
combined_geometry_name,
mesh_name,
))
invalid.append(obj)

View file

@ -9,9 +9,10 @@ class ValidateUnrealUpAxis(pyblish.api.ContextPlugin):
"""Validate if Z is set as up axis in Maya"""
optional = True
active = False
order = openpype.api.ValidateContentsOrder
hosts = ["maya"]
families = ["unrealStaticMesh"]
families = ["staticMesh"]
label = "Unreal Up-Axis check"
actions = [openpype.api.RepairAction]

View file

@ -10,7 +10,7 @@ def add_implementation_envs(env, _app):
]
old_nuke_path = env.get("NUKE_PATH") or ""
for path in old_nuke_path.split(os.pathsep):
if not path or not os.path.exists(path):
if not path:
continue
norm_path = os.path.normpath(path)

View file

@ -1048,17 +1048,28 @@ def add_review_knob(node):
def add_deadline_tab(node):
node.addKnob(nuke.Tab_Knob("Deadline"))
knob = nuke.Int_Knob("deadlineChunkSize", "Chunk Size")
knob.setValue(0)
node.addKnob(knob)
knob = nuke.Int_Knob("deadlinePriority", "Priority")
knob.setValue(50)
node.addKnob(knob)
knob = nuke.Int_Knob("deadlineChunkSize", "Chunk Size")
knob.setValue(0)
node.addKnob(knob)
knob = nuke.Int_Knob("deadlineConcurrentTasks", "Concurrent tasks")
# zero as default will get value from Settings during collection
# instead of being an explicit user override, see precollect_write.py
knob.setValue(0)
node.addKnob(knob)
def get_deadline_knob_names():
return ["Deadline", "deadlineChunkSize", "deadlinePriority"]
return [
"Deadline",
"deadlineChunkSize",
"deadlinePriority",
"deadlineConcurrentTasks"
]
def create_backdrop(label="", color=None, layer=0,

View file

@ -1,6 +1,7 @@
import json
from collections import OrderedDict
import nuke
import six
from avalon import io
@ -333,7 +334,7 @@ class LoadEffects(load.LoaderPlugin):
for key, value in input.items()}
elif isinstance(input, list):
return [self.byteify(element) for element in input]
elif isinstance(input, str):
elif isinstance(input, six.text_type):
return str(input)
else:
return input

View file

@ -1,6 +1,6 @@
import json
from collections import OrderedDict
import six
import nuke
from avalon import io
@ -353,7 +353,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin):
for key, value in input.items()}
elif isinstance(input, list):
return [self.byteify(element) for element in input]
elif isinstance(input, str):
elif isinstance(input, six.text_type):
return str(input)
else:
return input

View file

@ -1,5 +1,5 @@
import nuke
import six
from avalon import io
from openpype.pipeline import (
@ -243,8 +243,8 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
for key, value in input.items()}
elif isinstance(input, list):
return [self.byteify(element) for element in input]
elif isinstance(input, unicode):
return input.encode('utf-8')
elif isinstance(input, six.text_type):
return str(input)
else:
return input

View file

@ -123,6 +123,7 @@ class ExtractReviewDataMov(openpype.api.Extractor):
if generated_repres:
# assign to representations
instance.data["representations"] += generated_repres
instance.data["hasReviewableRepresentations"] = True
else:
instance.data["families"].remove("review")
self.log.info((

View file

@ -128,13 +128,17 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
}
group_node = [x for x in instance if x.Class() == "Group"][0]
deadlineChunkSize = 1
dl_chunk_size = 1
if "deadlineChunkSize" in group_node.knobs():
deadlineChunkSize = group_node["deadlineChunkSize"].value()
dl_chunk_size = group_node["deadlineChunkSize"].value()
deadlinePriority = 50
dl_priority = 50
if "deadlinePriority" in group_node.knobs():
deadlinePriority = group_node["deadlinePriority"].value()
dl_priority = group_node["deadlinePriority"].value()
dl_concurrent_tasks = 0
if "deadlineConcurrentTasks" in group_node.knobs():
dl_concurrent_tasks = group_node["deadlineConcurrentTasks"].value()
instance.data.update({
"versionData": version_data,
@ -144,8 +148,9 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
"label": label,
"outputType": output_type,
"colorspace": colorspace,
"deadlineChunkSize": deadlineChunkSize,
"deadlinePriority": deadlinePriority
"deadlineChunkSize": dl_chunk_size,
"deadlinePriority": dl_priority,
"deadlineConcurrentTasks": dl_concurrent_tasks
})
if self.is_prerender(_families_test):

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Collect original base name for use in templates."""
from pathlib import Path
import pyblish.api
class CollectOriginalBasename(pyblish.api.InstancePlugin):
"""Collect original file base name."""
order = pyblish.api.CollectorOrder + 0.498
label = "Collect Base Name"
hosts = ["standalonepublisher"]
families = ["simpleUnrealTexture"]
def process(self, instance):
file_name = Path(instance.data["representations"][0]["files"])
instance.data["originalBasename"] = file_name.stem

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Invalid texture name</title>
<description>
## Invalid file name
Submitted file has invalid name:
'{invalid_file}'
### How to repair?
Texture file must adhere to naming conventions for Unreal:
T_{asset}_*.ext
</description>
</error>
</root>

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""Validator for correct file naming."""
import pyblish.api
import openpype.api
import re
from openpype.pipeline import PublishXmlValidationError
class ValidateSimpleUnrealTextureNaming(pyblish.api.InstancePlugin):
label = "Validate Unreal Texture Names"
hosts = ["standalonepublisher"]
families = ["simpleUnrealTexture"]
order = openpype.api.ValidateContentsOrder
regex = "^T_{asset}.*"
def process(self, instance):
file_name = instance.data.get("originalBasename")
self.log.info(file_name)
pattern = self.regex.format(asset=instance.data.get("asset"))
if not re.match(pattern, file_name):
msg = f"Invalid file name {file_name}"
raise PublishXmlValidationError(
self, msg, formatting_data={
"invalid_file": file_name,
"asset": instance.data.get("asset")
})

View file

@ -7,7 +7,7 @@ from avalon import io
import avalon.api
import pyblish.api
from openpype.pipeline import BaseCreator
from openpype.pipeline import register_creator_plugin_path
ROOT_DIR = os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
@ -169,7 +169,7 @@ def install():
pyblish.api.register_host("traypublisher")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(BaseCreator, CREATE_PATH)
register_creator_plugin_path(CREATE_PATH)
def set_project_name(project_name):

View file

@ -14,11 +14,22 @@ class ValidateWorkfilePath(pyblish.api.InstancePlugin):
def process(self, instance):
filepath = instance.data["sourceFilepath"]
if not filepath:
raise PublishValidationError((
"Filepath of 'workfile' instance \"{}\" is not set"
).format(instance.data["name"]))
raise PublishValidationError(
(
"Filepath of 'workfile' instance \"{}\" is not set"
).format(instance.data["name"]),
"File not filled",
"## Missing file\nYou are supposed to fill the path."
)
if not os.path.exists(filepath):
raise PublishValidationError((
"Filepath of 'workfile' instance \"{}\" does not exist: {}"
).format(instance.data["name"], filepath))
raise PublishValidationError(
(
"Filepath of 'workfile' instance \"{}\" does not exist: {}"
).format(instance.data["name"], filepath),
"File not found",
(
"## File was not found\nFile \"{}\" was not found."
" Check if the path is still available."
).format(filepath)
)

View file

@ -27,6 +27,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
# presets
priority = 50
chunk_size = 1
concurrent_tasks = 1
primary_pool = ""
secondary_pool = ""
group = ""
@ -149,11 +150,16 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
pass
# define chunk and priority
chunk_size = instance.data.get("deadlineChunkSize")
chunk_size = instance.data["deadlineChunkSize"]
if chunk_size == 0 and self.chunk_size:
chunk_size = self.chunk_size
priority = instance.data.get("deadlinePriority")
# define chunk and priority
concurrent_tasks = instance.data["deadlineConcurrentTasks"]
if concurrent_tasks == 0 and self.concurrent_tasks:
concurrent_tasks = self.concurrent_tasks
priority = instance.data["deadlinePriority"]
if not priority:
priority = self.priority
@ -177,6 +183,8 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
"Priority": priority,
"ChunkSize": chunk_size,
"ConcurrentTasks": concurrent_tasks,
"Department": self.department,
"Pool": self.primary_pool,

View file

@ -509,8 +509,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
most cases, but if not - we create representation from each of them.
Arguments:
instance (pyblish.plugin.Instance): instance for which we are
setting representations
instance (dict): instance data for which we are
setting representations
exp_files (list): list of expected files
Returns:
@ -528,6 +528,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
# preview video rendering
for app in self.aov_filter.keys():
if os.environ.get("AVALON_APP", "") == app:
# no need to add review if `hasReviewableRepresentations`
if instance.get("hasReviewableRepresentations"):
break
# iteratre all aov filters
for aov in self.aov_filter[app]:
if re.match(
aov,
@ -724,7 +729,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"resolutionWidth": data.get("resolutionWidth", 1920),
"resolutionHeight": data.get("resolutionHeight", 1080),
"multipartExr": data.get("multipartExr", False),
"jobBatchName": data.get("jobBatchName", "")
"jobBatchName": data.get("jobBatchName", ""),
"hasReviewableRepresentations": data.get(
"hasReviewableRepresentations")
}
if "prerender" in instance.data["families"]:

View file

@ -35,6 +35,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin):
"image": "img",
"reference": "reference"
}
keep_first_subset_name_for_review = True
def process(self, instance):
self.log.debug("instance {}".format(instance))
@ -168,7 +169,47 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin):
# Change asset name of each new component for review
is_first_review_repre = True
not_first_components = []
extended_asset_name = ""
multiple_reviewable = len(review_representations) > 1
for repre in review_representations:
# Create copy of base comp item and append it
review_item = copy.deepcopy(base_component_item)
# get asset name and define extended name variant
asset_name = review_item["asset_data"]["name"]
extended_asset_name = "_".join(
(asset_name, repre["name"])
)
# reset extended if no need for extended asset name
if (
self.keep_first_subset_name_for_review
and is_first_review_repre
):
extended_asset_name = ""
else:
# only rename if multiple reviewable
if multiple_reviewable:
review_item["asset_data"]["name"] = extended_asset_name
else:
extended_asset_name = ""
# rename all already created components
# only if first repre and extended name available
if is_first_review_repre and extended_asset_name:
# and rename all already created components
for _ci in component_list:
_ci["asset_data"]["name"] = extended_asset_name
# and rename all already created src components
for _sci in src_components_to_add:
_sci["asset_data"]["name"] = extended_asset_name
# rename also first thumbnail component if any
if first_thumbnail_component is not None:
first_thumbnail_component[
"asset_data"]["name"] = extended_asset_name
frame_start = repre.get("frameStartFtrack")
frame_end = repre.get("frameEndFtrack")
if frame_start is None or frame_end is None:
@ -184,8 +225,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin):
if fps is None:
fps = instance_fps
# Create copy of base comp item and append it
review_item = copy.deepcopy(base_component_item)
# Change location
review_item["component_path"] = repre["published_path"]
# Change component data
@ -200,18 +239,16 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin):
})
}
}
# Create copy of item before setting location or changing asset
src_components_to_add.append(copy.deepcopy(review_item))
if is_first_review_repre:
is_first_review_repre = False
else:
# Add representation name to asset name of "not first" review
asset_name = review_item["asset_data"]["name"]
review_item["asset_data"]["name"] = "_".join(
(asset_name, repre["name"])
)
# later detection for thumbnail duplication
not_first_components.append(review_item)
# Create copy of item before setting location
src_components_to_add.append(copy.deepcopy(review_item))
# Set location
review_item["component_location"] = ftrack_server_location
# Add item to component list
@ -249,6 +286,14 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin):
continue
# Create copy of base comp item and append it
other_item = copy.deepcopy(base_component_item)
# add extended name if any
if (
not self.keep_first_subset_name_for_review
and extended_asset_name
):
other_item["asset_data"]["name"] = extended_asset_name
other_item["component_data"] = {
"name": repre["name"]
}

View file

@ -53,7 +53,10 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"textures",
"action",
"background",
"effect"
"effect",
"staticMesh",
"skeletalMesh"
]
def process(self, instance):

View file

@ -485,6 +485,11 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin):
anatomy = instance.context.data["anatomy"]
template_data = copy.deepcopy(instance.data["anatomyData"])
if "originalBasename" in instance.data:
template_data.update({
"originalBasename": instance.data.get("originalBasename")
})
if "folder" in anatomy.templates[template_key]:
anatomy_filled = anatomy.format(template_data)
publish_folder = anatomy_filled[template_key]["folder"]

View file

@ -106,8 +106,11 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"xgen",
"hda",
"usd",
"staticMesh",
"skeletalMesh",
"usdComposition",
"usdOverride"
"usdOverride",
"simpleUnrealTexture"
]
exclude_families = ["clip"]
db_representation_context_keys = [
@ -355,6 +358,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
if profile:
template_name = profile["template_name"]
published_representations = {}
for idx, repre in enumerate(instance.data["representations"]):
# reset transfers for next representation
@ -383,6 +388,11 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
if resolution_width:
template_data["fps"] = fps
if "originalBasename" in instance.data:
template_data.update({
"originalBasename": instance.data.get("originalBasename")
})
files = repre['files']
if repre.get('stagingDir'):
stagingdir = repre['stagingDir']
@ -554,6 +564,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
repre['published_path'] = dst
self.log.debug("__ dst: {}".format(dst))
if not instance.data.get("publishDir"):
instance.data["publishDir"] = (
anatomy_filled
[template_name]
["folder"]
)
if repre.get("udim"):
repre_context["udim"] = repre.get("udim") # store list

View file

@ -28,9 +28,30 @@
},
"delivery": {},
"unreal": {
"folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}",
"file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}",
"folder": "{root[work]}/{project[name]}/unreal/{task[name]}",
"file": "{project[code]}_{asset}",
"path": "{@folder}/{@file}"
},
"others": {}
"others": {
"maya2unreal": {
"folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}",
"file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}",
"path": "{@folder}/{@file}"
},
"simpleUnrealTextureHero": {
"folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/hero",
"file": "{originalBasename}.{ext}",
"path": "{@folder}/{@file}"
},
"simpleUnrealTexture": {
"folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{@version}",
"file": "{originalBasename}_{@version}.{ext}",
"path": "{@folder}/{@file}"
},
"__dynamic_keys_labels__": {
"maya2unreal": "Maya to Unreal",
"simpleUnrealTextureHero": "Simple Unreal Texture - Hero",
"simpleUnrealTexture": "Simple Unreal Texture"
}
}
}

View file

@ -62,6 +62,7 @@
"use_published": true,
"priority": 50,
"chunk_size": 10,
"concurrent_tasks": 1,
"primary_pool": "",
"secondary_pool": "",
"group": "",

View file

@ -395,7 +395,8 @@
"vrayproxy": "cache",
"redshiftproxy": "cache",
"usd": "usd"
}
},
"keep_first_subset_name_for_review": true
}
}
}

View file

@ -178,6 +178,29 @@
"task_types": [],
"tasks": [],
"template_name": "render"
},
{
"families": [
"simpleUnrealTexture"
],
"hosts": [
"standalonepublisher"
],
"task_types": [],
"tasks": [],
"template_name": "simpleUnrealTexture"
},
{
"families": [
"staticMesh",
"skeletalMesh"
],
"hosts": [
"maya"
],
"task_types": [],
"tasks": [],
"template_name": "maya2unreal"
}
],
"subset_grouping_profiles": [
@ -202,9 +225,22 @@
"animation",
"setdress",
"layout",
"mayaScene"
"mayaScene",
"simpleUnrealTexture"
],
"template_name_profiles": []
"template_name_profiles": [
{
"families": [
"simpleUnrealTexture"
],
"hosts": [
"standalonepublisher"
],
"task_types": [],
"task_names": [],
"template_name": "simpleUnrealTextureHero"
}
]
},
"CleanUp": {
"paterns": [],
@ -289,7 +325,7 @@
},
{
"families": [
"unrealStaticMesh"
"staticMesh"
],
"hosts": [
"maya"
@ -297,6 +333,17 @@
"task_types": [],
"tasks": [],
"template": "S_{asset}{variant}"
},
{
"families": [
"skeletalMesh"
],
"hosts": [
"maya"
],
"task_types": [],
"tasks": [],
"template": "SK_{asset}{variant}"
}
]
},
@ -306,6 +353,13 @@
"task_types": [],
"hosts": [],
"workfile_template": "work"
},
{
"task_types": [],
"hosts": [
"unreal"
],
"workfile_template": "unreal"
}
],
"last_workfile_on_startup": [

View file

@ -52,7 +52,7 @@
"",
"_Main"
],
"static_mesh_prefix": "S_",
"static_mesh_prefix": "S",
"collision_prefixes": [
"UBX",
"UCP",
@ -60,6 +60,11 @@
"UCX"
]
},
"CreateUnrealSkeletalMesh": {
"enabled": true,
"defaults": [],
"joint_hints": "jnt_org"
},
"CreateAnimation": {
"enabled": true,
"defaults": [

View file

@ -133,6 +133,14 @@
],
"help": "Texture files with UDIM together with worfile"
},
"create_simple_unreal_texture": {
"name": "simple_unreal_texture",
"label": "Simple Unreal Texture",
"family": "simpleUnrealTexture",
"icon": "Image",
"defaults": [],
"help": "Texture files with Unreal naming convention"
},
"__dynamic_keys_labels__": {
"create_workfile": "Workfile",
"create_model": "Model",
@ -145,7 +153,8 @@
"create_matchmove": "Matchmove",
"create_render": "Render",
"create_mov_batch": "Batch Mov",
"create_texture_batch": "Batch Texture"
"create_texture_batch": "Batch Texture",
"create_simple_unreal_texture": "Simple Unreal Texture"
}
},
"publish": {

View file

@ -192,6 +192,9 @@
"key": "use_published",
"label": "Use Published scene"
},
{
"type": "splitter"
},
{
"type": "number",
"key": "priority",
@ -202,6 +205,14 @@
"key": "chunk_size",
"label": "Chunk Size"
},
{
"type": "number",
"key": "concurrent_tasks",
"label": "Number of concurrent tasks"
},
{
"type": "splitter"
},
{
"type": "text",
"key": "primary_pool",
@ -217,6 +228,9 @@
"key": "group",
"label": "Group"
},
{
"type": "splitter"
},
{
"type": "text",
"key": "department",

View file

@ -784,6 +784,12 @@
"object_type": {
"type": "text"
}
},
{
"type": "boolean",
"key": "keep_first_subset_name_for_review",
"label": "Make subset name as first asset name",
"default": true
}
]
}

View file

@ -97,6 +97,32 @@
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "CreateUnrealSkeletalMesh",
"label": "Create Unreal - Skeletal Mesh",
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "list",
"key": "defaults",
"label": "Default Subsets",
"object_type": "text"
},
{
"type": "text",
"key": "joint_hints",
"label": "Joint root hint"
}
]
},
{
"type": "schema_template",

View file

@ -1269,6 +1269,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: #21252B;
}
/* Workfiles */
#WorkfilesPublishedContextSelect {
background: rgba(0, 0, 0, 127);
}
#WorkfilesPublishedContextSelect QLabel {
font-size: 17pt;
}
/* Tray */
#TrayRestartButton {
background: {color:restart-btn-bg};

View file

@ -26,7 +26,6 @@ from .model import (
DATE_MODIFIED_ROLE,
)
from .save_as_dialog import SaveAsDialog
from .lib import TempPublishFiles
log = logging.getLogger(__name__)
@ -45,11 +44,35 @@ class FilesView(QtWidgets.QTreeView):
return super(FilesView, self).mouseDoubleClickEvent(event)
class SelectContextOverlay(QtWidgets.QFrame):
def __init__(self, parent):
super(SelectContextOverlay, self).__init__(parent)
self.setObjectName("WorkfilesPublishedContextSelect")
label_widget = QtWidgets.QLabel(
"Please choose context on the left<br/>&lt",
self
)
label_widget.setAlignment(QtCore.Qt.AlignCenter)
layout = QtWidgets.QHBoxLayout(self)
layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter)
label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
parent.installEventFilter(self)
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.Resize:
self.resize(obj.size())
return super(SelectContextOverlay, self).eventFilter(obj, event)
class FilesWidget(QtWidgets.QWidget):
"""A widget displaying files that allows to save and open files."""
file_selected = QtCore.Signal(str)
file_opened = QtCore.Signal()
publish_file_viewed = QtCore.Signal()
workfile_created = QtCore.Signal(str)
published_visible_changed = QtCore.Signal(bool)
@ -71,9 +94,6 @@ class FilesWidget(QtWidgets.QWidget):
self._workfiles_root = None
self._workdir_path = None
self.host = api.registered_host()
temp_publish_files = TempPublishFiles()
temp_publish_files.cleanup()
self._temp_publish_files = temp_publish_files
# Whether to automatically select the latest modified
# file on a refresh of the files model.
@ -93,14 +113,14 @@ class FilesWidget(QtWidgets.QWidget):
filter_layout = QtWidgets.QHBoxLayout(filter_widget)
filter_layout.setContentsMargins(0, 0, 0, 0)
filter_layout.addWidget(published_checkbox, 0)
filter_layout.addWidget(filter_input, 1)
filter_layout.addWidget(published_checkbox, 0)
# Create the Files models
extensions = set(self.host.file_extensions())
views_widget = QtWidgets.QWidget(self)
# Workarea view
# --- Workarea view ---
workarea_files_model = WorkAreaFilesModel(extensions)
# Create proxy model for files to be able sort and filter
@ -118,13 +138,14 @@ class FilesWidget(QtWidgets.QWidget):
# Date modified delegate
workarea_time_delegate = PrettyTimeDelegate()
workarea_files_view.setItemDelegateForColumn(1, workarea_time_delegate)
workarea_files_view.setIndentation(3) # smaller indentation
# smaller indentation
workarea_files_view.setIndentation(3)
# Default to a wider first filename column it is what we mostly care
# about and the date modified is relatively small anyway.
workarea_files_view.setColumnWidth(0, 330)
# Publish files view
# --- Publish files view ---
publish_files_model = PublishFilesModel(extensions, io, self.anatomy)
publish_proxy_model = QtCore.QSortFilterProxyModel()
@ -141,12 +162,16 @@ class FilesWidget(QtWidgets.QWidget):
# Date modified delegate
publish_time_delegate = PrettyTimeDelegate()
publish_files_view.setItemDelegateForColumn(1, publish_time_delegate)
publish_files_view.setIndentation(3) # smaller indentation
# smaller indentation
publish_files_view.setIndentation(3)
# Default to a wider first filename column it is what we mostly care
# about and the date modified is relatively small anyway.
publish_files_view.setColumnWidth(0, 330)
publish_context_overlay = SelectContextOverlay(views_widget)
publish_context_overlay.setVisible(False)
views_layout = QtWidgets.QHBoxLayout(views_widget)
views_layout.setContentsMargins(0, 0, 0, 0)
views_layout.addWidget(workarea_files_view, 1)
@ -155,18 +180,43 @@ class FilesWidget(QtWidgets.QWidget):
# Home Page
# Build buttons widget for files widget
btns_widget = QtWidgets.QWidget(self)
btn_save = QtWidgets.QPushButton("Save As", btns_widget)
btn_browse = QtWidgets.QPushButton("Browse", btns_widget)
btn_open = QtWidgets.QPushButton("Open", btns_widget)
btn_view_published = QtWidgets.QPushButton("View", btns_widget)
workarea_btns_widget = QtWidgets.QWidget(btns_widget)
btn_save = QtWidgets.QPushButton("Save As", workarea_btns_widget)
btn_browse = QtWidgets.QPushButton("Browse", workarea_btns_widget)
btn_open = QtWidgets.QPushButton("Open", workarea_btns_widget)
workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget)
workarea_btns_layout.setContentsMargins(0, 0, 0, 0)
workarea_btns_layout.addWidget(btn_open, 1)
workarea_btns_layout.addWidget(btn_browse, 1)
workarea_btns_layout.addWidget(btn_save, 1)
publish_btns_widget = QtWidgets.QWidget(btns_widget)
btn_save_as_published = QtWidgets.QPushButton(
"Copy && Open", publish_btns_widget
)
btn_change_context = QtWidgets.QPushButton(
"Choose different context", publish_btns_widget
)
btn_select_context_published = QtWidgets.QPushButton(
"Copy && Open", publish_btns_widget
)
btn_cancel_published = QtWidgets.QPushButton(
"Cancel", publish_btns_widget
)
publish_btns_layout = QtWidgets.QHBoxLayout(publish_btns_widget)
publish_btns_layout.setContentsMargins(0, 0, 0, 0)
publish_btns_layout.addWidget(btn_save_as_published, 1)
publish_btns_layout.addWidget(btn_change_context, 1)
publish_btns_layout.addWidget(btn_select_context_published, 1)
publish_btns_layout.addWidget(btn_cancel_published, 1)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addWidget(btn_open, 1)
btns_layout.addWidget(btn_browse, 1)
btns_layout.addWidget(btn_save, 1)
btns_layout.addWidget(btn_view_published, 1)
btns_layout.addWidget(workarea_btns_widget, 1)
btns_layout.addWidget(publish_btns_widget, 1)
# Build files widgets for home page
main_layout = QtWidgets.QVBoxLayout(self)
@ -188,14 +238,22 @@ class FilesWidget(QtWidgets.QWidget):
workarea_files_view.selectionModel().selectionChanged.connect(
self.on_file_select
)
publish_files_view.doubleClickedLeft.connect(
self._on_view_published_pressed
)
btn_open.pressed.connect(self._on_workarea_open_pressed)
btn_browse.pressed.connect(self.on_browse_pressed)
btn_save.pressed.connect(self.on_save_as_pressed)
btn_view_published.pressed.connect(self._on_view_published_pressed)
btn_save.pressed.connect(self._on_save_as_pressed)
btn_save_as_published.pressed.connect(
self._on_published_save_as_pressed
)
btn_change_context.pressed.connect(
self._on_publish_change_context_pressed
)
btn_select_context_published.pressed.connect(
self._on_publish_select_context_pressed
)
btn_cancel_published.pressed.connect(
self._on_publish_cancel_pressed
)
# Store attributes
self._published_checkbox = published_checkbox
@ -211,18 +269,29 @@ class FilesWidget(QtWidgets.QWidget):
self._publish_files_model = publish_files_model
self._publish_proxy_model = publish_proxy_model
self._btns_widget = btns_widget
self._publish_context_overlay = publish_context_overlay
self._workarea_btns_widget = workarea_btns_widget
self._publish_btns_widget = publish_btns_widget
self._btn_open = btn_open
self._btn_browse = btn_browse
self._btn_save = btn_save
self._btn_view_published = btn_view_published
self._btn_save_as_published = btn_save_as_published
self._btn_change_context = btn_change_context
self._btn_select_context_published = btn_select_context_published
self._btn_cancel_published = btn_cancel_published
# Create a proxy widget for files widget
self.setFocusProxy(btn_open)
# Hide publish files widgets
publish_files_view.setVisible(False)
btn_view_published.setVisible(False)
publish_btns_widget.setVisible(False)
btn_select_context_published.setVisible(False)
btn_cancel_published.setVisible(False)
self._publish_context_select_mode = False
@property
def published_enabled(self):
@ -232,12 +301,10 @@ class FilesWidget(QtWidgets.QWidget):
published_enabled = self.published_enabled
self._workarea_files_view.setVisible(not published_enabled)
self._btn_open.setVisible(not published_enabled)
self._btn_browse.setVisible(not published_enabled)
self._btn_save.setVisible(not published_enabled)
self._workarea_btns_widget.setVisible(not published_enabled)
self._publish_files_view.setVisible(published_enabled)
self._btn_view_published.setVisible(published_enabled)
self._publish_btns_widget.setVisible(published_enabled)
self._update_filtering()
self._update_asset_task()
@ -258,6 +325,9 @@ class FilesWidget(QtWidgets.QWidget):
def set_save_enabled(self, enabled):
self._btn_save.setEnabled(enabled)
if not enabled and self._published_checkbox.isChecked():
self._published_checkbox.setChecked(False)
self._published_checkbox.setVisible(enabled)
def set_asset_task(self, asset_id, task_name, task_type):
if asset_id != self._asset_id:
@ -268,12 +338,14 @@ class FilesWidget(QtWidgets.QWidget):
self._update_asset_task()
def _update_asset_task(self):
if self.published_enabled:
if self.published_enabled and not self._publish_context_select_mode:
self._publish_files_model.set_context(
self._asset_id, self._task_name
)
has_valid_items = self._publish_files_model.has_valid_items()
self._btn_view_published.setEnabled(has_valid_items)
self._btn_save_as_published.setEnabled(has_valid_items)
self._btn_change_context.setEnabled(has_valid_items)
else:
# Define a custom session so we can query the work root
# for a "Work area" that is not our current Session.
@ -291,6 +363,13 @@ class FilesWidget(QtWidgets.QWidget):
has_valid_items = self._workarea_files_model.has_valid_items()
self._btn_browse.setEnabled(has_valid_items)
self._btn_open.setEnabled(has_valid_items)
if self._publish_context_select_mode:
self._btn_select_context_published.setEnabled(
bool(self._asset_id) and bool(self._task_name)
)
return
# Manually trigger file selection
if not has_valid_items:
self.on_file_select()
@ -400,11 +479,18 @@ class FilesWidget(QtWidgets.QWidget):
"""
session = self._get_session()
if self.published_enabled:
filepath = self._get_selected_filepath()
extensions = [os.path.splitext(filepath)[1]]
else:
extensions = self.host.file_extensions()
window = SaveAsDialog(
parent=self,
root=self._workfiles_root,
anatomy=self.anatomy,
template_key=self.template_key,
extensions=extensions,
session=session
)
window.exec_()
@ -462,10 +548,15 @@ class FilesWidget(QtWidgets.QWidget):
if work_file:
self.open_file(work_file)
def on_save_as_pressed(self):
def _on_save_as_pressed(self):
self._save_as_with_dialog()
def _save_as_with_dialog(self):
work_filename = self.get_filename()
if not work_filename:
return
return None
src_path = self._get_selected_filepath()
# Trigger before save event
emit_event(
@ -486,13 +577,20 @@ class FilesWidget(QtWidgets.QWidget):
log.debug("Initializing Work Directory: %s", self._workfiles_root)
os.makedirs(self._workfiles_root)
# Update session if context has changed
self._enter_session()
# Prepare full path to workfile and save it
filepath = os.path.join(
os.path.normpath(self._workfiles_root), work_filename
)
self.host.save_file(filepath)
# Update session if context has changed
self._enter_session()
if not self.published_enabled:
self.host.save_file(filepath)
else:
shutil.copy(src_path, filepath)
self.host.open_file(filepath)
# Create extra folders
create_workdir_extra_folders(
self._workdir_path,
@ -510,17 +608,55 @@ class FilesWidget(QtWidgets.QWidget):
self.workfile_created.emit(filepath)
# Refresh files model
self.refresh()
if self.published_enabled:
self._published_checkbox.setChecked(False)
else:
self.refresh()
return filepath
def _on_view_published_pressed(self):
filepath = self._get_selected_filepath()
if not filepath or not os.path.exists(filepath):
return
item = self._temp_publish_files.add_file(filepath)
self.host.open_file(item.filepath)
self.publish_file_viewed.emit()
# Change state back to workarea
self._published_checkbox.setChecked(False)
def _on_published_save_as_pressed(self):
self._save_as_with_dialog()
def _set_publish_context_select_mode(self, enabled):
self._publish_context_select_mode = enabled
# Show buttons related to context selection
self._publish_context_overlay.setVisible(enabled)
self._btn_cancel_published.setVisible(enabled)
self._btn_select_context_published.setVisible(enabled)
# Change enabled state based on select context
self._btn_select_context_published.setEnabled(
bool(self._asset_id) and bool(self._task_name)
)
self._btn_save_as_published.setVisible(not enabled)
self._btn_change_context.setVisible(not enabled)
# Change views and disable workarea view if enabled
self._workarea_files_view.setEnabled(not enabled)
if self.published_enabled:
self._workarea_files_view.setVisible(enabled)
self._publish_files_view.setVisible(not enabled)
else:
self._workarea_files_view.setVisible(True)
self._publish_files_view.setVisible(False)
# Disable filter widgets
self._published_checkbox.setEnabled(not enabled)
self._filter_input.setEnabled(not enabled)
def _on_publish_change_context_pressed(self):
self._set_publish_context_select_mode(True)
def _on_publish_select_context_pressed(self):
result = self._save_as_with_dialog()
if result is not None:
self._set_publish_context_select_mode(False)
self._update_asset_task()
def _on_publish_cancel_pressed(self):
self._set_publish_context_select_mode(False)
self._update_asset_task()
def on_file_select(self):
self.file_selected.emit(self._get_selected_filepath())

View file

@ -1,272 +0,0 @@
import os
import shutil
import uuid
import time
import json
import logging
import contextlib
import appdirs
class TempPublishFilesItem(object):
"""Object representing copied workfile in app temp folder.
Args:
item_id (str): Id of item used as subfolder.
data (dict): Metadata about temp files.
directory (str): Path to directory where files are copied to.
"""
def __init__(self, item_id, data, directory):
self._id = item_id
self._directory = directory
self._filepath = os.path.join(directory, data["filename"])
@property
def directory(self):
return self._directory
@property
def filepath(self):
return self._filepath
@property
def id(self):
return self._id
@property
def size(self):
if os.path.exists(self.filepath):
s = os.stat(self.filepath)
return s.st_size
return 0
class TempPublishFiles(object):
"""Directory where published workfiles are copied when opened.
Directory is located in appdirs on the machine. Folder contains file
with metadata about stored files. Each item in metadata has id, filename
and expiration time. When expiration time is higher then current time the
item is removed from metadata and it's files are deleted. Files of items
are stored in subfolder named by item's id.
Metadata file can be in theory opened and modified by multiple processes,
threads at one time. For those cases is created simple lock file which
is created before modification begins and is removed when modification
ends. Existence of the file means that it should not be modified by
any other process at the same time.
Metadata example:
```
{
"96050b4a-8974-4fca-8179-7c446c478d54": {
"created": 1647880725.555,
"expiration": 1647884325.555,
"filename": "cg_pigeon_workfileModeling_v025.ma"
},
...
}
```
## Why is this needed
Combination of more issues. Temp files are not automatically removed by
OS on windows so using tempfiles in TEMP would lead to kill disk space of
machine. There are also cases when someone wants to open multiple files
in short period of time and want to manually remove those files so keeping
track of temporary copied files in pre-defined structure is needed.
"""
minute_in_seconds = 60
hour_in_seconds = 60 * minute_in_seconds
day_in_seconds = 24 * hour_in_seconds
def __init__(self):
root_dir = appdirs.user_data_dir(
"published_workfiles_temp", "openpype"
)
if not os.path.exists(root_dir):
os.makedirs(root_dir)
metadata_path = os.path.join(root_dir, "metadata.json")
lock_path = os.path.join(root_dir, "lock.json")
self._root_dir = root_dir
self._metadata_path = metadata_path
self._lock_path = lock_path
self._log = None
@property
def log(self):
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
return self._log
@property
def life_time(self):
"""How long will be new item kept in temp in seconds.
Returns:
int: Lifetime of temp item.
"""
return int(self.hour_in_seconds)
@property
def size(self):
"""File size of existing items."""
size = 0
for item in self.get_items():
size += item.size
return size
def add_file(self, src_path):
"""Add workfile to temp directory.
This will create new item and source path is copied to it's directory.
"""
filename = os.path.basename(src_path)
item_id = str(uuid.uuid4())
dst_dirpath = os.path.join(self._root_dir, item_id)
if not os.path.exists(dst_dirpath):
os.makedirs(dst_dirpath)
dst_path = os.path.join(dst_dirpath, filename)
shutil.copy(src_path, dst_path)
now = time.time()
item_data = {
"filename": filename,
"expiration": now + self.life_time,
"created": now
}
with self._modify_data() as data:
data[item_id] = item_data
return TempPublishFilesItem(item_id, item_data, dst_dirpath)
@contextlib.contextmanager
def _modify_data(self):
"""Create lock file when data in metadata file are modified."""
start_time = time.time()
timeout = 3
while os.path.exists(self._lock_path):
time.sleep(0.01)
if start_time > timeout:
self.log.warning((
"Waited for {} seconds to free lock file. Overriding lock."
).format(timeout))
with open(self._lock_path, "w") as stream:
json.dump({"pid": os.getpid()}, stream)
try:
data = self._get_data()
yield data
with open(self._metadata_path, "w") as stream:
json.dump(data, stream)
finally:
os.remove(self._lock_path)
def _get_data(self):
output = {}
if not os.path.exists(self._metadata_path):
return output
try:
with open(self._metadata_path, "r") as stream:
output = json.load(stream)
except Exception:
self.log.warning("Failed to read metadata file.", exc_info=True)
return output
def cleanup(self, check_expiration=True):
"""Cleanup files based on metadata.
Items that passed expiration are removed when this is called. Or all
files are removed when `check_expiration` is set to False.
Args:
check_expiration (bool): All items and files are removed when set
to True.
"""
data = self._get_data()
now = time.time()
remove_ids = set()
all_ids = set()
for item_id, item_data in data.items():
all_ids.add(item_id)
if check_expiration and now < item_data["expiration"]:
continue
remove_ids.add(item_id)
for item_id in remove_ids:
try:
self.remove_id(item_id)
except Exception:
self.log.warning(
"Failed to remove temp publish item \"{}\"".format(
item_id
),
exc_info=True
)
# Remove unknown folders/files
for filename in os.listdir(self._root_dir):
if filename in all_ids:
continue
full_path = os.path.join(self._root_dir, filename)
if full_path in (self._metadata_path, self._lock_path):
continue
try:
shutil.rmtree(full_path)
except Exception:
self.log.warning(
"Couldn't remove arbitrary path \"{}\"".format(full_path),
exc_info=True
)
def clear(self):
self.cleanup(False)
def get_items(self):
"""Receive all items from metadata file.
Returns:
list<TempPublishFilesItem>: Info about each item in metadata.
"""
output = []
data = self._get_data()
for item_id, item_data in data.items():
item_path = os.path.join(self._root_dir, item_id)
output.append(TempPublishFilesItem(item_id, item_data, item_path))
return output
def remove_id(self, item_id):
"""Remove files of item and then remove the item from metadata."""
filepath = os.path.join(self._root_dir, item_id)
if os.path.exists(filepath):
shutil.rmtree(filepath)
with self._modify_data() as data:
data.pop(item_id, None)
def file_size_to_string(file_size):
size = 0
size_ending_mapping = {
"KB": 1024 ** 1,
"MB": 1024 ** 2,
"GB": 1024 ** 3
}
ending = "B"
for _ending, _size in size_ending_mapping.items():
if file_size < _size:
break
size = file_size / _size
ending = _ending
return "{:.2f} {}".format(size, ending)

View file

@ -193,7 +193,9 @@ class SaveAsDialog(QtWidgets.QDialog):
"""
def __init__(self, parent, root, anatomy, template_key, session=None):
def __init__(
self, parent, root, anatomy, template_key, extensions, session=None
):
super(SaveAsDialog, self).__init__(parent=parent)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
@ -201,6 +203,7 @@ class SaveAsDialog(QtWidgets.QDialog):
self.host = api.registered_host()
self.root = root
self.work_file = None
self._extensions = extensions
if not session:
# Fallback to active session
@ -257,7 +260,7 @@ class SaveAsDialog(QtWidgets.QDialog):
# Add styled delegate to use stylesheets
ext_delegate = QtWidgets.QStyledItemDelegate()
ext_combo.setItemDelegate(ext_delegate)
ext_combo.addItems(self.host.file_extensions())
ext_combo.addItems(self._extensions)
# Build inputs
inputs_layout = QtWidgets.QFormLayout(inputs_widget)
@ -336,7 +339,7 @@ class SaveAsDialog(QtWidgets.QDialog):
def get_existing_comments(self):
matcher = CommentMatcher(self.anatomy, self.template_key, self.data)
host_extensions = set(self.host.file_extensions())
host_extensions = set(self._extensions)
comments = set()
if os.path.isdir(self.root):
for fname in os.listdir(self.root):
@ -392,7 +395,7 @@ class SaveAsDialog(QtWidgets.QDialog):
return anatomy_filled[self.template_key]["file"]
def refresh(self):
extensions = self.host.file_extensions()
extensions = list(self._extensions)
extension = self.data["ext"]
if extension is None:
# Define saving file extension

View file

@ -14,7 +14,22 @@ from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget
from openpype.tools.utils.tasks_widget import TasksWidget
from .files_widget import FilesWidget
from .lib import TempPublishFiles, file_size_to_string
def file_size_to_string(file_size):
size = 0
size_ending_mapping = {
"KB": 1024 ** 1,
"MB": 1024 ** 2,
"GB": 1024 ** 3
}
ending = "B"
for _ending, _size in size_ending_mapping.items():
if file_size < _size:
break
size = file_size / _size
ending = _ending
return "{:.2f} {}".format(size, ending)
class SidePanelWidget(QtWidgets.QWidget):
@ -44,67 +59,25 @@ class SidePanelWidget(QtWidgets.QWidget):
btn_note_save, 0, alignment=QtCore.Qt.AlignRight
)
publish_temp_widget = QtWidgets.QWidget(self)
publish_temp_info_label = QtWidgets.QLabel(
self.published_workfile_message.format(
file_size_to_string(0)
),
publish_temp_widget
)
publish_temp_info_label.setWordWrap(True)
btn_clear_temp = QtWidgets.QPushButton(
"Clear temp", publish_temp_widget
)
publish_temp_layout = QtWidgets.QVBoxLayout(publish_temp_widget)
publish_temp_layout.setContentsMargins(0, 0, 0, 0)
publish_temp_layout.addWidget(publish_temp_info_label, 0)
publish_temp_layout.addWidget(
btn_clear_temp, 0, alignment=QtCore.Qt.AlignRight
)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(details_label, 0)
main_layout.addWidget(details_input, 1)
main_layout.addWidget(artist_note_widget, 1)
main_layout.addWidget(publish_temp_widget, 0)
note_input.textChanged.connect(self._on_note_change)
btn_note_save.clicked.connect(self._on_save_click)
btn_clear_temp.clicked.connect(self._on_clear_temp_click)
self._details_input = details_input
self._artist_note_widget = artist_note_widget
self._note_input = note_input
self._btn_note_save = btn_note_save
self._publish_temp_info_label = publish_temp_info_label
self._publish_temp_widget = publish_temp_widget
self._orig_note = ""
self._workfile_doc = None
publish_temp_widget.setVisible(False)
def set_published_visible(self, published_visible):
self._artist_note_widget.setVisible(not published_visible)
self._publish_temp_widget.setVisible(published_visible)
if published_visible:
self.refresh_publish_temp_sizes()
def refresh_publish_temp_sizes(self):
temp_publish_files = TempPublishFiles()
text = self.published_workfile_message.format(
file_size_to_string(temp_publish_files.size)
)
self._publish_temp_info_label.setText(text)
def _on_clear_temp_click(self):
temp_publish_files = TempPublishFiles()
temp_publish_files.clear()
self.refresh_publish_temp_sizes()
def _on_note_change(self):
text = self._note_input.toPlainText()
@ -225,9 +198,6 @@ class Window(QtWidgets.QMainWindow):
files_widget.file_selected.connect(self.on_file_select)
files_widget.workfile_created.connect(self.on_workfile_create)
files_widget.file_opened.connect(self._on_file_opened)
files_widget.publish_file_viewed.connect(
self._on_publish_file_viewed
)
files_widget.published_visible_changed.connect(
self._on_published_change
)
@ -292,9 +262,6 @@ class Window(QtWidgets.QMainWindow):
def _on_file_opened(self):
self.close()
def _on_publish_file_viewed(self):
self.side_panel.refresh_publish_temp_sizes()
def _on_published_change(self, visible):
self.side_panel.set_published_visible(visible)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.9.2-nightly.3"
__version__ = "3.9.3-nightly.1"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
version = "3.9.2-nightly.3" # OpenPype
version = "3.9.3-nightly.1" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team <info@openpype.io>"]
license = "MIT License"
@ -136,3 +136,19 @@ hash = "de63a8bf7f6c45ff59ecafeba13123f710c2cbc1783ec9e0b938e980d4f5c37f"
[openpype.thirdparty.oiio.darwin]
url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz"
hash = "sha256:..."
[tool.pyright]
include = [
"igniter",
"openpype",
"repos",
"vendor"
]
exclude = [
"**/node_modules",
"**/__pycache__"
]
ignore = ["website", "docs", ".git"]
reportMissingImports = true
reportMissingTypeStubs = false

View file

@ -4,7 +4,7 @@ title: Ftrack
sidebar_label: Project Manager
---
Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://ftrack.rtd.ftrack.com/en/stable/).
Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](https://help.ftrack.com/en/).
## Project management
Setting project attributes is the key to properly working pipeline.
@ -31,7 +31,7 @@ This process describes how data from Ftrack will get into Avalon database.
### How to synchronize
You can trigger synchronization manually using [Sync To Avalon](manager_ftrack_actions.md#sync-to-avalon) action.
Synchronization can also be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](#prepare-ftrack-for-openpype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server.
Synchronization can also be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](module_ftrack.md#prepare-ftrack-for-openpype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server.
:::tip
Always use `Sync To Avalon` action before you enable `Avalon auto-sync`!