Merge branch 'develop' into bugfix/aftereffects_workfiles_on_launch_var

This commit is contained in:
Roy Nieterau 2024-05-10 11:05:44 +02:00 committed by GitHub
commit bb7795dc65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 722 additions and 367 deletions

View file

@ -33,7 +33,7 @@ def load_scripts(paths):
if register:
try:
register()
except:
except: # noqa E722
traceback.print_exc()
else:
print("\nWarning! '%s' has no register function, "
@ -45,7 +45,7 @@ def load_scripts(paths):
if unregister:
try:
unregister()
except:
except: # noqa E722
traceback.print_exc()
def test_reload(mod):
@ -57,7 +57,7 @@ def load_scripts(paths):
try:
return importlib.reload(mod)
except:
except: # noqa E722
traceback.print_exc()
def test_register(mod):

View file

@ -143,13 +143,19 @@ def deselect_all():
if obj.mode != 'OBJECT':
modes.append((obj, obj.mode))
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='OBJECT')
context_override = create_blender_context(active=obj)
with bpy.context.temp_override(**context_override):
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
context_override = create_blender_context()
with bpy.context.temp_override(**context_override):
bpy.ops.object.select_all(action='DESELECT')
for p in modes:
bpy.context.view_layer.objects.active = p[0]
bpy.ops.object.mode_set(mode=p[1])
context_override = create_blender_context(active=p[0])
with bpy.context.temp_override(**context_override):
bpy.ops.object.mode_set(mode=p[1])
bpy.context.view_layer.objects.active = active

View file

@ -7,7 +7,8 @@ from ayon_core.hosts.houdini.api import lib
class CollectDataforCache(pyblish.api.InstancePlugin):
"""Collect data for caching to Deadline."""
order = pyblish.api.CollectorOrder + 0.04
# Run after Collect Frames
order = pyblish.api.CollectorOrder + 0.11
families = ["ass", "pointcache",
"mantraifd", "redshiftproxy",
"vdbcache"]

View file

@ -17,7 +17,7 @@ class CollectFrames(pyblish.api.InstancePlugin):
label = "Collect Frames"
families = ["vdbcache", "imagesequence", "ass",
"mantraifd", "redshiftproxy", "review",
"bgeo"]
"pointcache"]
def process(self, instance):

View file

@ -28,10 +28,15 @@ class ExtractAlembic(publish.Extractor):
staging_dir = os.path.dirname(output)
instance.data["stagingDir"] = staging_dir
file_name = os.path.basename(output)
if instance.data.get("frames"):
# list of files
files = instance.data["frames"]
else:
# single file
files = os.path.basename(output)
# We run the render
self.log.info("Writing alembic '%s' to '%s'" % (file_name,
self.log.info("Writing alembic '%s' to '%s'" % (files,
staging_dir))
render_rop(ropnode)
@ -42,7 +47,7 @@ class ExtractAlembic(publish.Extractor):
representation = {
'name': 'abc',
'ext': 'abc',
'files': file_name,
'files': files,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)

View file

@ -6,12 +6,9 @@ import json
from typing import Any, Dict, Union
import six
import ayon_api
from ayon_core.pipeline import (
get_current_project_name,
get_current_folder_path,
get_current_task_name,
colorspace
)
from ayon_core.settings import get_project_settings

View file

@ -22,7 +22,6 @@ ALEMBIC_ARGS = {
"melPostJobCallback": str,
"noNormals": bool,
"preRoll": bool,
"preRollStartFrame": int,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"renderableOnly": bool,
@ -54,15 +53,22 @@ def extract_alembic(
endFrame=None,
eulerFilter=True,
frameRange="",
melPerFrameCallback=None,
melPostJobCallback=None,
noNormals=False,
preRoll=False,
preRollStartFrame=0,
pythonPerFrameCallback=None,
pythonPostJobCallback=None,
renderableOnly=False,
root=None,
selection=True,
startFrame=None,
step=1.0,
stripNamespaces=True,
userAttr=None,
userAttrPrefix=None,
uvsOnly=False,
uvWrite=True,
verbose=False,
wholeFrameGeo=False,
@ -102,6 +108,11 @@ def extract_alembic(
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
melPerFrameCallback (Optional[str]): MEL callback run per frame.
melPostJobCallback (Optional[str]): MEL callback after last frame is
written.
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
@ -113,6 +124,11 @@ def extract_alembic(
dependent translations and can be used to evaluate run-up that
isn't actually translated. Defaults to 0.
pythonPerFrameCallback (Optional[str]): Python callback run per frame.
pythonPostJobCallback (Optional[str]): Python callback after last frame
is written.
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
@ -137,6 +153,15 @@ def extract_alembic(
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
userAttr (list of str, optional): A specific user defined attribute to
write out. Defaults to [].
userAttrPrefix (list of str, optional): Prefix filter for determining
which user defined attributes to write out. Defaults to [].
uvsOnly (bool): When on, only uv data for PolyMesh and SubD shapes
will be written to the Alembic file.
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
@ -183,6 +208,8 @@ def extract_alembic(
# Ensure list arguments are valid.
attr = attr or []
attrPrefix = attrPrefix or []
userAttr = userAttr or []
userAttrPrefix = userAttrPrefix or []
root = root or []
# Pass the start and end frame on as `frameRange` so that it
@ -213,8 +240,10 @@ def extract_alembic(
"eulerFilter": eulerFilter,
"noNormals": noNormals,
"preRoll": preRoll,
"root": root,
"renderableOnly": renderableOnly,
"uvWrite": uvWrite,
"uvsOnly": uvsOnly,
"writeColorSets": writeColorSets,
"writeFaceSets": writeFaceSets,
"wholeFrameGeo": wholeFrameGeo,
@ -226,9 +255,10 @@ def extract_alembic(
"step": step,
"attr": attr,
"attrPrefix": attrPrefix,
"userAttr": userAttr,
"userAttrPrefix": userAttrPrefix,
"stripNamespaces": stripNamespaces,
"verbose": verbose,
"preRollStartFrame": preRollStartFrame
"verbose": verbose
}
# Validate options
@ -264,6 +294,17 @@ def extract_alembic(
if maya_version >= 2018:
options['autoSubd'] = options.pop('writeCreases', False)
# Only add callbacks if they are set so that we're not passing `None`
callbacks = {
"melPerFrameCallback": melPerFrameCallback,
"melPostJobCallback": melPostJobCallback,
"pythonPerFrameCallback": pythonPerFrameCallback,
"pythonPostJobCallback": pythonPostJobCallback,
}
for key, callback in callbacks.items():
if callback:
options[key] = str(callback)
# Format the job string from options
job_args = list()
for key, value in options.items():
@ -297,7 +338,11 @@ def extract_alembic(
# exports are made. (PLN-31)
# TODO: Make sure this actually fixes the issues
with evaluation("off"):
cmds.AbcExport(j=job_str, verbose=verbose)
cmds.AbcExport(
j=job_str,
verbose=verbose,
preRollStartFrame=preRollStartFrame
)
if verbose:
log.debug("Extracted Alembic to: %s", file)

View file

@ -6,7 +6,6 @@ from ayon_core.lib import (
BoolDef,
NumberDef,
)
from ayon_core.pipeline import CreatedInstance
def _get_animation_attr_defs(cls):

View file

@ -6,6 +6,7 @@ from maya import cmds
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.hosts.maya.api.lib import (
get_all_children,
suspended_refresh,
maintained_selection,
iter_visible_nodes_in_range
@ -40,7 +41,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
# From settings
attr = []
attrPrefix = []
autoSubd = False
bake_attributes = []
bake_attribute_prefixes = []
dataFormat = "ogawa"
@ -63,6 +63,7 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
wholeFrameGeo = False
worldSpace = True
writeColorSets = False
writeCreases = False
writeFaceSets = False
writeNormals = True
writeUVSets = False
@ -173,15 +174,9 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
"writeVisibility": attribute_values.get(
"writeVisibility", self.writeVisibility
),
"autoSubd": attribute_values.get(
"autoSubd", self.autoSubd
),
"uvsOnly": attribute_values.get(
"uvsOnly", self.uvsOnly
),
"writeNormals": attribute_values.get(
"writeNormals", self.writeNormals
),
"melPerFrameCallback": attribute_values.get(
"melPerFrameCallback", self.melPerFrameCallback
),
@ -193,7 +188,12 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
),
"pythonPostJobCallback": attribute_values.get(
"pythonPostJobCallback", self.pythonPostJobCallback
)
),
# Note that this converts `writeNormals` to `noNormals` for the
# `AbcExport` equivalent in `extract_alembic`
"noNormals": not attribute_values.get(
"writeNormals", self.writeNormals
),
}
if instance.data.get("visibleOnly", False):
@ -249,7 +249,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
with maintained_selection():
cmds.select(instance.data["proxy"])
extract_alembic(**kwargs)
representation = {
"name": "proxy",
"ext": "abc",
@ -268,20 +267,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
return []
override_defs = OrderedDict({
"autoSubd": BoolDef(
"autoSubd",
label="Auto Subd",
default=cls.autoSubd,
tooltip=(
"If this flag is present and the mesh has crease edges, "
"crease vertices or holes, the mesh (OPolyMesh) would now "
"be written out as an OSubD and crease info will be stored"
" in the Alembic file. Otherwise, creases info won't be "
"preserved in Alembic file unless a custom Boolean "
"attribute SubDivisionMesh has been added to mesh node and"
" its value is true."
)
),
"eulerFilter": BoolDef(
"eulerFilter",
label="Euler Filter",
@ -354,6 +339,13 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
default=cls.writeColorSets,
tooltip="Write vertex colors with the geometry."
),
"writeCreases": BoolDef(
"writeCreases",
label="Write Creases",
default=cls.writeCreases,
tooltip="Write the geometry's edge and vertex crease "
"information."
),
"writeFaceSets": BoolDef(
"writeFaceSets",
label="Write Face Sets",
@ -527,9 +519,7 @@ class ExtractAnimation(ExtractAlembic):
roots = cmds.sets(out_set, query=True) or []
# Include all descendants
nodes = roots
nodes += cmds.listRelatives(
roots, allDescendents=True, fullPath=True
) or []
nodes = roots.copy()
nodes.extend(get_all_children(roots, ignore_intermediate_objects=True))
return nodes, roots

View file

@ -1,3 +1,4 @@
import inspect
import pyblish.api
from ayon_core.pipeline import OptionalPyblishPluginMixin
@ -29,29 +30,28 @@ class ValidateAlembicDefaultsPointcache(
@classmethod
def _get_publish_attributes(cls, instance):
attributes = instance.data["publish_attributes"][
cls.plugin_name(
instance.data["publish_attributes"]
)
]
return attributes
return instance.data["publish_attributes"][cls.plugin_name]
def process(self, instance):
if not self.is_active(instance.data):
return
settings = self._get_settings(instance.context)
attributes = self._get_publish_attributes(instance)
msg = (
"Alembic Extract setting \"{}\" is not the default value:"
"\nCurrent: {}"
"\nDefault Value: {}\n"
)
errors = []
invalid = {}
for key, value in attributes.items():
if key not in settings:
# This may occur if attributes have changed over time and an
# existing instance has older legacy attributes that do not
# match the current settings definition.
self.log.warning(
"Publish attribute %s not found in Alembic Export "
"default settings. Ignoring validation for attribute.",
key
)
continue
default_value = settings[key]
# Lists are best to compared sorted since we cant rely on the order
@ -61,10 +61,35 @@ class ValidateAlembicDefaultsPointcache(
default_value = sorted(default_value)
if value != default_value:
errors.append(msg.format(key, value, default_value))
invalid[key] = value, default_value
if errors:
raise PublishValidationError("\n".join(errors))
if invalid:
non_defaults = "\n".join(
f"- {key}: {value} \t(default: {default_value})"
for key, (value, default_value) in invalid.items()
)
raise PublishValidationError(
"Alembic extract options differ from default values:\n"
f"{non_defaults}",
description=self.get_description()
)
@staticmethod
def get_description():
return inspect.cleandoc(
"""### Alembic Extract settings differ from defaults
The alembic export options differ from the project default values.
If this is intentional you can disable this validation by
disabling **Validate Alembic Options Default**.
If not you may use the "Repair" action to revert all the options to
their default values.
"""
)
@classmethod
def repair(cls, instance):
@ -75,13 +100,20 @@ class ValidateAlembicDefaultsPointcache(
)
# Set the settings values on the create context then save to workfile.
publish_attributes = instance.data["publish_attributes"]
plugin_name = cls.plugin_name(publish_attributes)
attributes = cls._get_publish_attributes(instance)
settings = cls._get_settings(instance.context)
create_publish_attributes = create_instance.data["publish_attributes"]
attributes = cls._get_publish_attributes(create_instance)
for key in attributes:
create_publish_attributes[plugin_name][key] = settings[key]
if key not in settings:
# This may occur if attributes have changed over time and an
# existing instance has older legacy attributes that do not
# match the current settings definition.
cls.log.warning(
"Publish attribute %s not found in Alembic Export "
"default settings. Ignoring repair for attribute.",
key
)
continue
attributes[key] = settings[key]
create_context.save_changes()
@ -93,6 +125,6 @@ class ValidateAlembicDefaultsAnimation(
The defaults are defined in the project settings.
"""
label = "Validate Alembic Options Defaults"
label = "Validate Alembic Options Defaults"
families = ["animation"]
plugin_name = "ExtractAnimation"

View file

@ -0,0 +1,201 @@
from maya import cmds
from ayon_core.hosts.maya.api.workfile_template_builder import (
MayaPlaceholderPlugin
)
from ayon_core.lib import NumberDef, TextDef, EnumDef
from ayon_core.lib.events import weakref_partial
EXAMPLE_SCRIPT = """
# Access maya commands
from maya import cmds
# Access the placeholder node
placeholder_node = placeholder.scene_identifier
# Access the event callback
if event is None:
print(f"Populating {placeholder}")
else:
if event.topic == "template.depth_processed":
print(f"Processed depth: {event.get('depth')}")
elif event.topic == "template.finished":
print("Build finished.")
""".strip()
class MayaPlaceholderScriptPlugin(MayaPlaceholderPlugin):
"""Execute a script at the given `order` during workfile build.
This is a very low-level placeholder to run Python scripts at a given
point in time during the workfile template build.
It can create either a locator or an objectSet as placeholder node.
It defaults to an objectSet, since allowing to run on e.g. other
placeholder node members can be useful, e.g. using:
>>> members = cmds.sets(placeholder.scene_identifier, query=True)
"""
identifier = "maya.runscript"
label = "Run Python Script"
use_selection_as_parent = False
def get_placeholder_options(self, options=None):
options = options or {}
return [
NumberDef(
"order",
label="Order",
default=options.get("order") or 0,
decimals=0,
minimum=0,
maximum=999,
tooltip=(
"Order"
"\nOrder defines asset loading priority (0 to 999)"
"\nPriority rule is : \"lowest is first to load\"."
)
),
TextDef(
"prepare_script",
label="Run at\nprepare",
tooltip="Run before populate at prepare order",
multiline=True,
default=options.get("prepare_script", "")
),
TextDef(
"populate_script",
label="Run at\npopulate",
tooltip="Run script at populate node order<br>"
"This is the <b>default</b> behavior",
multiline=True,
default=options.get("populate_script", EXAMPLE_SCRIPT)
),
TextDef(
"depth_processed_script",
label="Run after\ndepth\niteration",
tooltip="Run script after every build depth iteration",
multiline=True,
default=options.get("depth_processed_script", "")
),
TextDef(
"finished_script",
label="Run after\nbuild",
tooltip=(
"Run script at build finished.<br>"
"<b>Note</b>: this even runs if other placeholders had "
"errors during the build"
),
multiline=True,
default=options.get("finished_script", "")
),
EnumDef(
"create_nodetype",
label="Nodetype",
items={
"spaceLocator": "Locator",
"objectSet": "ObjectSet"
},
tooltip=(
"The placeholder's node type to be created.<br>"
"<b>Note</b> this only works on create, not on update"
),
default=options.get("create_nodetype", "objectSet")
),
]
def create_placeholder(self, placeholder_data):
nodetype = placeholder_data.get("create_nodetype", "objectSet")
if nodetype == "spaceLocator":
super(MayaPlaceholderScriptPlugin, self).create_placeholder(
placeholder_data
)
elif nodetype == "objectSet":
placeholder_data["plugin_identifier"] = self.identifier
# Create maya objectSet on selection
selection = cmds.ls(selection=True, long=True)
name = self._create_placeholder_name(placeholder_data)
node = cmds.sets(selection, name=name)
self.imprint(node, placeholder_data)
def prepare_placeholders(self, placeholders):
super(MayaPlaceholderScriptPlugin, self).prepare_placeholders(
placeholders
)
for placeholder in placeholders:
prepare_script = placeholder.data.get("prepare_script")
if not prepare_script:
continue
self.run_script(placeholder, prepare_script)
def populate_placeholder(self, placeholder):
populate_script = placeholder.data.get("populate_script")
depth_script = placeholder.data.get("depth_processed_script")
finished_script = placeholder.data.get("finished_script")
# Run now
if populate_script:
self.run_script(placeholder, populate_script)
if not any([depth_script, finished_script]):
# No callback scripts to run
if not placeholder.data.get("keep_placeholder", True):
self.delete_placeholder(placeholder)
return
# Run at each depth processed
if depth_script:
callback = weakref_partial(
self.run_script, placeholder, depth_script)
self.builder.add_on_depth_processed_callback(
callback, order=placeholder.order)
# Run at build finish
if finished_script:
callback = weakref_partial(
self.run_script, placeholder, finished_script)
self.builder.add_on_finished_callback(
callback, order=placeholder.order)
# If placeholder should be deleted, delete it after finish so
# the scripts have access to it up to the last run
if not placeholder.data.get("keep_placeholder", True):
delete_callback = weakref_partial(
self.delete_placeholder, placeholder)
self.builder.add_on_finished_callback(
delete_callback, order=placeholder.order + 1)
def run_script(self, placeholder, script, event=None):
"""Run script
Even though `placeholder` is an unused arguments by exposing it as
an input argument it means it makes it available through
globals()/locals() in the `exec` call, giving the script access
to the placeholder.
For example:
>>> node = placeholder.scene_identifier
In the case the script is running at a callback level (not during
populate) then it has access to the `event` as well, otherwise the
value is None if it runs during `populate_placeholder` directly.
For example adding this as the callback script:
>>> if event is not None:
>>> if event.topic == "on_depth_processed":
>>> print(f"Processed depth: {event.get('depth')}")
>>> elif event.topic == "on_finished":
>>> print("Build finished.")
"""
self.log.debug(f"Running script at event: {event}")
exec(script, locals())

View file

@ -35,8 +35,12 @@ class ImageCreator(Creator):
create_empty_group = False
stub = api.stub() # only after PS is up
top_level_selected_items = stub.get_selected_layers()
if pre_create_data.get("use_selection"):
try:
top_level_selected_items = stub.get_selected_layers()
except ValueError:
raise CreatorError("Cannot group locked Background layer!")
only_single_item_selected = len(top_level_selected_items) == 1
if (
only_single_item_selected or
@ -50,11 +54,12 @@ class ImageCreator(Creator):
group = stub.group_selected_layers(product_name_from_ui)
groups_to_create.append(group)
else:
stub.select_layers(stub.get_layers())
try:
stub.select_layers(stub.get_layers())
group = stub.group_selected_layers(product_name_from_ui)
except:
except ValueError:
raise CreatorError("Cannot group locked Background layer!")
groups_to_create.append(group)
# create empty group if nothing selected

View file

@ -1,5 +1,3 @@
import os
import pyblish.api
import pyblish.util

View file

@ -139,6 +139,7 @@ from .path_tools import (
)
from .ayon_info import (
is_in_ayon_launcher_process,
is_running_from_build,
is_using_ayon_console,
is_staging_enabled,
@ -248,6 +249,7 @@ __all__ = [
"Logger",
"is_in_ayon_launcher_process",
"is_running_from_build",
"is_using_ayon_console",
"is_staging_enabled",

View file

@ -1,4 +1,5 @@
import os
import sys
import json
import datetime
import platform
@ -25,6 +26,18 @@ def get_ayon_launcher_version():
return content["__version__"]
def is_in_ayon_launcher_process():
"""Determine if current process is running from AYON launcher.
Returns:
bool: True if running from AYON launcher.
"""
ayon_executable_path = os.path.normpath(os.environ["AYON_EXECUTABLE"])
executable_path = os.path.normpath(sys.executable)
return ayon_executable_path == executable_path
def is_running_from_build():
"""Determine if current process is running from build or code.

View file

@ -467,8 +467,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
# Inject deadline url to instances to query DL for job id for overrides
for inst in instances:
if not "deadline" in inst:
inst["deadline"] = {}
inst["deadline"] = instance.data["deadline"]
# publish job file

View file

@ -72,7 +72,7 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin,
auth=auth,
log=self.log)
# some DL return "none" as a pool name
if not "none" in pools:
if "none" not in pools:
pools.append("none")
self.log.info("Available pools: {}".format(pools))
self.pools_per_url[deadline_url] = pools

View file

@ -2053,7 +2053,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
except: # noqa: E722
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
@ -2163,7 +2163,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@ -2197,7 +2197,7 @@ class CreateContext:
try:
convertor.find_instances()
except:
except: # noqa: E722
failed_info.append(
prepare_failed_convertor_operation_info(
convertor.identifier, sys.exc_info()
@ -2373,7 +2373,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@ -2440,7 +2440,7 @@ class CreateContext:
error_message.format(identifier, exc_info[1])
)
except:
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@ -2546,7 +2546,7 @@ class CreateContext:
try:
self.run_convertor(convertor_identifier)
except:
except: # noqa: E722
failed_info.append(
prepare_failed_convertor_operation_info(
convertor_identifier, sys.exc_info()

View file

@ -73,8 +73,8 @@ def get_folder_template_data(folder_entity, project_name):
- 'parent' - direct parent name, project name used if is under
project
Required document fields:
Folder: 'path' -> Plan to require: 'folderType'
Required entity fields:
Folder: 'path', 'folderType'
Args:
folder_entity (Dict[str, Any]): Folder entity.
@ -101,6 +101,8 @@ def get_folder_template_data(folder_entity, project_name):
return {
"folder": {
"name": folder_name,
"type": folder_entity["folderType"],
"path": path,
},
"asset": folder_name,
"hierarchy": hierarchy,

View file

@ -0,0 +1,263 @@
import os
import time
import collections
import ayon_api
from ayon_core.lib.local_settings import get_ayon_appdirs
FileInfo = collections.namedtuple(
"FileInfo",
("path", "size", "modification_time")
)
class ThumbnailsCache:
"""Cache of thumbnails on local storage.
Thumbnails are cached to appdirs to predefined directory. Each project has
own subfolder with thumbnails -> that's because each project has own
thumbnail id validation and file names are thumbnail ids with matching
extension. Extensions are predefined (.png and .jpeg).
Cache has cleanup mechanism which is triggered on initialized by default.
The cleanup has 2 levels:
1. soft cleanup which remove all files that are older then 'days_alive'
2. max size cleanup which remove all files until the thumbnails folder
contains less then 'max_filesize'
- this is time consuming so it's not triggered automatically
Args:
cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails).
"""
# Lifetime of thumbnails (in seconds)
# - default 3 days
days_alive = 3
# Max size of thumbnail directory (in bytes)
# - default 2 Gb
max_filesize = 2 * 1024 * 1024 * 1024
def __init__(self, cleanup=True):
self._thumbnails_dir = None
self._days_alive_secs = self.days_alive * 24 * 60 * 60
if cleanup:
self.cleanup()
def get_thumbnails_dir(self):
"""Root directory where thumbnails are stored.
Returns:
str: Path to thumbnails root.
"""
if self._thumbnails_dir is None:
self._thumbnails_dir = get_ayon_appdirs("thumbnails")
return self._thumbnails_dir
thumbnails_dir = property(get_thumbnails_dir)
def get_thumbnails_dir_file_info(self):
"""Get information about all files in thumbnails directory.
Returns:
List[FileInfo]: List of file information about all files.
"""
thumbnails_dir = self.thumbnails_dir
files_info = []
if not os.path.exists(thumbnails_dir):
return files_info
for root, _, filenames in os.walk(thumbnails_dir):
for filename in filenames:
path = os.path.join(root, filename)
files_info.append(FileInfo(
path, os.path.getsize(path), os.path.getmtime(path)
))
return files_info
def get_thumbnails_dir_size(self, files_info=None):
"""Got full size of thumbnail directory.
Args:
files_info (List[FileInfo]): Prepared file information about
files in thumbnail directory.
Returns:
int: File size of all files in thumbnail directory.
"""
if files_info is None:
files_info = self.get_thumbnails_dir_file_info()
if not files_info:
return 0
return sum(
file_info.size
for file_info in files_info
)
def cleanup(self, check_max_size=False):
"""Cleanup thumbnails directory.
Args:
check_max_size (bool): Also cleanup files to match max size of
thumbnails directory.
"""
thumbnails_dir = self.get_thumbnails_dir()
# Skip if thumbnails dir does not exist yet
if not os.path.exists(thumbnails_dir):
return
self._soft_cleanup(thumbnails_dir)
if check_max_size:
self._max_size_cleanup(thumbnails_dir)
def _soft_cleanup(self, thumbnails_dir):
current_time = time.time()
for root, _, filenames in os.walk(thumbnails_dir):
for filename in filenames:
path = os.path.join(root, filename)
modification_time = os.path.getmtime(path)
if current_time - modification_time > self._days_alive_secs:
os.remove(path)
def _max_size_cleanup(self, thumbnails_dir):
files_info = self.get_thumbnails_dir_file_info()
size = self.get_thumbnails_dir_size(files_info)
if size < self.max_filesize:
return
sorted_file_info = collections.deque(
sorted(files_info, key=lambda item: item.modification_time)
)
diff = size - self.max_filesize
while diff > 0:
if not sorted_file_info:
break
file_info = sorted_file_info.popleft()
diff -= file_info.size
os.remove(file_info.path)
def get_thumbnail_filepath(self, project_name, thumbnail_id):
"""Get thumbnail by thumbnail id.
Args:
project_name (str): Name of project.
thumbnail_id (str): Thumbnail id.
Returns:
Union[str, None]: Path to thumbnail image or None if thumbnail
is not cached yet.
"""
if not thumbnail_id:
return None
for ext in (
".png",
".jpeg",
):
filepath = os.path.join(
self.thumbnails_dir, project_name, thumbnail_id + ext
)
if os.path.exists(filepath):
return filepath
return None
def get_project_dir(self, project_name):
"""Path to root directory for specific project.
Args:
project_name (str): Name of project for which root directory path
should be returned.
Returns:
str: Path to root of project's thumbnails.
"""
return os.path.join(self.thumbnails_dir, project_name)
def make_sure_project_dir_exists(self, project_name):
project_dir = self.get_project_dir(project_name)
if not os.path.exists(project_dir):
os.makedirs(project_dir)
return project_dir
def store_thumbnail(self, project_name, thumbnail_id, content, mime_type):
"""Store thumbnail to cache folder.
Args:
project_name (str): Project where the thumbnail belong to.
thumbnail_id (str): Thumbnail id.
content (bytes): Byte content of thumbnail file.
mime_type (str): Type of content.
Returns:
str: Path to cached thumbnail image file.
"""
if mime_type == "image/png":
ext = ".png"
elif mime_type == "image/jpeg":
ext = ".jpeg"
else:
raise ValueError(
"Unknown mime type for thumbnail \"{}\"".format(mime_type))
project_dir = self.make_sure_project_dir_exists(project_name)
thumbnail_path = os.path.join(project_dir, thumbnail_id + ext)
with open(thumbnail_path, "wb") as stream:
stream.write(content)
current_time = time.time()
os.utime(thumbnail_path, (current_time, current_time))
return thumbnail_path
class _CacheItems:
thumbnails_cache = ThumbnailsCache()
def get_thumbnail_path(project_name, thumbnail_id):
"""Get path to thumbnail image.
Args:
project_name (str): Project where thumbnail belongs to.
thumbnail_id (Union[str, None]): Thumbnail id.
Returns:
Union[str, None]: Path to thumbnail image or None if thumbnail
id is not valid or thumbnail was not possible to receive.
"""
if not thumbnail_id:
return None
filepath = _CacheItems.thumbnails_cache.get_thumbnail_filepath(
project_name, thumbnail_id
)
if filepath is not None:
return filepath
# 'ayon_api' had a bug, public function
# 'get_thumbnail_by_id' did not return output of
# 'ServerAPI' method.
con = ayon_api.get_server_api_connection()
result = con.get_thumbnail_by_id(project_name, thumbnail_id)
if result is not None and result.is_valid:
return _CacheItems.thumbnails_cache.store_thumbnail(
project_name,
thumbnail_id,
result.content,
result.content_type
)
return None

View file

@ -33,6 +33,7 @@ import collections
import pyblish.api
import ayon_api
from ayon_core.pipeline.template_data import get_folder_template_data
from ayon_core.pipeline.version_start import get_versioning_start
@ -383,24 +384,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# - 'folder', 'hierarchy', 'parent', 'folder'
folder_entity = instance.data.get("folderEntity")
if folder_entity:
folder_name = folder_entity["name"]
folder_path = folder_entity["path"]
hierarchy_parts = folder_path.split("/")
hierarchy_parts.pop(0)
hierarchy_parts.pop(-1)
parent_name = project_entity["name"]
if hierarchy_parts:
parent_name = hierarchy_parts[-1]
hierarchy = "/".join(hierarchy_parts)
anatomy_data.update({
"asset": folder_name,
"hierarchy": hierarchy,
"parent": parent_name,
"folder": {
"name": folder_name,
},
})
folder_data = get_folder_template_data(
folder_entity,
project_entity["name"]
)
anatomy_data.update(folder_data)
return
if instance.data.get("newAssetPublishing"):
@ -418,6 +406,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
"parent": parent_name,
"folder": {
"name": folder_name,
"path": instance.data["folderPath"],
# TODO get folder type from hierarchy
# Using 'Shot' is current default behavior of editorial
# (or 'newAssetPublishing') publishing.
"type": "Shot",
},
})

View file

@ -104,14 +104,11 @@ class WebServerTool:
again. In that case, use existing running webserver.
Check here is easier than capturing exception from thread.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
result = True
try:
sock.bind((host_name, port))
result = False
except:
print("Port is in use")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as con:
result = con.connect_ex((host_name, port)) == 0
if result:
print(f"Port {port} is already in use")
return result
def call(self, func):

View file

@ -1,234 +1,15 @@
import os
import time
import collections
import ayon_api
import appdirs
from ayon_core.lib import NestedCacheItem
FileInfo = collections.namedtuple(
"FileInfo",
("path", "size", "modification_time")
)
class ThumbnailsCache:
"""Cache of thumbnails on local storage.
Thumbnails are cached to appdirs to predefined directory. Each project has
own subfolder with thumbnails -> that's because each project has own
thumbnail id validation and file names are thumbnail ids with matching
extension. Extensions are predefined (.png and .jpeg).
Cache has cleanup mechanism which is triggered on initialized by default.
The cleanup has 2 levels:
1. soft cleanup which remove all files that are older then 'days_alive'
2. max size cleanup which remove all files until the thumbnails folder
contains less then 'max_filesize'
- this is time consuming so it's not triggered automatically
Args:
cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails).
"""
# Lifetime of thumbnails (in seconds)
# - default 3 days
days_alive = 3
# Max size of thumbnail directory (in bytes)
# - default 2 Gb
max_filesize = 2 * 1024 * 1024 * 1024
def __init__(self, cleanup=True):
self._thumbnails_dir = None
self._days_alive_secs = self.days_alive * 24 * 60 * 60
if cleanup:
self.cleanup()
def get_thumbnails_dir(self):
"""Root directory where thumbnails are stored.
Returns:
str: Path to thumbnails root.
"""
if self._thumbnails_dir is None:
# TODO use generic function
directory = appdirs.user_data_dir("AYON", "Ynput")
self._thumbnails_dir = os.path.join(directory, "thumbnails")
return self._thumbnails_dir
thumbnails_dir = property(get_thumbnails_dir)
def get_thumbnails_dir_file_info(self):
"""Get information about all files in thumbnails directory.
Returns:
List[FileInfo]: List of file information about all files.
"""
thumbnails_dir = self.thumbnails_dir
files_info = []
if not os.path.exists(thumbnails_dir):
return files_info
for root, _, filenames in os.walk(thumbnails_dir):
for filename in filenames:
path = os.path.join(root, filename)
files_info.append(FileInfo(
path, os.path.getsize(path), os.path.getmtime(path)
))
return files_info
def get_thumbnails_dir_size(self, files_info=None):
"""Got full size of thumbnail directory.
Args:
files_info (List[FileInfo]): Prepared file information about
files in thumbnail directory.
Returns:
int: File size of all files in thumbnail directory.
"""
if files_info is None:
files_info = self.get_thumbnails_dir_file_info()
if not files_info:
return 0
return sum(
file_info.size
for file_info in files_info
)
def cleanup(self, check_max_size=False):
"""Cleanup thumbnails directory.
Args:
check_max_size (bool): Also cleanup files to match max size of
thumbnails directory.
"""
thumbnails_dir = self.get_thumbnails_dir()
# Skip if thumbnails dir does not exist yet
if not os.path.exists(thumbnails_dir):
return
self._soft_cleanup(thumbnails_dir)
if check_max_size:
self._max_size_cleanup(thumbnails_dir)
def _soft_cleanup(self, thumbnails_dir):
current_time = time.time()
for root, _, filenames in os.walk(thumbnails_dir):
for filename in filenames:
path = os.path.join(root, filename)
modification_time = os.path.getmtime(path)
if current_time - modification_time > self._days_alive_secs:
os.remove(path)
def _max_size_cleanup(self, thumbnails_dir):
files_info = self.get_thumbnails_dir_file_info()
size = self.get_thumbnails_dir_size(files_info)
if size < self.max_filesize:
return
sorted_file_info = collections.deque(
sorted(files_info, key=lambda item: item.modification_time)
)
diff = size - self.max_filesize
while diff > 0:
if not sorted_file_info:
break
file_info = sorted_file_info.popleft()
diff -= file_info.size
os.remove(file_info.path)
def get_thumbnail_filepath(self, project_name, thumbnail_id):
"""Get thumbnail by thumbnail id.
Args:
project_name (str): Name of project.
thumbnail_id (str): Thumbnail id.
Returns:
Union[str, None]: Path to thumbnail image or None if thumbnail
is not cached yet.
"""
if not thumbnail_id:
return None
for ext in (
".png",
".jpeg",
):
filepath = os.path.join(
self.thumbnails_dir, project_name, thumbnail_id + ext
)
if os.path.exists(filepath):
return filepath
return None
def get_project_dir(self, project_name):
"""Path to root directory for specific project.
Args:
project_name (str): Name of project for which root directory path
should be returned.
Returns:
str: Path to root of project's thumbnails.
"""
return os.path.join(self.thumbnails_dir, project_name)
def make_sure_project_dir_exists(self, project_name):
project_dir = self.get_project_dir(project_name)
if not os.path.exists(project_dir):
os.makedirs(project_dir)
return project_dir
def store_thumbnail(self, project_name, thumbnail_id, content, mime_type):
"""Store thumbnail to cache folder.
Args:
project_name (str): Project where the thumbnail belong to.
thumbnail_id (str): Id of thumbnail.
content (bytes): Byte content of thumbnail file.
mime_data (str): Type of content.
Returns:
str: Path to cached thumbnail image file.
"""
if mime_type == "image/png":
ext = ".png"
elif mime_type == "image/jpeg":
ext = ".jpeg"
else:
raise ValueError(
"Unknown mime type for thumbnail \"{}\"".format(mime_type))
project_dir = self.make_sure_project_dir_exists(project_name)
thumbnail_path = os.path.join(project_dir, thumbnail_id + ext)
with open(thumbnail_path, "wb") as stream:
stream.write(content)
current_time = time.time()
os.utime(thumbnail_path, (current_time, current_time))
return thumbnail_path
from ayon_core.pipeline.thumbnails import get_thumbnail_path
class ThumbnailsModel:
entity_cache_lifetime = 240 # In seconds
def __init__(self):
self._thumbnail_cache = ThumbnailsCache()
self._paths_cache = collections.defaultdict(dict)
self._folders_cache = NestedCacheItem(
levels=2, lifetime=self.entity_cache_lifetime)
@ -283,28 +64,7 @@ class ThumbnailsModel:
if thumbnail_id in project_cache:
return project_cache[thumbnail_id]
filepath = self._thumbnail_cache.get_thumbnail_filepath(
project_name, thumbnail_id
)
if filepath is not None:
project_cache[thumbnail_id] = filepath
return filepath
# 'ayon_api' had a bug, public function
# 'get_thumbnail_by_id' did not return output of
# 'ServerAPI' method.
con = ayon_api.get_server_api_connection()
result = con.get_thumbnail_by_id(project_name, thumbnail_id)
if result is None:
pass
elif result.is_valid:
filepath = self._thumbnail_cache.store_thumbnail(
project_name,
thumbnail_id,
result.content,
result.content_type
)
filepath = get_thumbnail_path(project_name, thumbnail_id)
project_cache[thumbnail_id] = filepath
return filepath

View file

@ -52,6 +52,7 @@ class SelectionTypes:
class BaseGroupWidget(QtWidgets.QWidget):
selected = QtCore.Signal(str, str, str)
removed_selected = QtCore.Signal()
double_clicked = QtCore.Signal()
def __init__(self, group_name, parent):
super(BaseGroupWidget, self).__init__(parent)
@ -192,6 +193,7 @@ class ConvertorItemsGroupWidget(BaseGroupWidget):
else:
widget = ConvertorItemCardWidget(item, self)
widget.selected.connect(self._on_widget_selection)
widget.double_clicked(self.double_clicked)
self._widgets_by_id[item.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1
@ -254,6 +256,7 @@ class InstanceGroupWidget(BaseGroupWidget):
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)
widget.double_clicked.connect(self.double_clicked)
self._widgets_by_id[instance.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1
@ -271,6 +274,7 @@ class CardWidget(BaseClickableFrame):
# Group identifier of card
# - this must be set because if send when mouse is released with card id
_group_identifier = None
double_clicked = QtCore.Signal()
def __init__(self, parent):
super(CardWidget, self).__init__(parent)
@ -279,6 +283,11 @@ class CardWidget(BaseClickableFrame):
self._selected = False
self._id = None
def mouseDoubleClickEvent(self, event):
super(CardWidget, self).mouseDoubleClickEvent(event)
if self._is_valid_double_click(event):
self.double_clicked.emit()
@property
def id(self):
"""Id of card."""
@ -312,6 +321,9 @@ class CardWidget(BaseClickableFrame):
self.selected.emit(self._id, self._group_identifier, selection_type)
def _is_valid_double_click(self, event):
return True
class ContextCardWidget(CardWidget):
"""Card for global context.
@ -527,6 +539,15 @@ class InstanceCardWidget(CardWidget):
def _on_expend_clicked(self):
self._set_expanded()
def _is_valid_double_click(self, event):
widget = self.childAt(event.pos())
if (
widget is self._active_checkbox
or widget is self._expand_btn
):
return False
return True
class InstanceCardView(AbstractInstanceView):
"""Publish access to card view.
@ -534,6 +555,8 @@ class InstanceCardView(AbstractInstanceView):
Wrapper of all widgets in card view.
"""
double_clicked = QtCore.Signal()
def __init__(self, controller, parent):
super(InstanceCardView, self).__init__(parent)
@ -715,6 +738,7 @@ class InstanceCardView(AbstractInstanceView):
)
group_widget.active_changed.connect(self._on_active_changed)
group_widget.selected.connect(self._on_widget_selection)
group_widget.double_clicked.connect(self.double_clicked)
self._content_layout.insertWidget(widget_idx, group_widget)
self._widgets_by_group[group_name] = group_widget
@ -755,6 +779,7 @@ class InstanceCardView(AbstractInstanceView):
widget = ContextCardWidget(self._content_widget)
widget.selected.connect(self._on_widget_selection)
widget.double_clicked.connect(self.double_clicked)
self._context_widget = widget
@ -778,6 +803,7 @@ class InstanceCardView(AbstractInstanceView):
CONVERTOR_ITEM_GROUP, self._content_widget
)
group_widget.selected.connect(self._on_widget_selection)
group_widget.double_clicked.connect(self.double_clicked)
self._content_layout.insertWidget(1, group_widget)
self._convertor_items_group = group_widget

View file

@ -110,6 +110,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
This is required to be able use custom checkbox on custom place.
"""
active_changed = QtCore.Signal(str, bool)
double_clicked = QtCore.Signal()
def __init__(self, instance, parent):
super(InstanceListItemWidget, self).__init__(parent)
@ -149,6 +150,12 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self._set_valid_property(instance.has_valid_context)
def mouseDoubleClickEvent(self, event):
widget = self.childAt(event.pos())
super(InstanceListItemWidget, self).mouseDoubleClickEvent(event)
if widget is not self._active_checkbox:
self.double_clicked.emit()
def _set_valid_property(self, valid):
if self._has_valid_context == valid:
return
@ -209,6 +216,8 @@ class InstanceListItemWidget(QtWidgets.QWidget):
class ListContextWidget(QtWidgets.QFrame):
"""Context (or global attributes) widget."""
double_clicked = QtCore.Signal()
def __init__(self, parent):
super(ListContextWidget, self).__init__(parent)
@ -225,6 +234,10 @@ class ListContextWidget(QtWidgets.QFrame):
self.label_widget = label_widget
def mouseDoubleClickEvent(self, event):
super(ListContextWidget, self).mouseDoubleClickEvent(event)
self.double_clicked.emit()
class InstanceListGroupWidget(QtWidgets.QFrame):
"""Widget representing group of instances.
@ -317,6 +330,7 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
class InstanceTreeView(QtWidgets.QTreeView):
"""View showing instances and their groups."""
toggle_requested = QtCore.Signal(int)
double_clicked = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(InstanceTreeView, self).__init__(*args, **kwargs)
@ -425,6 +439,9 @@ class InstanceListView(AbstractInstanceView):
This is public access to and from list view.
"""
double_clicked = QtCore.Signal()
def __init__(self, controller, parent):
super(InstanceListView, self).__init__(parent)
@ -454,6 +471,7 @@ class InstanceListView(AbstractInstanceView):
instance_view.collapsed.connect(self._on_collapse)
instance_view.expanded.connect(self._on_expand)
instance_view.toggle_requested.connect(self._on_toggle_request)
instance_view.double_clicked.connect(self.double_clicked)
self._group_items = {}
self._group_widgets = {}
@ -687,6 +705,7 @@ class InstanceListView(AbstractInstanceView):
self._active_toggle_enabled
)
widget.active_changed.connect(self._on_active_changed)
widget.double_clicked.connect(self.double_clicked)
self._instance_view.setIndexWidget(proxy_index, widget)
self._widgets_by_id[instance.id] = widget
@ -717,6 +736,7 @@ class InstanceListView(AbstractInstanceView):
)
proxy_index = self._proxy_model.mapFromSource(index)
widget = ListContextWidget(self._instance_view)
widget.double_clicked.connect(self.double_clicked)
self._instance_view.setIndexWidget(proxy_index, widget)
self._context_widget = widget

View file

@ -18,6 +18,7 @@ class OverviewWidget(QtWidgets.QFrame):
instance_context_changed = QtCore.Signal()
create_requested = QtCore.Signal()
convert_requested = QtCore.Signal()
publish_tab_requested = QtCore.Signal()
anim_end_value = 200
anim_duration = 200
@ -113,9 +114,15 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.selection_changed.connect(
self._on_product_change
)
product_list_view.double_clicked.connect(
self.publish_tab_requested
)
product_view_cards.selection_changed.connect(
self._on_product_change
)
product_view_cards.double_clicked.connect(
self.publish_tab_requested
)
# Active instances changed
product_list_view.active_changed.connect(
self._on_active_changed

View file

@ -258,6 +258,9 @@ class PublisherWindow(QtWidgets.QDialog):
overview_widget.convert_requested.connect(
self._on_convert_requested
)
overview_widget.publish_tab_requested.connect(
self._go_to_publish_tab
)
save_btn.clicked.connect(self._on_save_clicked)
reset_btn.clicked.connect(self._on_reset_clicked)

View file

@ -1,3 +1,3 @@
name = "maya"
title = "Maya"
version = "0.1.17"
version = "0.1.18"

View file

@ -46,7 +46,6 @@ def extract_alembic_overrides_enum():
return [
{"label": "Custom Attributes", "value": "attr"},
{"label": "Custom Attributes Prefix", "value": "attrPrefix"},
{"label": "Auto Subd", "value": "autoSubd"},
{"label": "Data Format", "value": "dataFormat"},
{"label": "Euler Filter", "value": "eulerFilter"},
{"label": "Mel Per Frame Callback", "value": "melPerFrameCallback"},
@ -347,17 +346,6 @@ class ExtractAlembicModel(BaseSettingsModel):
families: list[str] = SettingsField(
default_factory=list,
title="Families")
autoSubd: bool = SettingsField(
title="Auto Subd",
description=(
"If this flag is present and the mesh has crease edges, crease "
"vertices or holes, the mesh (OPolyMesh) would now be written out "
"as an OSubD and crease info will be stored in the Alembic file. "
"Otherwise, creases info won't be preserved in Alembic file unless"
" a custom Boolean attribute SubDivisionMesh has been added to "
"mesh node and its value is true."
)
)
eulerFilter: bool = SettingsField(
title="Euler Filter",
description="Apply Euler filter while sampling rotations."
@ -409,6 +397,10 @@ class ExtractAlembicModel(BaseSettingsModel):
title="Write Color Sets",
description="Write vertex colors with the geometry."
)
writeCreases: bool = SettingsField(
title="Write Creases",
description="Write the geometry's edge and vertex crease information."
)
writeFaceSets: bool = SettingsField(
title="Write Face Sets",
description="Write face sets with the geometry."
@ -1617,7 +1609,6 @@ DEFAULT_PUBLISH_SETTINGS = {
],
"attr": "",
"attrPrefix": "",
"autoSubd": False,
"bake_attributes": [],
"bake_attribute_prefixes": [],
"dataFormat": "ogawa",
@ -1641,7 +1632,7 @@ DEFAULT_PUBLISH_SETTINGS = {
"renderableOnly": False,
"stripNamespaces": True,
"uvsOnly": False,
"uvWrite": False,
"uvWrite": True,
"userAttr": "",
"userAttrPrefix": "",
"verbose": False,
@ -1649,6 +1640,7 @@ DEFAULT_PUBLISH_SETTINGS = {
"wholeFrameGeo": False,
"worldSpace": True,
"writeColorSets": False,
"writeCreases": False,
"writeFaceSets": False,
"writeNormals": True,
"writeUVSets": False,