diff --git a/pype/blender/__init__.py b/pype/blender/__init__.py
new file mode 100644
index 0000000000..8a29917e40
--- /dev/null
+++ b/pype/blender/__init__.py
@@ -0,0 +1,34 @@
+import logging
+from pathlib import Path
+import os
+
+import bpy
+
+from avalon import api as avalon
+from pyblish import api as pyblish
+
+from .plugin import AssetLoader
+
+logger = logging.getLogger("pype.blender")
+
+PARENT_DIR = os.path.dirname(__file__)
+PACKAGE_DIR = os.path.dirname(PARENT_DIR)
+PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins")
+
+PUBLISH_PATH = os.path.join(PLUGINS_DIR, "blender", "publish")
+LOAD_PATH = os.path.join(PLUGINS_DIR, "blender", "load")
+CREATE_PATH = os.path.join(PLUGINS_DIR, "blender", "create")
+
+
+def install():
+ """Install Blender configuration for Avalon."""
+ pyblish.register_plugin_path(str(PUBLISH_PATH))
+ avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH))
+ avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH))
+
+
+def uninstall():
+ """Uninstall Blender configuration for Avalon."""
+ pyblish.deregister_plugin_path(str(PUBLISH_PATH))
+ avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH))
+ avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH))
diff --git a/pype/blender/action.py b/pype/blender/action.py
new file mode 100644
index 0000000000..4bd7e303fc
--- /dev/null
+++ b/pype/blender/action.py
@@ -0,0 +1,47 @@
+import bpy
+
+import pyblish.api
+
+from ..action import get_errored_instances_from_context
+
+
+class SelectInvalidAction(pyblish.api.Action):
+ """Select invalid objects in Blender when a publish plug-in failed."""
+ label = "Select Invalid"
+ on = "failed"
+ icon = "search"
+
+ def process(self, context, plugin):
+ errored_instances = get_errored_instances_from_context(context)
+ instances = pyblish.api.instances_by_plugin(errored_instances, plugin)
+
+ # Get the invalid nodes for the plug-ins
+ self.log.info("Finding invalid nodes...")
+ invalid = list()
+ for instance in instances:
+ invalid_nodes = plugin.get_invalid(instance)
+ if invalid_nodes:
+ if isinstance(invalid_nodes, (list, tuple)):
+ invalid.extend(invalid_nodes)
+ else:
+ self.log.warning(
+ "Failed plug-in doens't have any selectable objects."
+ )
+
+ bpy.ops.object.select_all(action='DESELECT')
+
+ # Make sure every node is only processed once
+ invalid = list(set(invalid))
+ if not invalid:
+ self.log.info("No invalid nodes found.")
+ return
+
+ invalid_names = [obj.name for obj in invalid]
+ self.log.info(
+ "Selecting invalid objects: %s", ", ".join(invalid_names)
+ )
+ # Select the objects and also make the last one the active object.
+ for obj in invalid:
+ obj.select_set(True)
+
+ bpy.context.view_layer.objects.active = invalid[-1]
diff --git a/pype/blender/plugin.py b/pype/blender/plugin.py
new file mode 100644
index 0000000000..ad5a259785
--- /dev/null
+++ b/pype/blender/plugin.py
@@ -0,0 +1,135 @@
+"""Shared functionality for pipeline plugins for Blender."""
+
+from pathlib import Path
+from typing import Dict, List, Optional
+
+import bpy
+
+from avalon import api
+
+VALID_EXTENSIONS = [".blend"]
+
+
+def model_name(asset: str, subset: str, namespace: Optional[str] = None) -> str:
+ """Return a consistent name for a model asset."""
+ name = f"{asset}_{subset}"
+ if namespace:
+ name = f"{namespace}:{name}"
+ return name
+
+
+class AssetLoader(api.Loader):
+ """A basic AssetLoader for Blender
+
+ This will implement the basic logic for linking/appending assets
+ into another Blender scene.
+
+ The `update` method should be implemented by a sub-class, because
+ it's different for different types (e.g. model, rig, animation,
+ etc.).
+ """
+
+ @staticmethod
+ def _get_instance_empty(instance_name: str, nodes: List) -> Optional[bpy.types.Object]:
+ """Get the 'instance empty' that holds the collection instance."""
+ for node in nodes:
+ if not isinstance(node, bpy.types.Object):
+ continue
+ if (node.type == 'EMPTY' and node.instance_type == 'COLLECTION'
+ and node.instance_collection and node.name == instance_name):
+ return node
+ return None
+
+ @staticmethod
+ def _get_instance_collection(instance_name: str, nodes: List) -> Optional[bpy.types.Collection]:
+ """Get the 'instance collection' (container) for this asset."""
+ for node in nodes:
+ if not isinstance(node, bpy.types.Collection):
+ continue
+ if node.name == instance_name:
+ return node
+ return None
+
+ @staticmethod
+ def _get_library_from_container(container: bpy.types.Collection) -> bpy.types.Library:
+ """Find the library file from the container.
+
+ It traverses the objects from this collection, checks if there is only
+ 1 library from which the objects come from and returns the library.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ assert not container.children, "Nested collections are not supported."
+ assert container.objects, "The collection doesn't contain any objects."
+ libraries = set()
+ for obj in container.objects:
+ assert obj.library, f"'{obj.name}' is not linked."
+ libraries.add(obj.library)
+
+ assert len(libraries) == 1, "'{container.name}' contains objects from more then 1 library."
+
+ return list(libraries)[0]
+
+ def process_asset(self,
+ context: dict,
+ name: str,
+ namespace: Optional[str] = None,
+ options: Optional[Dict] = None):
+ """Must be implemented by a sub-class"""
+ raise NotImplementedError("Must be implemented by a sub-class")
+
+ def load(self,
+ context: dict,
+ name: Optional[str] = None,
+ namespace: Optional[str] = None,
+ options: Optional[Dict] = None) -> Optional[bpy.types.Collection]:
+ """Load asset via database
+
+ Arguments:
+ context: Full parenthood of representation to load
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ options: Additional settings dictionary
+ """
+ # TODO (jasper): make it possible to add the asset several times by
+ # just re-using the collection
+ assert Path(self.fname).exists(), f"{self.fname} doesn't exist."
+
+ self.process_asset(
+ context=context,
+ name=name,
+ namespace=namespace,
+ options=options,
+ )
+
+ # Only containerise if anything was loaded by the Loader.
+ nodes = self[:]
+ if not nodes:
+ return None
+
+ # Only containerise if it's not already a collection from a .blend file.
+ representation = context["representation"]["name"]
+ if representation != "blend":
+ from avalon.blender.pipeline import containerise
+ return containerise(
+ name=name,
+ namespace=namespace,
+ nodes=nodes,
+ context=context,
+ loader=self.__class__.__name__,
+ )
+
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+ instance_name = model_name(asset, subset, namespace)
+
+ return self._get_instance_collection(instance_name, nodes)
+
+ def update(self, container: Dict, representation: Dict):
+ """Must be implemented by a sub-class"""
+ raise NotImplementedError("Must be implemented by a sub-class")
+
+ def remove(self, container: Dict) -> bool:
+ """Must be implemented by a sub-class"""
+ raise NotImplementedError("Must be implemented by a sub-class")
diff --git a/pype/ftrack/actions/action_delivery.py b/pype/ftrack/actions/action_delivery.py
new file mode 100644
index 0000000000..afd20d12d1
--- /dev/null
+++ b/pype/ftrack/actions/action_delivery.py
@@ -0,0 +1,538 @@
+import os
+import copy
+import shutil
+import collections
+import string
+
+import clique
+from bson.objectid import ObjectId
+
+from avalon import pipeline
+from avalon.vendor import filelink
+from avalon.tools.libraryloader.io_nonsingleton import DbConnector
+
+from pypeapp import Anatomy
+from pype.ftrack import BaseAction
+from pype.ftrack.lib.avalon_sync import CustAttrIdKey
+
+
+class Delivery(BaseAction):
+ '''Edit meta data action.'''
+
+ #: Action identifier.
+ identifier = "delivery.action"
+ #: Action label.
+ label = "Delivery"
+ #: Action description.
+ description = "Deliver data to client"
+ #: roles that are allowed to register this action
+ role_list = ["Pypeclub", "Administrator", "Project manager"]
+ icon = '{}/ftrack/action_icons/Delivery.svg'.format(
+ os.environ.get('PYPE_STATICS_SERVER', '')
+ )
+
+ db_con = DbConnector()
+
+ def discover(self, session, entities, event):
+ ''' Validation '''
+ for entity in entities:
+ if entity.entity_type.lower() == "assetversion":
+ return True
+
+ return False
+
+ def interface(self, session, entities, event):
+ if event["data"].get("values", {}):
+ return
+
+ title = "Delivery data to Client"
+
+ items = []
+ item_splitter = {"type": "label", "value": "---"}
+
+ # Prepare component names for processing
+ components = None
+ project = None
+ for entity in entities:
+ if project is None:
+ project_id = None
+ for ent_info in entity["link"]:
+ if ent_info["type"].lower() == "project":
+ project_id = ent_info["id"]
+ break
+
+ if project_id is None:
+ project = entity["asset"]["parent"]["project"]
+ else:
+ project = session.query((
+ "select id, full_name from Project where id is \"{}\""
+ ).format(project_id)).one()
+
+ _components = set(
+ [component["name"] for component in entity["components"]]
+ )
+ if components is None:
+ components = _components
+ continue
+
+ components = components.intersection(_components)
+ if not components:
+ break
+
+ project_name = project["full_name"]
+ items.append({
+ "type": "hidden",
+ "name": "__project_name__",
+ "value": project_name
+ })
+
+ # Prpeare anatomy data
+ anatomy = Anatomy(project_name)
+ new_anatomies = []
+ first = None
+ for key in (anatomy.templates.get("delivery") or {}):
+ new_anatomies.append({
+ "label": key,
+ "value": key
+ })
+ if first is None:
+ first = key
+
+ skipped = False
+ # Add message if there are any common components
+ if not components or not new_anatomies:
+ skipped = True
+ items.append({
+ "type": "label",
+ "value": "
Something went wrong:
"
+ })
+
+ items.append({
+ "type": "hidden",
+ "name": "__skipped__",
+ "value": skipped
+ })
+
+ if not components:
+ if len(entities) == 1:
+ items.append({
+ "type": "label",
+ "value": (
+ "- Selected entity doesn't have components to deliver."
+ )
+ })
+ else:
+ items.append({
+ "type": "label",
+ "value": (
+ "- Selected entities don't have common components."
+ )
+ })
+
+ # Add message if delivery anatomies are not set
+ if not new_anatomies:
+ items.append({
+ "type": "label",
+ "value": (
+ "- `\"delivery\"` anatomy key is not set in config."
+ )
+ })
+
+ # Skip if there are any data shortcomings
+ if skipped:
+ return {
+ "items": items,
+ "title": title
+ }
+
+ items.append({
+ "value": "Choose Components to deliver
",
+ "type": "label"
+ })
+
+ for component in components:
+ items.append({
+ "type": "boolean",
+ "value": False,
+ "label": component,
+ "name": component
+ })
+
+ items.append(item_splitter)
+
+ items.append({
+ "value": "Location for delivery
",
+ "type": "label"
+ })
+
+ items.append({
+ "type": "label",
+ "value": (
+ "NOTE: It is possible to replace `root` key in anatomy."
+ )
+ })
+
+ items.append({
+ "type": "text",
+ "name": "__location_path__",
+ "empty_text": "Type location path here...(Optional)"
+ })
+
+ items.append(item_splitter)
+
+ items.append({
+ "value": "Anatomy of delivery files
",
+ "type": "label"
+ })
+
+ items.append({
+ "type": "label",
+ "value": (
+ "NOTE: These can be set in Anatomy.yaml"
+ " within `delivery` key.
"
+ )
+ })
+
+ items.append({
+ "type": "enumerator",
+ "name": "__new_anatomies__",
+ "data": new_anatomies,
+ "value": first
+ })
+
+ return {
+ "items": items,
+ "title": title
+ }
+
+ def launch(self, session, entities, event):
+ if "values" not in event["data"]:
+ return
+
+ self.report_items = collections.defaultdict(list)
+
+ values = event["data"]["values"]
+ skipped = values.pop("__skipped__")
+ if skipped:
+ return None
+
+ component_names = []
+ location_path = values.pop("__location_path__")
+ anatomy_name = values.pop("__new_anatomies__")
+ project_name = values.pop("__project_name__")
+
+ for key, value in values.items():
+ if value is True:
+ component_names.append(key)
+
+ if not component_names:
+ return {
+ "success": True,
+ "message": "Not selected components to deliver."
+ }
+
+ location_path = location_path.strip()
+ if location_path:
+ location_path = os.path.normpath(location_path)
+ if not os.path.exists(location_path):
+ return {
+ "success": False,
+ "message": (
+ "Entered location path does not exists. \"{}\""
+ ).format(location_path)
+ }
+
+ self.db_con.install()
+ self.db_con.Session["AVALON_PROJECT"] = project_name
+
+ repres_to_deliver = []
+ for entity in entities:
+ asset = entity["asset"]
+ subset_name = asset["name"]
+ version = entity["version"]
+
+ parent = asset["parent"]
+ parent_mongo_id = parent["custom_attributes"].get(CustAttrIdKey)
+ if parent_mongo_id:
+ parent_mongo_id = ObjectId(parent_mongo_id)
+ else:
+ asset_ent = self.db_con.find_one({
+ "type": "asset",
+ "data.ftrackId": parent["id"]
+ })
+ if not asset_ent:
+ ent_path = "/".join(
+ [ent["name"] for ent in parent["link"]]
+ )
+ msg = "Not synchronized entities to avalon"
+ self.report_items[msg].append(ent_path)
+ self.log.warning("{} <{}>".format(msg, ent_path))
+ continue
+
+ parent_mongo_id = asset_ent["_id"]
+
+ subset_ent = self.db_con.find_one({
+ "type": "subset",
+ "parent": parent_mongo_id,
+ "name": subset_name
+ })
+
+ version_ent = self.db_con.find_one({
+ "type": "version",
+ "name": version,
+ "parent": subset_ent["_id"]
+ })
+
+ repre_ents = self.db_con.find({
+ "type": "representation",
+ "parent": version_ent["_id"]
+ })
+
+ repres_by_name = {}
+ for repre in repre_ents:
+ repre_name = repre["name"]
+ repres_by_name[repre_name] = repre
+
+ for component in entity["components"]:
+ comp_name = component["name"]
+ if comp_name not in component_names:
+ continue
+
+ repre = repres_by_name.get(comp_name)
+ repres_to_deliver.append(repre)
+
+ if not location_path:
+ location_path = os.environ.get("AVALON_PROJECTS") or ""
+
+ print(location_path)
+
+ anatomy = Anatomy(project_name)
+ for repre in repres_to_deliver:
+ # Get destination repre path
+ anatomy_data = copy.deepcopy(repre["context"])
+ anatomy_data["root"] = location_path
+
+ anatomy_filled = anatomy.format(anatomy_data)
+ test_path = (
+ anatomy_filled
+ .get("delivery", {})
+ .get(anatomy_name)
+ )
+
+ if not test_path:
+ msg = (
+ "Missing keys in Representation's context"
+ " for anatomy template \"{}\"."
+ ).format(anatomy_name)
+
+ all_anatomies = anatomy.format_all(anatomy_data)
+ result = None
+ for anatomies in all_anatomies.values():
+ for key, temp in anatomies.get("delivery", {}).items():
+ if key != anatomy_name:
+ continue
+
+ result = temp
+ break
+
+ # TODO log error! - missing keys in anatomy
+ if result:
+ missing_keys = [
+ key[1] for key in string.Formatter().parse(result)
+ if key[1] is not None
+ ]
+ else:
+ missing_keys = ["unknown"]
+
+ keys = ", ".join(missing_keys)
+ sub_msg = (
+ "Representation: {}
- Missing keys: \"{}\"
"
+ ).format(str(repre["_id"]), keys)
+ self.report_items[msg].append(sub_msg)
+ self.log.warning(
+ "{} Representation: \"{}\" Filled: <{}>".format(
+ msg, str(repre["_id"]), str(result)
+ )
+ )
+ continue
+
+ # Get source repre path
+ frame = repre['context'].get('frame')
+
+ if frame:
+ repre["context"]["frame"] = len(str(frame)) * "#"
+
+ repre_path = self.path_from_represenation(repre)
+ # TODO add backup solution where root of path from component
+ # is repalced with AVALON_PROJECTS root
+ if not frame:
+ self.process_single_file(
+ repre_path, anatomy, anatomy_name, anatomy_data
+ )
+
+ else:
+ self.process_sequence(
+ repre_path, anatomy, anatomy_name, anatomy_data
+ )
+
+ self.db_con.uninstall()
+
+ return self.report()
+
+ def process_single_file(
+ self, repre_path, anatomy, anatomy_name, anatomy_data
+ ):
+ anatomy_filled = anatomy.format(anatomy_data)
+ delivery_path = anatomy_filled["delivery"][anatomy_name]
+ delivery_folder = os.path.dirname(delivery_path)
+ if not os.path.exists(delivery_folder):
+ os.makedirs(delivery_folder)
+
+ self.copy_file(repre_path, delivery_path)
+
+ def process_sequence(
+ self, repre_path, anatomy, anatomy_name, anatomy_data
+ ):
+ dir_path, file_name = os.path.split(str(repre_path))
+
+ base_name, ext = os.path.splitext(file_name)
+ file_name_items = None
+ if "#" in base_name:
+ file_name_items = [part for part in base_name.split("#") if part]
+
+ elif "%" in base_name:
+ file_name_items = base_name.split("%")
+
+ if not file_name_items:
+ msg = "Source file was not found"
+ self.report_items[msg].append(repre_path)
+ self.log.warning("{} <{}>".format(msg, repre_path))
+ return
+
+ src_collections, remainder = clique.assemble(os.listdir(dir_path))
+ src_collection = None
+ for col in src_collections:
+ if col.tail != ext:
+ continue
+
+ # skip if collection don't have same basename
+ if not col.head.startswith(file_name_items[0]):
+ continue
+
+ src_collection = col
+ break
+
+ if src_collection is None:
+ # TODO log error!
+ msg = "Source collection of files was not found"
+ self.report_items[msg].append(repre_path)
+ self.log.warning("{} <{}>".format(msg, repre_path))
+ return
+
+ frame_indicator = "@####@"
+
+ anatomy_data["frame"] = frame_indicator
+ anatomy_filled = anatomy.format(anatomy_data)
+
+ delivery_path = anatomy_filled["delivery"][anatomy_name]
+ print(delivery_path)
+ delivery_folder = os.path.dirname(delivery_path)
+ dst_head, dst_tail = delivery_path.split(frame_indicator)
+ dst_padding = src_collection.padding
+ dst_collection = clique.Collection(
+ head=dst_head,
+ tail=dst_tail,
+ padding=dst_padding
+ )
+
+ if not os.path.exists(delivery_folder):
+ os.makedirs(delivery_folder)
+
+ src_head = src_collection.head
+ src_tail = src_collection.tail
+ for index in src_collection.indexes:
+ src_padding = src_collection.format("{padding}") % index
+ src_file_name = "{}{}{}".format(src_head, src_padding, src_tail)
+ src = os.path.normpath(
+ os.path.join(dir_path, src_file_name)
+ )
+
+ dst_padding = dst_collection.format("{padding}") % index
+ dst = "{}{}{}".format(dst_head, dst_padding, dst_tail)
+
+ self.copy_file(src, dst)
+
+ def path_from_represenation(self, representation):
+ try:
+ template = representation["data"]["template"]
+
+ except KeyError:
+ return None
+
+ try:
+ context = representation["context"]
+ context["root"] = os.environ.get("AVALON_PROJECTS") or ""
+ path = pipeline.format_template_with_optional_keys(
+ context, template
+ )
+
+ except KeyError:
+ # Template references unavailable data
+ return None
+
+ return os.path.normpath(path)
+
+ def copy_file(self, src_path, dst_path):
+ if os.path.exists(dst_path):
+ return
+ try:
+ filelink.create(
+ src_path,
+ dst_path,
+ filelink.HARDLINK
+ )
+ except OSError:
+ shutil.copyfile(src_path, dst_path)
+
+ def report(self):
+ items = []
+ title = "Delivery report"
+ for msg, _items in self.report_items.items():
+ if not _items:
+ continue
+
+ if items:
+ items.append({"type": "label", "value": "---"})
+
+ items.append({
+ "type": "label",
+ "value": "# {}".format(msg)
+ })
+ if not isinstance(_items, (list, tuple)):
+ _items = [_items]
+ __items = []
+ for item in _items:
+ __items.append(str(item))
+
+ items.append({
+ "type": "label",
+ "value": '{}
'.format("
".join(__items))
+ })
+
+ if not items:
+ return {
+ "success": True,
+ "message": "Delivery Finished"
+ }
+
+ return {
+ "items": items,
+ "title": title,
+ "success": False,
+ "message": "Delivery Finished"
+ }
+
+def register(session, plugins_presets={}):
+ '''Register plugin. Called when used as an plugin.'''
+
+ Delivery(session, plugins_presets).register()
diff --git a/pype/ftrack/actions/action_sync_to_avalon.py b/pype/ftrack/actions/action_sync_to_avalon.py
index 01d0b866bf..d2fcfb372f 100644
--- a/pype/ftrack/actions/action_sync_to_avalon.py
+++ b/pype/ftrack/actions/action_sync_to_avalon.py
@@ -70,7 +70,10 @@ class SyncToAvalonLocal(BaseAction):
ft_project_name = in_entities[0]["project"]["full_name"]
try:
- self.entities_factory.launch_setup(ft_project_name)
+ output = self.entities_factory.launch_setup(ft_project_name)
+ if output is not None:
+ return output
+
time_1 = time.time()
self.entities_factory.set_cutom_attributes()
diff --git a/pype/ftrack/events/action_sync_to_avalon.py b/pype/ftrack/events/action_sync_to_avalon.py
index 9f9deeab95..79ab1b5f7a 100644
--- a/pype/ftrack/events/action_sync_to_avalon.py
+++ b/pype/ftrack/events/action_sync_to_avalon.py
@@ -105,7 +105,10 @@ class SyncToAvalonServer(BaseAction):
ft_project_name = in_entities[0]["project"]["full_name"]
try:
- self.entities_factory.launch_setup(ft_project_name)
+ output = self.entities_factory.launch_setup(ft_project_name)
+ if output is not None:
+ return output
+
time_1 = time.time()
self.entities_factory.set_cutom_attributes()
diff --git a/pype/ftrack/events/event_sync_to_avalon.py b/pype/ftrack/events/event_sync_to_avalon.py
index 606866aba2..23284a2ae6 100644
--- a/pype/ftrack/events/event_sync_to_avalon.py
+++ b/pype/ftrack/events/event_sync_to_avalon.py
@@ -28,7 +28,7 @@ class SyncToAvalonEvent(BaseEvent):
ignore_entTypes = [
"socialfeed", "socialnotification", "note",
"assetversion", "job", "user", "reviewsessionobject", "timer",
- "timelog", "auth_userrole"
+ "timelog", "auth_userrole", "appointment"
]
ignore_ent_types = ["Milestone"]
ignore_keys = ["statusid"]
@@ -131,7 +131,9 @@ class SyncToAvalonEvent(BaseEvent):
ftrack_id = proj["data"]["ftrackId"]
self._avalon_ents_by_ftrack_id[ftrack_id] = proj
for ent in ents:
- ftrack_id = ent["data"]["ftrackId"]
+ ftrack_id = ent["data"].get("ftrackId")
+ if ftrack_id is None:
+ continue
self._avalon_ents_by_ftrack_id[ftrack_id] = ent
return self._avalon_ents_by_ftrack_id
@@ -1427,6 +1429,93 @@ class SyncToAvalonEvent(BaseEvent):
parent_id = ent_info["parentId"]
new_tasks_by_parent[parent_id].append(ent_info)
pop_out_ents.append(ftrack_id)
+ continue
+
+ name = (
+ ent_info
+ .get("changes", {})
+ .get("name", {})
+ .get("new")
+ )
+ avalon_ent_by_name = self.avalon_ents_by_name.get(name)
+ avalon_ent_by_name_ftrack_id = (
+ avalon_ent_by_name
+ .get("data", {})
+ .get("ftrackId")
+ )
+ if avalon_ent_by_name and avalon_ent_by_name_ftrack_id is None:
+ ftrack_ent = self.ftrack_ents_by_id.get(ftrack_id)
+ if not ftrack_ent:
+ ftrack_ent = self.process_session.query(
+ self.entities_query_by_id.format(
+ self.cur_project["id"], ftrack_id
+ )
+ ).one()
+ self.ftrack_ents_by_id[ftrack_id] = ftrack_ent
+
+ ent_path_items = [ent["name"] for ent in ftrack_ent["link"]]
+ parents = ent_path_items[1:len(ent_path_items)-1:]
+
+ avalon_ent_parents = (
+ avalon_ent_by_name.get("data", {}).get("parents")
+ )
+ if parents == avalon_ent_parents:
+ self.dbcon.update_one({
+ "_id": avalon_ent_by_name["_id"]
+ }, {
+ "$set": {
+ "data.ftrackId": ftrack_id,
+ "data.entityType": entity_type
+ }
+ })
+
+ avalon_ent_by_name["data"]["ftrackId"] = ftrack_id
+ avalon_ent_by_name["data"]["entityType"] = entity_type
+
+ self._avalon_ents_by_ftrack_id[ftrack_id] = (
+ avalon_ent_by_name
+ )
+ if self._avalon_ents_by_parent_id:
+ found = None
+ for _parent_id_, _entities_ in (
+ self._avalon_ents_by_parent_id.items()
+ ):
+ for _idx_, entity in enumerate(_entities_):
+ if entity["_id"] == avalon_ent_by_name["_id"]:
+ found = (_parent_id_, _idx_)
+ break
+
+ if found:
+ break
+
+ if found:
+ _parent_id_, _idx_ = found
+ self._avalon_ents_by_parent_id[_parent_id_][
+ _idx_] = avalon_ent_by_name
+
+ if self._avalon_ents_by_id:
+ self._avalon_ents_by_id[avalon_ent_by_name["_id"]] = (
+ avalon_ent_by_name
+ )
+
+ if self._avalon_ents_by_name:
+ self._avalon_ents_by_name[name] = avalon_ent_by_name
+
+ if self._avalon_ents:
+ found = None
+ project, entities = self._avalon_ents
+ for _idx_, _ent_ in enumerate(entities):
+ if _ent_["_id"] != avalon_ent_by_name["_id"]:
+ continue
+ found = _idx_
+ break
+
+ if found is not None:
+ entities[found] = avalon_ent_by_name
+ self._avalon_ents = project, entities
+
+ pop_out_ents.append(ftrack_id)
+ continue
configuration_id = entity_type_conf_ids.get(entity_type)
if not configuration_id:
@@ -1438,9 +1527,11 @@ class SyncToAvalonEvent(BaseEvent):
if attr["entity_type"] != ent_info["entityType"]:
continue
- if ent_info["entityType"] != "show":
- if attr["object_type_id"] != ent_info["objectTypeId"]:
- continue
+ if (
+ ent_info["entityType"] == "task" and
+ attr["object_type_id"] != ent_info["objectTypeId"]
+ ):
+ continue
configuration_id = attr["id"]
entity_type_conf_ids[entity_type] = configuration_id
@@ -1712,7 +1803,8 @@ class SyncToAvalonEvent(BaseEvent):
if ca_ent_type == "show":
cust_attrs_by_obj_id[ca_ent_type][key] = cust_attr
- else:
+
+ elif ca_ent_type == "task":
obj_id = cust_attr["object_type_id"]
cust_attrs_by_obj_id[obj_id][key] = cust_attr
diff --git a/pype/ftrack/ftrack_server/lib.py b/pype/ftrack/ftrack_server/lib.py
index edd3cee09b..fefba580e0 100644
--- a/pype/ftrack/ftrack_server/lib.py
+++ b/pype/ftrack/ftrack_server/lib.py
@@ -265,6 +265,37 @@ class ProcessEventHub(ftrack_api.event.hub.EventHub):
return self._send_packet(self._code_name_mapping["heartbeat"])
return super()._handle_packet(code, packet_identifier, path, data)
+
+
+class UserEventHub(ftrack_api.event.hub.EventHub):
+ def __init__(self, *args, **kwargs):
+ self.sock = kwargs.pop("sock")
+ super(UserEventHub, self).__init__(*args, **kwargs)
+
+ def _handle_packet(self, code, packet_identifier, path, data):
+ """Override `_handle_packet` which extend heartbeat"""
+ code_name = self._code_name_mapping[code]
+ if code_name == "heartbeat":
+ # Reply with heartbeat.
+ self.sock.sendall(b"hearbeat")
+ return self._send_packet(self._code_name_mapping['heartbeat'])
+
+ elif code_name == "connect":
+ event = ftrack_api.event.base.Event(
+ topic="pype.storer.started",
+ data={},
+ source={
+ "id": self.id,
+ "user": {"username": self._api_user}
+ }
+ )
+ self._event_queue.put(event)
+
+ return super(UserEventHub, self)._handle_packet(
+ code, packet_identifier, path, data
+ )
+
+
class SocketSession(ftrack_api.session.Session):
'''An isolated session for interaction with an ftrack server.'''
def __init__(
diff --git a/pype/ftrack/ftrack_server/socket_thread.py b/pype/ftrack/ftrack_server/socket_thread.py
index 3309f75cd7..c688693c77 100644
--- a/pype/ftrack/ftrack_server/socket_thread.py
+++ b/pype/ftrack/ftrack_server/socket_thread.py
@@ -26,6 +26,8 @@ class SocketThread(threading.Thread):
self.mongo_error = False
+ self._temp_data = {}
+
def stop(self):
self._is_running = False
@@ -81,8 +83,9 @@ class SocketThread(threading.Thread):
try:
if not self._is_running:
break
+ data = None
try:
- data = connection.recv(16)
+ data = self.get_data_from_con(connection)
time_con = time.time()
except socket.timeout:
@@ -99,10 +102,7 @@ class SocketThread(threading.Thread):
self._is_running = False
break
- if data:
- if data == b"MongoError":
- self.mongo_error = True
- connection.sendall(data)
+ self._handle_data(connection, data)
except Exception as exc:
self.log.error(
@@ -121,3 +121,14 @@ class SocketThread(threading.Thread):
for line in lines:
os.write(1, line)
self.finished = True
+
+ def get_data_from_con(self, connection):
+ return connection.recv(16)
+
+ def _handle_data(self, connection, data):
+ if not data:
+ return
+
+ if data == b"MongoError":
+ self.mongo_error = True
+ connection.sendall(data)
diff --git a/pype/ftrack/ftrack_server/sub_user_server.py b/pype/ftrack/ftrack_server/sub_user_server.py
new file mode 100644
index 0000000000..68066b33ce
--- /dev/null
+++ b/pype/ftrack/ftrack_server/sub_user_server.py
@@ -0,0 +1,51 @@
+import sys
+import signal
+import socket
+
+from ftrack_server import FtrackServer
+from pype.ftrack.ftrack_server.lib import SocketSession, UserEventHub
+
+from pypeapp import Logger
+
+log = Logger().get_logger(__name__)
+
+
+def main(args):
+ port = int(args[-1])
+
+ # Create a TCP/IP socket
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+
+ # Connect the socket to the port where the server is listening
+ server_address = ("localhost", port)
+ log.debug("Storer connected to {} port {}".format(*server_address))
+ sock.connect(server_address)
+ sock.sendall(b"CreatedUser")
+
+ try:
+ session = SocketSession(
+ auto_connect_event_hub=True, sock=sock, Eventhub=UserEventHub
+ )
+ server = FtrackServer("action")
+ log.debug("Launched Ftrack Event storer")
+ server.run_server(session=session)
+
+ finally:
+ log.debug("Closing socket")
+ sock.close()
+ return 1
+
+
+if __name__ == "__main__":
+ # Register interupt signal
+ def signal_handler(sig, frame):
+ log.info(
+ "Process was forced to stop. Process ended."
+ )
+ log.info("Process ended.")
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, signal_handler)
+ signal.signal(signal.SIGTERM, signal_handler)
+
+ sys.exit(main(sys.argv))
diff --git a/pype/ftrack/lib/avalon_sync.py b/pype/ftrack/lib/avalon_sync.py
index 064ea1adb8..8cebd12a59 100644
--- a/pype/ftrack/lib/avalon_sync.py
+++ b/pype/ftrack/lib/avalon_sync.py
@@ -314,6 +314,9 @@ class SyncEntitiesFactory:
self.log.warning(msg)
return {"success": False, "message": msg}
+ self.log.debug((
+ "*** Synchronization initialization started <{}>."
+ ).format(project_full_name))
# Check if `avalon_mongo_id` custom attribute exist or is accessible
if CustAttrIdKey not in ft_project["custom_attributes"]:
items = []
@@ -699,7 +702,7 @@ class SyncEntitiesFactory:
if ca_ent_type == "show":
avalon_attrs[ca_ent_type][key] = cust_attr["default"]
avalon_attrs_ca_id[ca_ent_type][key] = cust_attr["id"]
- else:
+ elif ca_ent_type == "task":
obj_id = cust_attr["object_type_id"]
avalon_attrs[obj_id][key] = cust_attr["default"]
avalon_attrs_ca_id[obj_id][key] = cust_attr["id"]
@@ -708,7 +711,7 @@ class SyncEntitiesFactory:
if ca_ent_type == "show":
attrs_per_entity_type[ca_ent_type][key] = cust_attr["default"]
attrs_per_entity_type_ca_id[ca_ent_type][key] = cust_attr["id"]
- else:
+ elif ca_ent_type == "task":
obj_id = cust_attr["object_type_id"]
attrs_per_entity_type[obj_id][key] = cust_attr["default"]
attrs_per_entity_type_ca_id[obj_id][key] = cust_attr["id"]
diff --git a/pype/ftrack/tray/ftrack_module.py b/pype/ftrack/tray/ftrack_module.py
index 8da97da56b..dab751c001 100644
--- a/pype/ftrack/tray/ftrack_module.py
+++ b/pype/ftrack/tray/ftrack_module.py
@@ -1,26 +1,27 @@
import os
-import json
-import threading
import time
-from Qt import QtCore, QtGui, QtWidgets
+import datetime
+import threading
+from Qt import QtCore, QtWidgets
import ftrack_api
-from pypeapp import style
-from pype.ftrack import FtrackServer, check_ftrack_url, credentials
+from ..ftrack_server.lib import check_ftrack_url
+from ..ftrack_server import socket_thread
+from ..lib import credentials
from . import login_dialog
-from pype import api as pype
+from pypeapp import Logger
-log = pype.Logger().get_logger("FtrackModule", "ftrack")
+log = Logger().get_logger("FtrackModule", "ftrack")
class FtrackModule:
def __init__(self, main_parent=None, parent=None):
self.parent = parent
self.widget_login = login_dialog.Login_Dialog_ui(self)
- self.action_server = FtrackServer('action')
self.thread_action_server = None
+ self.thread_socket_server = None
self.thread_timer = None
self.bool_logged = False
@@ -75,14 +76,6 @@ class FtrackModule:
# Actions part
def start_action_server(self):
- self.bool_action_thread_running = True
- self.set_menu_visibility()
- if (
- self.thread_action_server is not None and
- self.bool_action_thread_running is False
- ):
- self.stop_action_server()
-
if self.thread_action_server is None:
self.thread_action_server = threading.Thread(
target=self.set_action_server
@@ -90,35 +83,114 @@ class FtrackModule:
self.thread_action_server.start()
def set_action_server(self):
- first_check = True
- while self.bool_action_thread_running is True:
- if not check_ftrack_url(os.environ['FTRACK_SERVER']):
- if first_check:
- log.warning(
- "Could not connect to Ftrack server"
- )
- first_check = False
+ if self.bool_action_server_running:
+ return
+
+ self.bool_action_server_running = True
+ self.bool_action_thread_running = False
+
+ ftrack_url = os.environ['FTRACK_SERVER']
+
+ parent_file_path = os.path.dirname(
+ os.path.dirname(os.path.realpath(__file__))
+ )
+
+ min_fail_seconds = 5
+ max_fail_count = 3
+ wait_time_after_max_fail = 10
+
+ # Threads data
+ thread_name = "ActionServerThread"
+ thread_port = 10021
+ subprocess_path = (
+ "{}/ftrack_server/sub_user_server.py".format(parent_file_path)
+ )
+ if self.thread_socket_server is not None:
+ self.thread_socket_server.stop()
+ self.thread_socket_server.join()
+ self.thread_socket_server = None
+
+ last_failed = datetime.datetime.now()
+ failed_count = 0
+
+ ftrack_accessible = False
+ printed_ftrack_error = False
+
+ # Main loop
+ while True:
+ if not self.bool_action_server_running:
+ log.debug("Action server was pushed to stop.")
+ break
+
+ # Check if accessible Ftrack and Mongo url
+ if not ftrack_accessible:
+ ftrack_accessible = check_ftrack_url(ftrack_url)
+
+ # Run threads only if Ftrack is accessible
+ if not ftrack_accessible:
+ if not printed_ftrack_error:
+ log.warning("Can't access Ftrack {}".format(ftrack_url))
+
+ if self.thread_socket_server is not None:
+ self.thread_socket_server.stop()
+ self.thread_socket_server.join()
+ self.thread_socket_server = None
+ self.bool_action_thread_running = False
+ self.set_menu_visibility()
+
+ printed_ftrack_error = True
+
time.sleep(1)
continue
- log.info(
- "Connected to Ftrack server. Running actions session"
- )
- try:
- self.bool_action_server_running = True
+
+ printed_ftrack_error = False
+
+ # Run backup thread which does not requeire mongo to work
+ if self.thread_socket_server is None:
+ if failed_count < max_fail_count:
+ self.thread_socket_server = socket_thread.SocketThread(
+ thread_name, thread_port, subprocess_path
+ )
+ self.thread_socket_server.start()
+ self.bool_action_thread_running = True
+ self.set_menu_visibility()
+
+ elif failed_count == max_fail_count:
+ log.warning((
+ "Action server failed {} times."
+ " I'll try to run again {}s later"
+ ).format(
+ str(max_fail_count), str(wait_time_after_max_fail))
+ )
+ failed_count += 1
+
+ elif ((
+ datetime.datetime.now() - last_failed
+ ).seconds > wait_time_after_max_fail):
+ failed_count = 0
+
+ # If thread failed test Ftrack and Mongo connection
+ elif not self.thread_socket_server.isAlive():
+ self.thread_socket_server_thread.join()
+ self.thread_socket_server = None
+ ftrack_accessible = False
+
+ self.bool_action_thread_running = False
self.set_menu_visibility()
- self.action_server.run_server()
- if self.bool_action_thread_running:
- log.debug("Ftrack action server has stopped")
- except Exception:
- log.warning(
- "Ftrack Action server crashed. Trying to connect again",
- exc_info=True
- )
- self.bool_action_server_running = False
- self.set_menu_visibility()
- first_check = True
+
+ _last_failed = datetime.datetime.now()
+ delta_time = (_last_failed - last_failed).seconds
+ if delta_time < min_fail_seconds:
+ failed_count += 1
+ else:
+ failed_count = 0
+ last_failed = _last_failed
+
+ time.sleep(1)
self.bool_action_thread_running = False
+ self.bool_action_server_running = False
+ self.set_menu_visibility()
def reset_action_server(self):
self.stop_action_server()
@@ -126,16 +198,18 @@ class FtrackModule:
def stop_action_server(self):
try:
- self.bool_action_thread_running = False
- self.action_server.stop_session()
+ self.bool_action_server_running = False
+ if self.thread_socket_server is not None:
+ self.thread_socket_server.stop()
+ self.thread_socket_server.join()
+ self.thread_socket_server = None
+
if self.thread_action_server is not None:
self.thread_action_server.join()
self.thread_action_server = None
log.info("Ftrack action server was forced to stop")
- self.bool_action_server_running = False
- self.set_menu_visibility()
except Exception:
log.warning(
"Error has happened during Killing action server",
@@ -201,9 +275,9 @@ class FtrackModule:
self.stop_timer_thread()
return
- self.aRunActionS.setVisible(not self.bool_action_thread_running)
+ self.aRunActionS.setVisible(not self.bool_action_server_running)
self.aResetActionS.setVisible(self.bool_action_thread_running)
- self.aStopActionS.setVisible(self.bool_action_thread_running)
+ self.aStopActionS.setVisible(self.bool_action_server_running)
if self.bool_timer_event is False:
self.start_timer_thread()
diff --git a/pype/lib.py b/pype/lib.py
index 8772608b38..f26395d930 100644
--- a/pype/lib.py
+++ b/pype/lib.py
@@ -18,13 +18,16 @@ def _subprocess(*args, **kwargs):
"""Convenience method for getting output errors for subprocess."""
# make sure environment contains only strings
- filtered_env = {k: str(v) for k, v in os.environ.items()}
+ if not kwargs.get("env"):
+ filtered_env = {k: str(v) for k, v in os.environ.items()}
+ else:
+ filtered_env = {k: str(v) for k, v in kwargs.get("env").items()}
# set overrides
kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE)
kwargs['stderr'] = kwargs.get('stderr', subprocess.STDOUT)
kwargs['stdin'] = kwargs.get('stdin', subprocess.PIPE)
- kwargs['env'] = kwargs.get('env',filtered_env)
+ kwargs['env'] = filtered_env
proc = subprocess.Popen(*args, **kwargs)
@@ -193,9 +196,13 @@ def any_outdated():
if representation in checked:
continue
- representation_doc = io.find_one({"_id": io.ObjectId(representation),
- "type": "representation"},
- projection={"parent": True})
+ representation_doc = io.find_one(
+ {
+ "_id": io.ObjectId(representation),
+ "type": "representation"
+ },
+ projection={"parent": True}
+ )
if representation_doc and not is_latest(representation_doc):
return True
elif not representation_doc:
@@ -305,27 +312,38 @@ def switch_item(container,
representation_name = representation["name"]
# Find the new one
- asset = io.find_one({"name": asset_name, "type": "asset"})
+ asset = io.find_one({
+ "name": asset_name,
+ "type": "asset"
+ })
assert asset, ("Could not find asset in the database with the name "
"'%s'" % asset_name)
- subset = io.find_one({"name": subset_name,
- "type": "subset",
- "parent": asset["_id"]})
+ subset = io.find_one({
+ "name": subset_name,
+ "type": "subset",
+ "parent": asset["_id"]
+ })
assert subset, ("Could not find subset in the database with the name "
"'%s'" % subset_name)
- version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[('name', -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[('name', -1)]
+ )
assert version, "Could not find a version for {}.{}".format(
asset_name, subset_name
)
- representation = io.find_one({"name": representation_name,
- "type": "representation",
- "parent": version["_id"]})
+ representation = io.find_one({
+ "name": representation_name,
+ "type": "representation",
+ "parent": version["_id"]}
+ )
assert representation, ("Could not find representation in the database with"
" the name '%s'" % representation_name)
@@ -363,7 +381,10 @@ def get_asset(asset_name=None):
if not asset_name:
asset_name = avalon.api.Session["AVALON_ASSET"]
- asset_document = io.find_one({"name": asset_name, "type": "asset"})
+ asset_document = io.find_one({
+ "name": asset_name,
+ "type": "asset"
+ })
if not asset_document:
raise TypeError("Entity \"{}\" was not found in DB".format(asset_name))
@@ -535,8 +556,7 @@ def get_subsets(asset_name,
from avalon import io
# query asset from db
- asset_io = io.find_one({"type": "asset",
- "name": asset_name})
+ asset_io = io.find_one({"type": "asset", "name": asset_name})
# check if anything returned
assert asset_io, "Asset not existing. \
@@ -560,14 +580,20 @@ def get_subsets(asset_name,
# Process subsets
for subset in subsets:
if not version:
- version_sel = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[("name", -1)])
+ version_sel = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
+ )
else:
assert isinstance(version, int), "version needs to be `int` type"
- version_sel = io.find_one({"type": "version",
- "parent": subset["_id"],
- "name": int(version)})
+ version_sel = io.find_one({
+ "type": "version",
+ "parent": subset["_id"],
+ "name": int(version)
+ })
find_dict = {"type": "representation",
"parent": version_sel["_id"]}
diff --git a/pype/nuke/lib.py b/pype/nuke/lib.py
index f213b596ad..7aa0395da5 100644
--- a/pype/nuke/lib.py
+++ b/pype/nuke/lib.py
@@ -707,9 +707,11 @@ class WorkfileSettings(object):
frame_start = int(data["frameStart"]) - handle_start
frame_end = int(data["frameEnd"]) + handle_end
+ self._root_node["lock_range"].setValue(False)
self._root_node["fps"].setValue(fps)
self._root_node["first_frame"].setValue(frame_start)
self._root_node["last_frame"].setValue(frame_end)
+ self._root_node["lock_range"].setValue(True)
# setting active viewers
try:
@@ -1197,13 +1199,13 @@ class BuildWorkfile(WorkfileSettings):
self.ypos -= (self.ypos_size * multiply) + self.ypos_gap
-class Exporter_review_lut:
+class ExporterReview:
"""
- Generator object for review lut from Nuke
+ Base class object for generating review data from Nuke
Args:
klass (pyblish.plugin): pyblish plugin parent
-
+ instance (pyblish.instance): instance of pyblish context
"""
_temp_nodes = []
@@ -1213,94 +1215,15 @@ class Exporter_review_lut:
def __init__(self,
klass,
- instance,
- name=None,
- ext=None,
- cube_size=None,
- lut_size=None,
- lut_style=None):
+ instance
+ ):
self.log = klass.log
self.instance = instance
-
- self.name = name or "baked_lut"
- self.ext = ext or "cube"
- self.cube_size = cube_size or 32
- self.lut_size = lut_size or 1024
- self.lut_style = lut_style or "linear"
-
- self.stagingDir = self.instance.data["stagingDir"]
+ self.path_in = self.instance.data.get("path", None)
+ self.staging_dir = self.instance.data["stagingDir"]
self.collection = self.instance.data.get("collection", None)
- # set frame start / end and file name to self
- self.get_file_info()
-
- self.log.info("File info was set...")
-
- self.file = self.fhead + self.name + ".{}".format(self.ext)
- self.path = os.path.join(self.stagingDir, self.file).replace("\\", "/")
-
- def generate_lut(self):
- # ---------- start nodes creation
-
- # CMSTestPattern
- cms_node = nuke.createNode("CMSTestPattern")
- cms_node["cube_size"].setValue(self.cube_size)
- # connect
- self._temp_nodes.append(cms_node)
- self.previous_node = cms_node
- self.log.debug("CMSTestPattern... `{}`".format(self._temp_nodes))
-
- # Node View Process
- ipn = self.get_view_process_node()
- if ipn is not None:
- # connect
- ipn.setInput(0, self.previous_node)
- self._temp_nodes.append(ipn)
- self.previous_node = ipn
- self.log.debug("ViewProcess... `{}`".format(self._temp_nodes))
-
- # OCIODisplay
- dag_node = nuke.createNode("OCIODisplay")
- # connect
- dag_node.setInput(0, self.previous_node)
- self._temp_nodes.append(dag_node)
- self.previous_node = dag_node
- self.log.debug("OCIODisplay... `{}`".format(self._temp_nodes))
-
- # GenerateLUT
- gen_lut_node = nuke.createNode("GenerateLUT")
- gen_lut_node["file"].setValue(self.path)
- gen_lut_node["file_type"].setValue(".{}".format(self.ext))
- gen_lut_node["lut1d"].setValue(self.lut_size)
- gen_lut_node["style1d"].setValue(self.lut_style)
- # connect
- gen_lut_node.setInput(0, self.previous_node)
- self._temp_nodes.append(gen_lut_node)
- self.log.debug("GenerateLUT... `{}`".format(self._temp_nodes))
-
- # ---------- end nodes creation
-
- # Export lut file
- nuke.execute(
- gen_lut_node.name(),
- int(self.first_frame),
- int(self.first_frame))
-
- self.log.info("Exported...")
-
- # ---------- generate representation data
- self.get_representation_data()
-
- self.log.debug("Representation... `{}`".format(self.data))
-
- # ---------- Clean up
- for node in self._temp_nodes:
- nuke.delete(node)
- self.log.info("Deleted nodes...")
-
- return self.data
-
def get_file_info(self):
if self.collection:
self.log.debug("Collection: `{}`".format(self.collection))
@@ -1312,8 +1235,10 @@ class Exporter_review_lut:
# get first and last frame
self.first_frame = min(self.collection.indexes)
self.last_frame = max(self.collection.indexes)
+ if "slate" in self.instance.data["families"]:
+ self.first_frame += 1
else:
- self.fname = os.path.basename(self.instance.data.get("path", None))
+ self.fname = os.path.basename(self.path_in)
self.fhead = os.path.splitext(self.fname)[0] + "."
self.first_frame = self.instance.data.get("frameStart", None)
self.last_frame = self.instance.data.get("frameEnd", None)
@@ -1321,17 +1246,26 @@ class Exporter_review_lut:
if "#" in self.fhead:
self.fhead = self.fhead.replace("#", "")[:-1]
- def get_representation_data(self):
+ def get_representation_data(self, tags=None, range=False):
+ add_tags = []
+ if tags:
+ add_tags = tags
repre = {
'name': self.name,
'ext': self.ext,
'files': self.file,
- "stagingDir": self.stagingDir,
+ "stagingDir": self.staging_dir,
"anatomy_template": "publish",
- "tags": [self.name.replace("_", "-")]
+ "tags": [self.name.replace("_", "-")] + add_tags
}
+ if range:
+ repre.update({
+ "frameStart": self.first_frame,
+ "frameEnd": self.last_frame,
+ })
+
self.data["representations"].append(repre)
def get_view_process_node(self):
@@ -1366,6 +1300,252 @@ class Exporter_review_lut:
return ipn
+ def clean_nodes(self):
+ for node in self._temp_nodes:
+ nuke.delete(node)
+ self.log.info("Deleted nodes...")
+
+
+class ExporterReviewLut(ExporterReview):
+ """
+ Generator object for review lut from Nuke
+
+ Args:
+ klass (pyblish.plugin): pyblish plugin parent
+ instance (pyblish.instance): instance of pyblish context
+
+
+ """
+ def __init__(self,
+ klass,
+ instance,
+ name=None,
+ ext=None,
+ cube_size=None,
+ lut_size=None,
+ lut_style=None):
+ # initialize parent class
+ ExporterReview.__init__(self, klass, instance)
+
+ # deal with now lut defined in viewer lut
+ if hasattr(klass, "viewer_lut_raw"):
+ self.viewer_lut_raw = klass.viewer_lut_raw
+ else:
+ self.viewer_lut_raw = False
+
+ self.name = name or "baked_lut"
+ self.ext = ext or "cube"
+ self.cube_size = cube_size or 32
+ self.lut_size = lut_size or 1024
+ self.lut_style = lut_style or "linear"
+
+ # set frame start / end and file name to self
+ self.get_file_info()
+
+ self.log.info("File info was set...")
+
+ self.file = self.fhead + self.name + ".{}".format(self.ext)
+ self.path = os.path.join(
+ self.staging_dir, self.file).replace("\\", "/")
+
+ def generate_lut(self):
+ # ---------- start nodes creation
+
+ # CMSTestPattern
+ cms_node = nuke.createNode("CMSTestPattern")
+ cms_node["cube_size"].setValue(self.cube_size)
+ # connect
+ self._temp_nodes.append(cms_node)
+ self.previous_node = cms_node
+ self.log.debug("CMSTestPattern... `{}`".format(self._temp_nodes))
+
+ # Node View Process
+ ipn = self.get_view_process_node()
+ if ipn is not None:
+ # connect
+ ipn.setInput(0, self.previous_node)
+ self._temp_nodes.append(ipn)
+ self.previous_node = ipn
+ self.log.debug("ViewProcess... `{}`".format(self._temp_nodes))
+
+ if not self.viewer_lut_raw:
+ # OCIODisplay
+ dag_node = nuke.createNode("OCIODisplay")
+ # connect
+ dag_node.setInput(0, self.previous_node)
+ self._temp_nodes.append(dag_node)
+ self.previous_node = dag_node
+ self.log.debug("OCIODisplay... `{}`".format(self._temp_nodes))
+
+ # GenerateLUT
+ gen_lut_node = nuke.createNode("GenerateLUT")
+ gen_lut_node["file"].setValue(self.path)
+ gen_lut_node["file_type"].setValue(".{}".format(self.ext))
+ gen_lut_node["lut1d"].setValue(self.lut_size)
+ gen_lut_node["style1d"].setValue(self.lut_style)
+ # connect
+ gen_lut_node.setInput(0, self.previous_node)
+ self._temp_nodes.append(gen_lut_node)
+ self.log.debug("GenerateLUT... `{}`".format(self._temp_nodes))
+
+ # ---------- end nodes creation
+
+ # Export lut file
+ nuke.execute(
+ gen_lut_node.name(),
+ int(self.first_frame),
+ int(self.first_frame))
+
+ self.log.info("Exported...")
+
+ # ---------- generate representation data
+ self.get_representation_data()
+
+ self.log.debug("Representation... `{}`".format(self.data))
+
+ # ---------- Clean up
+ self.clean_nodes()
+
+ return self.data
+
+
+class ExporterReviewMov(ExporterReview):
+ """
+ Metaclass for generating review mov files
+
+ Args:
+ klass (pyblish.plugin): pyblish plugin parent
+ instance (pyblish.instance): instance of pyblish context
+
+ """
+ def __init__(self,
+ klass,
+ instance,
+ name=None,
+ ext=None,
+ ):
+ # initialize parent class
+ ExporterReview.__init__(self, klass, instance)
+
+ # passing presets for nodes to self
+ if hasattr(klass, "nodes"):
+ self.nodes = klass.nodes
+ else:
+ self.nodes = {}
+
+ # deal with now lut defined in viewer lut
+ if hasattr(klass, "viewer_lut_raw"):
+ self.viewer_lut_raw = klass.viewer_lut_raw
+ else:
+ self.viewer_lut_raw = False
+
+ self.name = name or "baked"
+ self.ext = ext or "mov"
+
+ # set frame start / end and file name to self
+ self.get_file_info()
+
+ self.log.info("File info was set...")
+
+ self.file = self.fhead + self.name + ".{}".format(self.ext)
+ self.path = os.path.join(
+ self.staging_dir, self.file).replace("\\", "/")
+
+ def render(self, render_node_name):
+ self.log.info("Rendering... ")
+ # Render Write node
+ nuke.execute(
+ render_node_name,
+ int(self.first_frame),
+ int(self.last_frame))
+
+ self.log.info("Rendered...")
+
+ def save_file(self):
+ import shutil
+ with anlib.maintained_selection():
+ self.log.info("Saving nodes as file... ")
+ # create nk path
+ path = os.path.splitext(self.path)[0] + ".nk"
+ # save file to the path
+ shutil.copyfile(self.instance.context.data["currentFile"], path)
+
+ self.log.info("Nodes exported...")
+ return path
+
+ def generate_mov(self, farm=False):
+ # ---------- start nodes creation
+
+ # Read node
+ r_node = nuke.createNode("Read")
+ r_node["file"].setValue(self.path_in)
+ r_node["first"].setValue(self.first_frame)
+ r_node["origfirst"].setValue(self.first_frame)
+ r_node["last"].setValue(self.last_frame)
+ r_node["origlast"].setValue(self.last_frame)
+ # connect
+ self._temp_nodes.append(r_node)
+ self.previous_node = r_node
+ self.log.debug("Read... `{}`".format(self._temp_nodes))
+
+ # View Process node
+ ipn = self.get_view_process_node()
+ if ipn is not None:
+ # connect
+ ipn.setInput(0, self.previous_node)
+ self._temp_nodes.append(ipn)
+ self.previous_node = ipn
+ self.log.debug("ViewProcess... `{}`".format(self._temp_nodes))
+
+ if not self.viewer_lut_raw:
+ # OCIODisplay node
+ dag_node = nuke.createNode("OCIODisplay")
+ # connect
+ dag_node.setInput(0, self.previous_node)
+ self._temp_nodes.append(dag_node)
+ self.previous_node = dag_node
+ self.log.debug("OCIODisplay... `{}`".format(self._temp_nodes))
+
+ # Write node
+ write_node = nuke.createNode("Write")
+ self.log.debug("Path: {}".format(self.path))
+ write_node["file"].setValue(self.path)
+ write_node["file_type"].setValue(self.ext)
+ write_node["meta_codec"].setValue("ap4h")
+ write_node["mov64_codec"].setValue("ap4h")
+ write_node["mov64_write_timecode"].setValue(1)
+ write_node["raw"].setValue(1)
+ # connect
+ write_node.setInput(0, self.previous_node)
+ self._temp_nodes.append(write_node)
+ self.log.debug("Write... `{}`".format(self._temp_nodes))
+ # ---------- end nodes creation
+
+ # ---------- render or save to nk
+ if farm:
+ nuke.scriptSave()
+ path_nk = self.save_file()
+ self.data.update({
+ "bakeScriptPath": path_nk,
+ "bakeWriteNodeName": write_node.name(),
+ "bakeRenderPath": self.path
+ })
+ else:
+ self.render(write_node.name())
+ # ---------- generate representation data
+ self.get_representation_data(
+ tags=["review", "delete"],
+ range=True
+ )
+
+ self.log.debug("Representation... `{}`".format(self.data))
+
+ # ---------- Clean up
+ self.clean_nodes()
+ nuke.scriptSave()
+ return self.data
+
+
def get_dependent_nodes(nodes):
"""Get all dependent nodes connected to the list of nodes.
@@ -1401,3 +1581,70 @@ def get_dependent_nodes(nodes):
})
return connections_in, connections_out
+
+
+def find_free_space_to_paste_nodes(
+ nodes,
+ group=nuke.root(),
+ direction="right",
+ offset=300):
+ """
+ For getting coordinates in DAG (node graph) for placing new nodes
+
+ Arguments:
+ nodes (list): list of nuke.Node objects
+ group (nuke.Node) [optional]: object in which context it is
+ direction (str) [optional]: where we want it to be placed
+ [left, right, top, bottom]
+ offset (int) [optional]: what offset it is from rest of nodes
+
+ Returns:
+ xpos (int): x coordinace in DAG
+ ypos (int): y coordinace in DAG
+ """
+ if len(nodes) == 0:
+ return 0, 0
+
+ group_xpos = list()
+ group_ypos = list()
+
+ # get local coordinates of all nodes
+ nodes_xpos = [n.xpos() for n in nodes] + \
+ [n.xpos() + n.screenWidth() for n in nodes]
+
+ nodes_ypos = [n.ypos() for n in nodes] + \
+ [n.ypos() + n.screenHeight() for n in nodes]
+
+ # get complete screen size of all nodes to be placed in
+ nodes_screen_width = max(nodes_xpos) - min(nodes_xpos)
+ nodes_screen_heigth = max(nodes_ypos) - min(nodes_ypos)
+
+ # get screen size (r,l,t,b) of all nodes in `group`
+ with group:
+ group_xpos = [n.xpos() for n in nuke.allNodes() if n not in nodes] + \
+ [n.xpos() + n.screenWidth() for n in nuke.allNodes()
+ if n not in nodes]
+ group_ypos = [n.ypos() for n in nuke.allNodes() if n not in nodes] + \
+ [n.ypos() + n.screenHeight() for n in nuke.allNodes()
+ if n not in nodes]
+
+ # calc output left
+ if direction in "left":
+ xpos = min(group_xpos) - abs(nodes_screen_width) - abs(offset)
+ ypos = min(group_ypos)
+ return xpos, ypos
+ # calc output right
+ if direction in "right":
+ xpos = max(group_xpos) + abs(offset)
+ ypos = min(group_ypos)
+ return xpos, ypos
+ # calc output top
+ if direction in "top":
+ xpos = min(group_xpos)
+ ypos = min(group_ypos) - abs(nodes_screen_heigth) - abs(offset)
+ return xpos, ypos
+ # calc output bottom
+ if direction in "bottom":
+ xpos = min(group_xpos)
+ ypos = max(group_ypos) + abs(offset)
+ return xpos, ypos
diff --git a/pype/nukestudio/workio.py b/pype/nukestudio/workio.py
index 1681d8a2ab..c7484b826b 100644
--- a/pype/nukestudio/workio.py
+++ b/pype/nukestudio/workio.py
@@ -22,19 +22,16 @@ def has_unsaved_changes():
def save_file(filepath):
+ file = os.path.basename(filepath)
project = hiero.core.projects()[-1]
- # close `Untitled` project
- if "Untitled" not in project.name():
- log.info("Saving project: `{}`".format(project.name()))
+ if project:
+ log.info("Saving project: `{}` as '{}'".format(project.name(), file))
project.saveAs(filepath)
- elif not project:
+ else:
log.info("Creating new project...")
project = hiero.core.newProject()
project.saveAs(filepath)
- else:
- log.info("Dropping `Untitled` project...")
- return
def open_file(filepath):
diff --git a/pype/plugins/blender/create/create_model.py b/pype/plugins/blender/create/create_model.py
new file mode 100644
index 0000000000..7301073f05
--- /dev/null
+++ b/pype/plugins/blender/create/create_model.py
@@ -0,0 +1,32 @@
+"""Create a model asset."""
+
+import bpy
+
+from avalon import api
+from avalon.blender import Creator, lib
+
+
+class CreateModel(Creator):
+ """Polygonal static geometry"""
+
+ name = "modelMain"
+ label = "Model"
+ family = "model"
+ icon = "cube"
+
+ def process(self):
+ import pype.blender
+
+ asset = self.data["asset"]
+ subset = self.data["subset"]
+ name = pype.blender.plugin.model_name(asset, subset)
+ collection = bpy.data.collections.new(name=name)
+ bpy.context.scene.collection.children.link(collection)
+ self.data['task'] = api.Session.get('AVALON_TASK')
+ lib.imprint(collection, self.data)
+
+ if (self.options or {}).get("useSelection"):
+ for obj in lib.get_selection():
+ collection.objects.link(obj)
+
+ return collection
diff --git a/pype/plugins/blender/load/load_model.py b/pype/plugins/blender/load/load_model.py
new file mode 100644
index 0000000000..bd6db17650
--- /dev/null
+++ b/pype/plugins/blender/load/load_model.py
@@ -0,0 +1,315 @@
+"""Load a model asset in Blender."""
+
+import logging
+from pathlib import Path
+from pprint import pformat
+from typing import Dict, List, Optional
+
+import avalon.blender.pipeline
+import bpy
+import pype.blender
+from avalon import api
+
+logger = logging.getLogger("pype").getChild("blender").getChild("load_model")
+
+
+class BlendModelLoader(pype.blender.AssetLoader):
+ """Load models from a .blend file.
+
+ Because they come from a .blend file we can simply link the collection that
+ contains the model. There is no further need to 'containerise' it.
+
+ Warning:
+ Loading the same asset more then once is not properly supported at the
+ moment.
+ """
+
+ families = ["model"]
+ representations = ["blend"]
+
+ label = "Link Model"
+ icon = "code-fork"
+ color = "orange"
+
+ @staticmethod
+ def _get_lib_collection(name: str, libpath: Path) -> Optional[bpy.types.Collection]:
+ """Find the collection(s) with name, loaded from libpath.
+
+ Note:
+ It is assumed that only 1 matching collection is found.
+ """
+ for collection in bpy.data.collections:
+ if collection.name != name:
+ continue
+ if collection.library is None:
+ continue
+ if not collection.library.filepath:
+ continue
+ collection_lib_path = str(Path(bpy.path.abspath(collection.library.filepath)).resolve())
+ normalized_libpath = str(Path(bpy.path.abspath(str(libpath))).resolve())
+ if collection_lib_path == normalized_libpath:
+ return collection
+ return None
+
+ @staticmethod
+ def _collection_contains_object(
+ collection: bpy.types.Collection, object: bpy.types.Object
+ ) -> bool:
+ """Check if the collection contains the object."""
+ for obj in collection.objects:
+ if obj == object:
+ return True
+ return False
+
+ def process_asset(
+ self, context: dict, name: str, namespace: Optional[str] = None,
+ options: Optional[Dict] = None
+ ) -> Optional[List]:
+ """
+ Arguments:
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ context: Full parenthood of representation to load
+ options: Additional settings dictionary
+ """
+
+ libpath = self.fname
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+ lib_container = pype.blender.plugin.model_name(asset, subset)
+ container_name = pype.blender.plugin.model_name(
+ asset, subset, namespace
+ )
+ relative = bpy.context.preferences.filepaths.use_relative_paths
+
+ with bpy.data.libraries.load(
+ libpath, link=True, relative=relative
+ ) as (_, data_to):
+ data_to.collections = [lib_container]
+
+ scene = bpy.context.scene
+ instance_empty = bpy.data.objects.new(
+ container_name, None
+ )
+ if not instance_empty.get("avalon"):
+ instance_empty["avalon"] = dict()
+ avalon_info = instance_empty["avalon"]
+ avalon_info.update({"container_name": container_name})
+ scene.collection.objects.link(instance_empty)
+ instance_empty.instance_type = 'COLLECTION'
+ container = bpy.data.collections[lib_container]
+ container.name = container_name
+ instance_empty.instance_collection = container
+ container.make_local()
+ avalon.blender.pipeline.containerise_existing(
+ container,
+ name,
+ namespace,
+ context,
+ self.__class__.__name__,
+ )
+
+ nodes = list(container.objects)
+ nodes.append(container)
+ nodes.append(instance_empty)
+ self[:] = nodes
+ return nodes
+
+ def update(self, container: Dict, representation: Dict):
+ """Update the loaded asset.
+
+ This will remove all objects of the current collection, load the new
+ ones and add them to the collection.
+ If the objects of the collection are used in another collection they
+ will not be removed, only unlinked. Normally this should not be the
+ case though.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ collection = bpy.data.collections.get(
+ container["objectName"]
+ )
+ libpath = Path(api.get_representation_path(representation))
+ extension = libpath.suffix.lower()
+
+ logger.debug(
+ "Container: %s\nRepresentation: %s",
+ pformat(container, indent=2),
+ pformat(representation, indent=2),
+ )
+
+ assert collection, (
+ f"The asset is not loaded: {container['objectName']}"
+ )
+ assert not (collection.children), (
+ "Nested collections are not supported."
+ )
+ assert libpath, (
+ "No existing library file found for {container['objectName']}"
+ )
+ assert libpath.is_file(), (
+ f"The file doesn't exist: {libpath}"
+ )
+ assert extension in pype.blender.plugin.VALID_EXTENSIONS, (
+ f"Unsupported file: {libpath}"
+ )
+ collection_libpath = (
+ self._get_library_from_container(collection).filepath
+ )
+ normalized_collection_libpath = (
+ str(Path(bpy.path.abspath(collection_libpath)).resolve())
+ )
+ normalized_libpath = (
+ str(Path(bpy.path.abspath(str(libpath))).resolve())
+ )
+ logger.debug(
+ "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
+ normalized_collection_libpath,
+ normalized_libpath,
+ )
+ if normalized_collection_libpath == normalized_libpath:
+ logger.info("Library already loaded, not updating...")
+ return
+ # Let Blender's garbage collection take care of removing the library
+ # itself after removing the objects.
+ objects_to_remove = set()
+ collection_objects = list()
+ collection_objects[:] = collection.objects
+ for obj in collection_objects:
+ # Unlink every object
+ collection.objects.unlink(obj)
+ remove_obj = True
+ for coll in [
+ coll for coll in bpy.data.collections
+ if coll != collection
+ ]:
+ if (
+ coll.objects and
+ self._collection_contains_object(coll, obj)
+ ):
+ remove_obj = False
+ if remove_obj:
+ objects_to_remove.add(obj)
+
+ for obj in objects_to_remove:
+ # Only delete objects that are not used elsewhere
+ bpy.data.objects.remove(obj)
+
+ instance_empties = [
+ obj for obj in collection.users_dupli_group
+ if obj.name in collection.name
+ ]
+ if instance_empties:
+ instance_empty = instance_empties[0]
+ container_name = instance_empty["avalon"]["container_name"]
+
+ relative = bpy.context.preferences.filepaths.use_relative_paths
+ with bpy.data.libraries.load(
+ str(libpath), link=True, relative=relative
+ ) as (_, data_to):
+ data_to.collections = [container_name]
+
+ new_collection = self._get_lib_collection(container_name, libpath)
+ if new_collection is None:
+ raise ValueError(
+ "A matching collection '{container_name}' "
+ "should have been found in: {libpath}"
+ )
+
+ for obj in new_collection.objects:
+ collection.objects.link(obj)
+ bpy.data.collections.remove(new_collection)
+ # Update the representation on the collection
+ avalon_prop = collection[avalon.blender.pipeline.AVALON_PROPERTY]
+ avalon_prop["representation"] = str(representation["_id"])
+
+ def remove(self, container: Dict) -> bool:
+ """Remove an existing container from a Blender scene.
+
+ Arguments:
+ container (avalon-core:container-1.0): Container to remove,
+ from `host.ls()`.
+
+ Returns:
+ bool: Whether the container was deleted.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ collection = bpy.data.collections.get(
+ container["objectName"]
+ )
+ if not collection:
+ return False
+ assert not (collection.children), (
+ "Nested collections are not supported."
+ )
+ instance_parents = list(collection.users_dupli_group)
+ instance_objects = list(collection.objects)
+ for obj in instance_objects + instance_parents:
+ bpy.data.objects.remove(obj)
+ bpy.data.collections.remove(collection)
+
+ return True
+
+
+class CacheModelLoader(pype.blender.AssetLoader):
+ """Load cache models.
+
+ Stores the imported asset in a collection named after the asset.
+
+ Note:
+ At least for now it only supports Alembic files.
+ """
+
+ families = ["model"]
+ representations = ["abc"]
+
+ label = "Link Model"
+ icon = "code-fork"
+ color = "orange"
+
+ def process_asset(
+ self, context: dict, name: str, namespace: Optional[str] = None,
+ options: Optional[Dict] = None
+ ) -> Optional[List]:
+ """
+ Arguments:
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ context: Full parenthood of representation to load
+ options: Additional settings dictionary
+ """
+ raise NotImplementedError("Loading of Alembic files is not yet implemented.")
+ # TODO (jasper): implement Alembic import.
+
+ libpath = self.fname
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+ # TODO (jasper): evaluate use of namespace which is 'alien' to Blender.
+ lib_container = container_name = (
+ pype.blender.plugin.model_name(asset, subset, namespace)
+ )
+ relative = bpy.context.preferences.filepaths.use_relative_paths
+
+ with bpy.data.libraries.load(
+ libpath, link=True, relative=relative
+ ) as (data_from, data_to):
+ data_to.collections = [lib_container]
+
+ scene = bpy.context.scene
+ instance_empty = bpy.data.objects.new(
+ container_name, None
+ )
+ scene.collection.objects.link(instance_empty)
+ instance_empty.instance_type = 'COLLECTION'
+ collection = bpy.data.collections[lib_container]
+ collection.name = container_name
+ instance_empty.instance_collection = collection
+
+ nodes = list(collection.objects)
+ nodes.append(collection)
+ nodes.append(instance_empty)
+ self[:] = nodes
+ return nodes
diff --git a/pype/plugins/blender/publish/collect_current_file.py b/pype/plugins/blender/publish/collect_current_file.py
new file mode 100644
index 0000000000..a097c72047
--- /dev/null
+++ b/pype/plugins/blender/publish/collect_current_file.py
@@ -0,0 +1,16 @@
+import bpy
+
+import pyblish.api
+
+
+class CollectBlenderCurrentFile(pyblish.api.ContextPlugin):
+ """Inject the current working file into context"""
+
+ order = pyblish.api.CollectorOrder - 0.5
+ label = "Blender Current File"
+ hosts = ['blender']
+
+ def process(self, context):
+ """Inject the current working file"""
+ current_file = bpy.data.filepath
+ context.data['currentFile'] = current_file
diff --git a/pype/plugins/blender/publish/collect_model.py b/pype/plugins/blender/publish/collect_model.py
new file mode 100644
index 0000000000..ee10eaf7f2
--- /dev/null
+++ b/pype/plugins/blender/publish/collect_model.py
@@ -0,0 +1,53 @@
+import typing
+from typing import Generator
+
+import bpy
+
+import avalon.api
+import pyblish.api
+from avalon.blender.pipeline import AVALON_PROPERTY
+
+
+class CollectModel(pyblish.api.ContextPlugin):
+ """Collect the data of a model."""
+
+ hosts = ["blender"]
+ label = "Collect Model"
+ order = pyblish.api.CollectorOrder
+
+ @staticmethod
+ def get_model_collections() -> Generator:
+ """Return all 'model' collections.
+
+ Check if the family is 'model' and if it doesn't have the
+ representation set. If the representation is set, it is a loaded model
+ and we don't want to publish it.
+ """
+ for collection in bpy.data.collections:
+ avalon_prop = collection.get(AVALON_PROPERTY) or dict()
+ if (avalon_prop.get('family') == 'model'
+ and not avalon_prop.get('representation')):
+ yield collection
+
+ def process(self, context):
+ """Collect the models from the current Blender scene."""
+ collections = self.get_model_collections()
+ for collection in collections:
+ avalon_prop = collection[AVALON_PROPERTY]
+ asset = avalon_prop['asset']
+ family = avalon_prop['family']
+ subset = avalon_prop['subset']
+ task = avalon_prop['task']
+ name = f"{asset}_{subset}"
+ instance = context.create_instance(
+ name=name,
+ family=family,
+ families=[family],
+ subset=subset,
+ asset=asset,
+ task=task,
+ )
+ members = list(collection.objects)
+ members.append(collection)
+ instance[:] = members
+ self.log.debug(instance.data)
diff --git a/pype/plugins/blender/publish/extract_model.py b/pype/plugins/blender/publish/extract_model.py
new file mode 100644
index 0000000000..501c4d9d5c
--- /dev/null
+++ b/pype/plugins/blender/publish/extract_model.py
@@ -0,0 +1,47 @@
+import os
+import avalon.blender.workio
+
+import pype.api
+
+
+class ExtractModel(pype.api.Extractor):
+ """Extract as model."""
+
+ label = "Model"
+ hosts = ["blender"]
+ families = ["model"]
+ optional = True
+
+ def process(self, instance):
+ # Define extract output file path
+
+ stagingdir = self.staging_dir(instance)
+ filename = f"{instance.name}.blend"
+ filepath = os.path.join(stagingdir, filename)
+
+ # Perform extraction
+ self.log.info("Performing extraction..")
+
+ # Just save the file to a temporary location. At least for now it's no
+ # problem to have (possibly) extra stuff in the file.
+ avalon.blender.workio.save_file(filepath, copy=True)
+ #
+ # # Store reference for integration
+ # if "files" not in instance.data:
+ # instance.data["files"] = list()
+ #
+ # # instance.data["files"].append(filename)
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'blend',
+ 'ext': 'blend',
+ 'files': filename,
+ "stagingDir": stagingdir,
+ }
+ instance.data["representations"].append(representation)
+
+
+ self.log.info("Extracted instance '%s' to: %s", instance.name, representation)
diff --git a/pype/plugins/blender/publish/validate_mesh_has_uv.py b/pype/plugins/blender/publish/validate_mesh_has_uv.py
new file mode 100644
index 0000000000..b71a40ad8f
--- /dev/null
+++ b/pype/plugins/blender/publish/validate_mesh_has_uv.py
@@ -0,0 +1,49 @@
+from typing import List
+
+import bpy
+
+import pyblish.api
+import pype.blender.action
+
+
+class ValidateMeshHasUvs(pyblish.api.InstancePlugin):
+ """Validate that the current mesh has UV's."""
+
+ order = pyblish.api.ValidatorOrder
+ hosts = ["blender"]
+ families = ["model"]
+ category = "geometry"
+ label = "Mesh Has UV's"
+ actions = [pype.blender.action.SelectInvalidAction]
+ optional = True
+
+ @staticmethod
+ def has_uvs(obj: bpy.types.Object) -> bool:
+ """Check if an object has uv's."""
+ if not obj.data.uv_layers:
+ return False
+ for uv_layer in obj.data.uv_layers:
+ for polygon in obj.data.polygons:
+ for loop_index in polygon.loop_indices:
+ if not uv_layer.data[loop_index].uv:
+ return False
+
+ return True
+
+ @classmethod
+ def get_invalid(cls, instance) -> List:
+ invalid = []
+ # TODO (jasper): only check objects in the collection that will be published?
+ for obj in [
+ obj for obj in bpy.data.objects if obj.type == 'MESH'
+ ]:
+ # Make sure we are in object mode.
+ bpy.ops.object.mode_set(mode='OBJECT')
+ if not cls.has_uvs(obj):
+ invalid.append(obj)
+ return invalid
+
+ def process(self, instance):
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise RuntimeError(f"Meshes found in instance without valid UV's: {invalid}")
diff --git a/pype/plugins/blender/publish/validate_mesh_no_negative_scale.py b/pype/plugins/blender/publish/validate_mesh_no_negative_scale.py
new file mode 100644
index 0000000000..7e3b38dd19
--- /dev/null
+++ b/pype/plugins/blender/publish/validate_mesh_no_negative_scale.py
@@ -0,0 +1,35 @@
+from typing import List
+
+import bpy
+
+import pyblish.api
+import pype.blender.action
+
+
+class ValidateMeshNoNegativeScale(pyblish.api.Validator):
+ """Ensure that meshes don't have a negative scale."""
+
+ order = pyblish.api.ValidatorOrder
+ hosts = ["blender"]
+ families = ["model"]
+ label = "Mesh No Negative Scale"
+ actions = [pype.blender.action.SelectInvalidAction]
+
+ @staticmethod
+ def get_invalid(instance) -> List:
+ invalid = []
+ # TODO (jasper): only check objects in the collection that will be published?
+ for obj in [
+ obj for obj in bpy.data.objects if obj.type == 'MESH'
+ ]:
+ if any(v < 0 for v in obj.scale):
+ invalid.append(obj)
+
+ return invalid
+
+ def process(self, instance):
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise RuntimeError(
+ f"Meshes found in instance with negative scale: {invalid}"
+ )
diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_api.py b/pype/plugins/ftrack/publish/integrate_ftrack_api.py
index 337562c1f5..c51685f84d 100644
--- a/pype/plugins/ftrack/publish/integrate_ftrack_api.py
+++ b/pype/plugins/ftrack/publish/integrate_ftrack_api.py
@@ -188,14 +188,18 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin):
# Adding Custom Attributes
for attr, val in assetversion_cust_attrs.items():
if attr in assetversion_entity["custom_attributes"]:
- assetversion_entity["custom_attributes"][attr] = val
- continue
+ try:
+ assetversion_entity["custom_attributes"][attr] = val
+ session.commit()
+ continue
+ except Exception:
+ session.rollback()
self.log.warning((
"Custom Attrubute \"{0}\""
- " is not available for AssetVersion."
- " Can't set it's value to: \"{1}\""
- ).format(attr, str(val)))
+ " is not available for AssetVersion <{1}>."
+ " Can't set it's value to: \"{2}\""
+ ).format(attr, assetversion_entity["id"], str(val)))
# Have to commit the version and asset, because location can't
# determine the final location without.
diff --git a/pype/plugins/global/publish/collect_filesequences.py b/pype/plugins/global/publish/collect_filesequences.py
index e7fe085027..5f6bc78664 100644
--- a/pype/plugins/global/publish/collect_filesequences.py
+++ b/pype/plugins/global/publish/collect_filesequences.py
@@ -12,7 +12,6 @@ import os
import re
import copy
import json
-from pprint import pformat
import pyblish.api
from avalon import api
@@ -91,13 +90,21 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
"""
- order = pyblish.api.CollectorOrder
+ order = pyblish.api.CollectorOrder - 0.0001
targets = ["filesequence"]
label = "RenderedFrames"
def process(self, context):
pixel_aspect = 1
+ resolution_width = 1920
+ resolution_height = 1080
lut_path = None
+ slate_frame = None
+ families_data = None
+ subset = None
+ version = None
+ frame_start = 0
+ frame_end = 0
if os.environ.get("PYPE_PUBLISH_PATHS"):
paths = os.environ["PYPE_PUBLISH_PATHS"].split(os.pathsep)
self.log.info("Collecting paths: {}".format(paths))
@@ -123,6 +130,10 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
cwd = os.path.dirname(path)
root_override = data.get("root")
+ frame_start = int(data.get("frameStart"))
+ frame_end = int(data.get("frameEnd"))
+ subset = data.get("subset")
+
if root_override:
if os.path.isabs(root_override):
root = root_override
@@ -148,7 +159,13 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
if instance:
instance_family = instance.get("family")
pixel_aspect = instance.get("pixelAspect", 1)
+ resolution_width = instance.get("resolutionWidth", 1920)
+ resolution_height = instance.get("resolutionHeight", 1080)
lut_path = instance.get("lutPath", None)
+ baked_mov_path = instance.get("bakeRenderPath")
+ families_data = instance.get("families")
+ slate_frame = instance.get("slateFrame")
+ version = instance.get("version")
else:
# Search in directory
@@ -156,35 +173,36 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
root = path
self.log.info("Collecting: {}".format(root))
+
regex = data.get("regex")
+ if baked_mov_path:
+ regex = "^{}.*$".format(subset)
+
if regex:
self.log.info("Using regex: {}".format(regex))
+ if "slate" in families_data:
+ frame_start -= 1
+
collections, remainder = collect(
root=root,
regex=regex,
exclude_regex=data.get("exclude_regex"),
- frame_start=data.get("frameStart"),
- frame_end=data.get("frameEnd"),
+ frame_start=frame_start,
+ frame_end=frame_end,
)
self.log.info("Found collections: {}".format(collections))
-
- """
- if data.get("subset"):
- # If subset is provided for this json then it must be a single
- # collection.
- if len(collections) > 1:
- self.log.error("Forced subset can only work with a single "
- "found sequence")
- raise RuntimeError("Invalid sequence")
- """
+ self.log.info("Found remainder: {}".format(remainder))
fps = data.get("fps", 25)
if data.get("user"):
context.data["user"] = data["user"]
+ if data.get("version"):
+ version = data.get("version")
+
# Get family from the data
families = data.get("families", ["render"])
if "render" not in families:
@@ -193,6 +211,8 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
families.append("ftrack")
if "write" in instance_family:
families.append("write")
+ if families_data and "slate" in families_data:
+ families.append("slate")
if data.get("attachTo"):
# we need to attach found collections to existing
@@ -213,11 +233,13 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
"asset": data.get(
"asset", api.Session["AVALON_ASSET"]),
"stagingDir": root,
- "frameStart": data.get("frameStart"),
- "frameEnd": data.get("frameEnd"),
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
"fps": fps,
"source": data.get("source", ""),
- "pixelAspect": pixel_aspect
+ "pixelAspect": pixel_aspect,
+ "resolutionWidth": resolution_width,
+ "resolutionHeight": resolution_height
})
if "representations" not in instance.data:
@@ -242,31 +264,47 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
instance.data["representations"].append(
representation)
- elif data.get("subset"):
+ elif subset:
# if we have subset - add all collections and known
# reminder as representations
+ # take out review family if mov path
+ # this will make imagesequence none review
+
+ if baked_mov_path:
+ self.log.info(
+ "Baked mov is available {}".format(
+ baked_mov_path))
+ families.append("review")
+
+ if session['AVALON_APP'] == "maya":
+ families.append("review")
+
self.log.info(
"Adding representations to subset {}".format(
- data.get("subset")))
+ subset))
- instance = context.create_instance(data.get("subset"))
+ instance = context.create_instance(subset)
data = copy.deepcopy(data)
instance.data.update(
{
- "name": data.get("subset"),
+ "name": subset,
"family": families[0],
"families": list(families),
- "subset": data.get("subset"),
+ "subset": subset,
"asset": data.get(
"asset", api.Session["AVALON_ASSET"]),
"stagingDir": root,
- "frameStart": data.get("frameStart"),
- "frameEnd": data.get("frameEnd"),
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
"fps": fps,
"source": data.get("source", ""),
"pixelAspect": pixel_aspect,
+ "resolutionWidth": resolution_width,
+ "resolutionHeight": resolution_height,
+ "slateFrame": slate_frame,
+ "version": version
}
)
@@ -278,31 +316,53 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
ext = collection.tail.lstrip(".")
+ if "slate" in instance.data["families"]:
+ frame_start += 1
+
representation = {
"name": ext,
"ext": "{}".format(ext),
"files": list(collection),
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
"stagingDir": root,
"anatomy_template": "render",
"fps": fps,
- "tags": ["review"],
+ "tags": ["review"] if not baked_mov_path else [],
}
instance.data["representations"].append(
representation)
+ # filter out only relevant mov in case baked available
+ self.log.debug("__ remainder {}".format(remainder))
+ if baked_mov_path:
+ remainder = [r for r in remainder
+ if r in baked_mov_path]
+ self.log.debug("__ remainder {}".format(remainder))
+
# process reminders
for rem in remainder:
# add only known types to representation
if rem.split(".")[-1] in ['mov', 'jpg', 'mp4']:
self.log.info(" . {}".format(rem))
+
+ if "slate" in instance.data["families"]:
+ frame_start += 1
+
+ tags = ["review"]
+
+ if baked_mov_path:
+ tags.append("delete")
+
representation = {
"name": rem.split(".")[-1],
"ext": "{}".format(rem.split(".")[-1]),
"files": rem,
"stagingDir": root,
+ "frameStart": frame_start,
"anatomy_template": "render",
"fps": fps,
- "tags": ["review"],
+ "tags": tags
}
instance.data["representations"].append(
representation)
@@ -344,6 +404,9 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
"fps": fps,
"source": data.get("source", ""),
"pixelAspect": pixel_aspect,
+ "resolutionWidth": resolution_width,
+ "resolutionHeight": resolution_height,
+ "version": version
}
)
if lut_path:
@@ -365,3 +428,18 @@ class CollectRenderedFrames(pyblish.api.ContextPlugin):
"tags": ["review"],
}
instance.data["representations"].append(representation)
+
+ # temporary ... allow only beauty on ftrack
+ if session['AVALON_APP'] == "maya":
+ AOV_filter = ['beauty']
+ for aov in AOV_filter:
+ if aov not in instance.data['subset']:
+ instance.data['families'].remove('review')
+ instance.data['families'].remove('ftrack')
+ representation["tags"].remove('review')
+
+ self.log.debug(
+ "__ representations {}".format(
+ instance.data["representations"]))
+ self.log.debug(
+ "__ instance.data {}".format(instance.data))
diff --git a/pype/plugins/global/publish/collect_templates.py b/pype/plugins/global/publish/collect_templates.py
index 48623eec22..383944e293 100644
--- a/pype/plugins/global/publish/collect_templates.py
+++ b/pype/plugins/global/publish/collect_templates.py
@@ -31,32 +31,44 @@ class CollectTemplates(pyblish.api.InstancePlugin):
asset_name = instance.data["asset"]
project_name = api.Session["AVALON_PROJECT"]
- project = io.find_one({"type": "project",
- "name": project_name},
- projection={"config": True, "data": True})
+ project = io.find_one(
+ {
+ "type": "project",
+ "name": project_name
+ },
+ projection={"config": True, "data": True}
+ )
template = project["config"]["template"]["publish"]
anatomy = instance.context.data['anatomy']
- asset = io.find_one({"type": "asset",
- "name": asset_name,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
assert asset, ("No asset found by the name '{}' "
"in project '{}'".format(asset_name, project_name))
silo = asset.get('silo')
- subset = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset["_id"]})
+ subset = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
# assume there is no version yet, we start at `1`
version = None
version_number = 1
if subset is not None:
- version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[("name", -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
+ )
# if there is a subset there ought to be version
if version is not None:
@@ -76,7 +88,18 @@ class CollectTemplates(pyblish.api.InstancePlugin):
"subset": subset_name,
"version": version_number,
"hierarchy": hierarchy.replace("\\", "/"),
- "representation": "TEMP"}
+ "representation": "TEMP")}
+
+ resolution_width = instance.data.get("resolutionWidth")
+ resolution_height = instance.data.get("resolutionHeight")
+ fps = instance.data.get("fps")
+
+ if resolution_width:
+ template_data["resolution_width"] = resolution_width
+ if resolution_width:
+ template_data["resolution_height"] = resolution_height
+ if resolution_width:
+ template_data["fps"] = fps
instance.data["template"] = template
instance.data["assumedTemplateData"] = template_data
diff --git a/pype/plugins/global/publish/extract_burnin.py b/pype/plugins/global/publish/extract_burnin.py
index 06a62dd98b..8f5a4aa000 100644
--- a/pype/plugins/global/publish/extract_burnin.py
+++ b/pype/plugins/global/publish/extract_burnin.py
@@ -4,6 +4,7 @@ import copy
import pype.api
import pyblish
+from pypeapp import config
class ExtractBurnin(pype.api.Extractor):
@@ -25,11 +26,8 @@ class ExtractBurnin(pype.api.Extractor):
if "representations" not in instance.data:
raise RuntimeError("Burnin needs already created mov to work on.")
- # TODO: expand burnin data list to include all usefull keys
- version = ''
- if instance.context.data.get('version'):
- version = "v" + str(instance.context.data['version'])
-
+ version = instance.context.data.get(
+ 'version', instance.data.get('version'))
frame_start = int(instance.data.get("frameStart") or 0)
frame_end = int(instance.data.get("frameEnd") or 1)
duration = frame_end - frame_start + 1
@@ -41,10 +39,30 @@ class ExtractBurnin(pype.api.Extractor):
"frame_start": frame_start,
"frame_end": frame_end,
"duration": duration,
- "version": version,
- "comment": instance.context.data.get("comment"),
- "intent": instance.context.data.get("intent")
+ "version": int(version),
+ "comment": instance.context.data.get("comment", ""),
+ "intent": instance.context.data.get("intent", "")
}
+
+ # Add datetime data to preparation data
+ prep_data.update(config.get_datetime_data())
+
+ slate_frame_start = frame_start
+ slate_frame_end = frame_end
+ slate_duration = duration
+
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ slate_frame_start = frame_start - 1
+ slate_frame_end = frame_end
+ slate_duration = slate_frame_end - slate_frame_start + 1
+
+ prep_data.update({
+ "slate_frame_start": slate_frame_start,
+ "slate_frame_end": slate_frame_end,
+ "slate_duration": slate_duration
+ })
+
# Update data with template data
template_data = instance.data.get("assumedTemplateData") or {}
prep_data.update(template_data)
@@ -63,7 +81,8 @@ class ExtractBurnin(pype.api.Extractor):
filename = "{0}".format(repre["files"])
name = "_burnin"
- movieFileBurnin = filename.replace(".mov", "") + name + ".mov"
+ ext = os.path.splitext(filename)[1]
+ movieFileBurnin = filename.replace(ext, "") + name + ext
full_movie_path = os.path.join(
os.path.normpath(stagingdir), repre["files"]
diff --git a/pype/plugins/global/publish/extract_jpeg.py b/pype/plugins/global/publish/extract_jpeg.py
index 8a1a0b5e68..00e8a6fedf 100644
--- a/pype/plugins/global/publish/extract_jpeg.py
+++ b/pype/plugins/global/publish/extract_jpeg.py
@@ -20,6 +20,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
hosts = ["shell"]
order = pyblish.api.ExtractorOrder
families = ["imagesequence", "render", "write", "source"]
+ enabled = False
def process(self, instance):
start = instance.data.get("frameStart")
@@ -28,51 +29,74 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin):
collected_frames = os.listdir(stagingdir)
collections, remainder = clique.assemble(collected_frames)
- input_file = (
- collections[0].format('{head}{padding}{tail}') % start
- )
- full_input_path = os.path.join(stagingdir, input_file)
- self.log.info("input {}".format(full_input_path))
+ self.log.info("subset {}".format(instance.data['subset']))
+ if 'crypto' in instance.data['subset']:
+ return
- filename = collections[0].format('{head}')
- if not filename.endswith('.'):
- filename += "."
- jpegFile = filename + "jpg"
- full_output_path = os.path.join(stagingdir, jpegFile)
+ # get representation and loop them
+ representations = instance.data["representations"]
- self.log.info("output {}".format(full_output_path))
+ # filter out mov and img sequences
+ representations_new = representations[:]
- config_data = instance.context.data['output_repre_config']
+ for repre in representations:
+ self.log.debug(repre)
+ if 'review' not in repre['tags']:
+ return
- proj_name = os.environ.get('AVALON_PROJECT', '__default__')
- profile = config_data.get(proj_name, config_data['__default__'])
+ input_file = repre['files'][0]
- jpeg_items = []
- jpeg_items.append(
- os.path.join(os.environ.get("FFMPEG_PATH"), "ffmpeg"))
- # override file if already exists
- jpeg_items.append("-y")
- # use same input args like with mov
- jpeg_items.extend(profile.get('input', []))
- # input file
- jpeg_items.append("-i {}".format(full_input_path))
- # output file
- jpeg_items.append(full_output_path)
+ # input_file = (
+ # collections[0].format('{head}{padding}{tail}') % start
+ # )
+ full_input_path = os.path.join(stagingdir, input_file)
+ self.log.info("input {}".format(full_input_path))
- subprocess_jpeg = " ".join(jpeg_items)
+ filename = os.path.splitext(input_file)[0]
+ if not filename.endswith('.'):
+ filename += "."
+ jpegFile = filename + "jpg"
+ full_output_path = os.path.join(stagingdir, jpegFile)
- # run subprocess
- self.log.debug("{}".format(subprocess_jpeg))
- pype.api.subprocess(subprocess_jpeg)
+ self.log.info("output {}".format(full_output_path))
- if "representations" not in instance.data:
- instance.data["representations"] = []
+ config_data = instance.context.data['output_repre_config']
- representation = {
- 'name': 'jpg',
- 'ext': 'jpg',
- 'files': jpegFile,
- "stagingDir": stagingdir,
- "thumbnail": True
- }
- instance.data["representations"].append(representation)
+ proj_name = os.environ.get('AVALON_PROJECT', '__default__')
+ profile = config_data.get(proj_name, config_data['__default__'])
+
+ jpeg_items = []
+ jpeg_items.append(
+ os.path.join(os.environ.get("FFMPEG_PATH"), "ffmpeg"))
+ # override file if already exists
+ jpeg_items.append("-y")
+ # use same input args like with mov
+ jpeg_items.extend(profile.get('input', []))
+ # input file
+ jpeg_items.append("-i {}".format(full_input_path))
+ # output file
+ jpeg_items.append(full_output_path)
+
+ subprocess_jpeg = " ".join(jpeg_items)
+
+ # run subprocess
+ self.log.debug("{}".format(subprocess_jpeg))
+ pype.api.subprocess(subprocess_jpeg)
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ representation = {
+ 'name': 'jpg',
+ 'ext': 'jpg',
+ 'files': jpegFile,
+ "stagingDir": stagingdir,
+ "thumbnail": True,
+ "tags": ['thumbnail']
+ }
+
+ # adding representation
+ self.log.debug("Adding: {}".format(representation))
+ representations_new.append(representation)
+
+ instance.data["representations"] = representations_new
diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py
index f621df0c66..a11f681e61 100644
--- a/pype/plugins/global/publish/extract_review.py
+++ b/pype/plugins/global/publish/extract_review.py
@@ -1,5 +1,4 @@
import os
-import math
import pyblish.api
import clique
import pype.api
@@ -25,19 +24,21 @@ class ExtractReview(pyblish.api.InstancePlugin):
ext_filter = []
def process(self, instance):
+ to_width = 1920
+ to_height = 1080
output_profiles = self.outputs or {}
inst_data = instance.data
fps = inst_data.get("fps")
start_frame = inst_data.get("frameStart")
- resolution_height = instance.data.get("resolutionHeight", 1080)
- resolution_width = instance.data.get("resolutionWidth", 1920)
- pixel_aspect = instance.data.get("pixelAspect", 1)
- self.log.debug("Families In: `{}`".format(instance.data["families"]))
+ resolution_width = inst_data.get("resolutionWidth", to_width)
+ resolution_height = inst_data.get("resolutionHeight", to_height)
+ pixel_aspect = inst_data.get("pixelAspect", 1)
+ self.log.debug("Families In: `{}`".format(inst_data["families"]))
# get representation and loop them
- representations = instance.data["representations"]
+ representations = inst_data["representations"]
# filter out mov and img sequences
representations_new = representations[:]
@@ -45,6 +46,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
if repre['ext'] in self.ext_filter:
tags = repre.get("tags", [])
+ if "thumbnail" in tags:
+ continue
+
self.log.info("Try repre: {}".format(repre))
if "review" in tags:
@@ -56,10 +60,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not ext:
ext = "mov"
self.log.warning(
- "`ext` attribute not in output profile. Setting to default ext: `mov`")
+ str("`ext` attribute not in output "
+ "profile. Setting to default ext: `mov`"))
- self.log.debug("instance.families: {}".format(instance.data['families']))
- self.log.debug("profile.families: {}".format(profile['families']))
+ self.log.debug(
+ "instance.families: {}".format(
+ instance.data['families']))
+ self.log.debug(
+ "profile.families: {}".format(profile['families']))
if any(item in instance.data['families'] for item in profile['families']):
if isinstance(repre["files"], list):
@@ -114,8 +122,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
# necessary input data
# adds start arg only if image sequence
if isinstance(repre["files"], list):
- input_args.append("-start_number {0} -framerate {1}".format(
- start_frame, fps))
+ input_args.append(
+ "-start_number {0} -framerate {1}".format(
+ start_frame, fps))
input_args.append("-i {}".format(full_input_path))
@@ -155,13 +164,43 @@ class ExtractReview(pyblish.api.InstancePlugin):
# preset's output data
output_args.extend(profile.get('output', []))
+ # defining image ratios
+ resolution_ratio = float(resolution_width / (
+ resolution_height * pixel_aspect))
+ delivery_ratio = float(to_width) / float(to_height)
+ self.log.debug(resolution_ratio)
+ self.log.debug(delivery_ratio)
+
+ # get scale factor
+ scale_factor = to_height / (
+ resolution_height * pixel_aspect)
+ self.log.debug(scale_factor)
+
# letter_box
lb = profile.get('letter_box', 0)
- if lb is not 0:
+ if lb != 0:
+ ffmpet_width = to_width
+ ffmpet_height = to_height
if "reformat" not in p_tags:
lb /= pixel_aspect
- output_args.append(
- "-filter:v scale=1920x1080:flags=lanczos,setsar=1,drawbox=0:0:iw:round((ih-(iw*(1/{0})))/2):t=fill:c=black,drawbox=0:ih-round((ih-(iw*(1/{0})))/2):iw:round((ih-(iw*(1/{0})))/2):t=fill:c=black".format(lb))
+ if resolution_ratio != delivery_ratio:
+ ffmpet_width = resolution_width
+ ffmpet_height = int(
+ resolution_height * pixel_aspect)
+ else:
+ if resolution_ratio != delivery_ratio:
+ lb /= scale_factor
+ else:
+ lb /= pixel_aspect
+
+ output_args.append(str(
+ "-filter:v scale={0}x{1}:flags=lanczos,"
+ "setsar=1,drawbox=0:0:iw:"
+ "round((ih-(iw*(1/{2})))/2):t=fill:"
+ "c=black,drawbox=0:ih-round((ih-(iw*("
+ "1/{2})))/2):iw:round((ih-(iw*(1/{2})))"
+ "/2):t=fill:c=black").format(
+ ffmpet_width, ffmpet_height, lb))
# In case audio is longer than video.
output_args.append("-shortest")
@@ -169,35 +208,56 @@ class ExtractReview(pyblish.api.InstancePlugin):
# output filename
output_args.append(full_output_path)
- self.log.debug("__ pixel_aspect: `{}`".format(pixel_aspect))
- self.log.debug("__ resolution_width: `{}`".format(resolution_width))
- self.log.debug("__ resolution_height: `{}`".format(resolution_height))
+ self.log.debug(
+ "__ pixel_aspect: `{}`".format(pixel_aspect))
+ self.log.debug(
+ "__ resolution_width: `{}`".format(
+ resolution_width))
+ self.log.debug(
+ "__ resolution_height: `{}`".format(
+ resolution_height))
+
# scaling none square pixels and 1920 width
if "reformat" in p_tags:
- width_scale = 1920
- width_half_pad = 0
- res_w = int(float(resolution_width) * pixel_aspect)
- height_half_pad = int((
- (res_w - 1920) / (
- res_w * .01) * (
- 1080 * .01)) / 2
- )
- height_scale = 1080 - (height_half_pad * 2)
- if height_scale > 1080:
+ if resolution_ratio < delivery_ratio:
+ self.log.debug("lower then delivery")
+ width_scale = int(to_width * scale_factor)
+ width_half_pad = int((
+ to_width - width_scale)/2)
+ height_scale = to_height
height_half_pad = 0
- height_scale = 1080
- width_half_pad = (1920 - (float(resolution_width) * (1080 / float(resolution_height))) ) / 2
- width_scale = int(1920 - (width_half_pad * 2))
+ else:
+ self.log.debug("heigher then delivery")
+ width_scale = to_width
+ width_half_pad = 0
+ scale_factor = float(to_width) / float(
+ resolution_width)
+ self.log.debug(scale_factor)
+ height_scale = int(
+ resolution_height * scale_factor)
+ height_half_pad = int(
+ (to_height - height_scale)/2)
- self.log.debug("__ width_scale: `{}`".format(width_scale))
- self.log.debug("__ width_half_pad: `{}`".format(width_half_pad))
- self.log.debug("__ height_scale: `{}`".format(height_scale))
- self.log.debug("__ height_half_pad: `{}`".format(height_half_pad))
+ self.log.debug(
+ "__ width_scale: `{}`".format(width_scale))
+ self.log.debug(
+ "__ width_half_pad: `{}`".format(
+ width_half_pad))
+ self.log.debug(
+ "__ height_scale: `{}`".format(
+ height_scale))
+ self.log.debug(
+ "__ height_half_pad: `{}`".format(
+ height_half_pad))
-
- scaling_arg = "scale={0}x{1}:flags=lanczos,pad=1920:1080:{2}:{3}:black,setsar=1".format(
- width_scale, height_scale, width_half_pad, height_half_pad
- )
+ scaling_arg = str(
+ "scale={0}x{1}:flags=lanczos,"
+ "pad={2}:{3}:{4}:{5}:black,setsar=1"
+ ).format(width_scale, height_scale,
+ to_width, to_height,
+ width_half_pad,
+ height_half_pad
+ )
vf_back = self.add_video_filter_args(
output_args, scaling_arg)
@@ -225,7 +285,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
# add it to output_args
output_args.insert(0, vf_back)
self.log.info("Added Lut to ffmpeg command")
- self.log.debug("_ output_args: `{}`".format(output_args))
+ self.log.debug(
+ "_ output_args: `{}`".format(output_args))
mov_args = [
os.path.join(
@@ -249,7 +310,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
'files': repr_file,
"tags": new_tags,
"outputName": name,
- "codec": codec_args
+ "codec": codec_args,
+ "_profile": profile,
+ "resolutionHeight": resolution_height,
+ "resolutionWidth": resolution_width,
})
if repre_new.get('preview'):
repre_new.pop("preview")
diff --git a/pype/plugins/global/publish/extract_review_slate.py b/pype/plugins/global/publish/extract_review_slate.py
new file mode 100644
index 0000000000..9a720b77a9
--- /dev/null
+++ b/pype/plugins/global/publish/extract_review_slate.py
@@ -0,0 +1,243 @@
+import os
+import pype.api
+import pyblish
+
+
+class ExtractReviewSlate(pype.api.Extractor):
+ """
+ Will add slate frame at the start of the video files
+ """
+
+ label = "Review with Slate frame"
+ order = pyblish.api.ExtractorOrder + 0.031
+ families = ["slate"]
+ hosts = ["nuke", "maya", "shell"]
+ optional = True
+
+ def process(self, instance):
+ inst_data = instance.data
+ if "representations" not in inst_data:
+ raise RuntimeError("Burnin needs already created mov to work on.")
+
+ suffix = "_slate"
+ slate_path = inst_data.get("slateFrame")
+ ffmpeg_path = os.path.join(os.environ.get("FFMPEG_PATH", ""), "ffmpeg")
+
+ to_width = 1920
+ to_height = 1080
+ resolution_width = inst_data.get("resolutionWidth", to_width)
+ resolution_height = inst_data.get("resolutionHeight", to_height)
+ pixel_aspect = inst_data.get("pixelAspect", 1)
+ fps = inst_data.get("fps")
+
+ # defining image ratios
+ resolution_ratio = float(resolution_width / (
+ resolution_height * pixel_aspect))
+ delivery_ratio = float(to_width) / float(to_height)
+ self.log.debug(resolution_ratio)
+ self.log.debug(delivery_ratio)
+
+ # get scale factor
+ scale_factor = to_height / (
+ resolution_height * pixel_aspect)
+ self.log.debug(scale_factor)
+
+ for i, repre in enumerate(inst_data["representations"]):
+ _remove_at_end = []
+ self.log.debug("__ i: `{}`, repre: `{}`".format(i, repre))
+
+ p_tags = repre.get("tags", [])
+
+ if "slate-frame" not in p_tags:
+ continue
+
+ stagingdir = repre["stagingDir"]
+ input_file = "{0}".format(repre["files"])
+
+ ext = os.path.splitext(input_file)[1]
+ output_file = input_file.replace(ext, "") + suffix + ext
+
+ input_path = os.path.join(
+ os.path.normpath(stagingdir), repre["files"])
+ self.log.debug("__ input_path: {}".format(input_path))
+ _remove_at_end.append(input_path)
+
+ output_path = os.path.join(
+ os.path.normpath(stagingdir), output_file)
+ self.log.debug("__ output_path: {}".format(output_path))
+
+ input_args = []
+ output_args = []
+ # overrides output file
+ input_args.append("-y")
+ # preset's input data
+ input_args.extend(repre["_profile"].get('input', []))
+ input_args.append("-loop 1 -i {}".format(slate_path))
+ input_args.extend([
+ "-r {}".format(fps),
+ "-t 0.04"]
+ )
+
+ # output args
+ codec_args = repre["_profile"].get('codec', [])
+ output_args.extend(codec_args)
+ # preset's output data
+ output_args.extend(repre["_profile"].get('output', []))
+
+ # make sure colors are correct
+ output_args.extend([
+ "-vf scale=out_color_matrix=bt709",
+ "-color_primaries bt709",
+ "-color_trc bt709",
+ "-colorspace bt709"
+ ])
+
+ # scaling none square pixels and 1920 width
+ if "reformat" in p_tags:
+ if resolution_ratio < delivery_ratio:
+ self.log.debug("lower then delivery")
+ width_scale = int(to_width * scale_factor)
+ width_half_pad = int((
+ to_width - width_scale)/2)
+ height_scale = to_height
+ height_half_pad = 0
+ else:
+ self.log.debug("heigher then delivery")
+ width_scale = to_width
+ width_half_pad = 0
+ scale_factor = float(to_width) / float(resolution_width)
+ self.log.debug(scale_factor)
+ height_scale = int(
+ resolution_height * scale_factor)
+ height_half_pad = int(
+ (to_height - height_scale)/2)
+
+ self.log.debug(
+ "__ width_scale: `{}`".format(width_scale))
+ self.log.debug(
+ "__ width_half_pad: `{}`".format(width_half_pad))
+ self.log.debug(
+ "__ height_scale: `{}`".format(height_scale))
+ self.log.debug(
+ "__ height_half_pad: `{}`".format(height_half_pad))
+
+ scaling_arg = "scale={0}x{1}:flags=lanczos,pad={2}:{3}:{4}:{5}:black,setsar=1".format(
+ width_scale, height_scale, to_width, to_height, width_half_pad, height_half_pad
+ )
+
+ vf_back = self.add_video_filter_args(
+ output_args, scaling_arg)
+ # add it to output_args
+ output_args.insert(0, vf_back)
+
+ slate_v_path = slate_path.replace(".png", ext)
+ output_args.append(slate_v_path)
+ _remove_at_end.append(slate_v_path)
+
+ slate_args = [
+ ffmpeg_path,
+ " ".join(input_args),
+ " ".join(output_args)
+ ]
+ slate_subprcs_cmd = " ".join(slate_args)
+
+ # run slate generation subprocess
+ self.log.debug("Slate Executing: {}".format(slate_subprcs_cmd))
+ slate_output = pype.api.subprocess(slate_subprcs_cmd)
+ self.log.debug("Slate Output: {}".format(slate_output))
+
+ # create ffmpeg concat text file path
+ conc_text_file = input_file.replace(ext, "") + "_concat" + ".txt"
+ conc_text_path = os.path.join(
+ os.path.normpath(stagingdir), conc_text_file)
+ _remove_at_end.append(conc_text_path)
+ self.log.debug("__ conc_text_path: {}".format(conc_text_path))
+
+ new_line = "\n"
+ with open(conc_text_path, "w") as conc_text_f:
+ conc_text_f.writelines([
+ "file {}".format(
+ slate_v_path.replace("\\", "/")),
+ new_line,
+ "file {}".format(input_path.replace("\\", "/"))
+ ])
+
+ # concat slate and videos together
+ conc_input_args = ["-y", "-f concat", "-safe 0"]
+ conc_input_args.append("-i {}".format(conc_text_path))
+
+ conc_output_args = ["-c copy"]
+ conc_output_args.append(output_path)
+
+ concat_args = [
+ ffmpeg_path,
+ " ".join(conc_input_args),
+ " ".join(conc_output_args)
+ ]
+ concat_subprcs_cmd = " ".join(concat_args)
+
+ # ffmpeg concat subprocess
+ self.log.debug("Executing concat: {}".format(concat_subprcs_cmd))
+ concat_output = pype.api.subprocess(concat_subprcs_cmd)
+ self.log.debug("Output concat: {}".format(concat_output))
+
+ self.log.debug("__ repre[tags]: {}".format(repre["tags"]))
+ repre_update = {
+ "files": output_file,
+ "name": repre["name"],
+ "tags": [x for x in repre["tags"] if x != "delete"]
+ }
+ inst_data["representations"][i].update(repre_update)
+ self.log.debug(
+ "_ representation {}: `{}`".format(
+ i, inst_data["representations"][i]))
+
+ # removing temp files
+ for f in _remove_at_end:
+ os.remove(f)
+ self.log.debug("Removed: `{}`".format(f))
+
+ # Remove any representations tagged for deletion.
+ for repre in inst_data.get("representations", []):
+ if "delete" in repre.get("tags", []):
+ self.log.debug("Removing representation: {}".format(repre))
+ inst_data["representations"].remove(repre)
+
+ self.log.debug(inst_data["representations"])
+
+ def add_video_filter_args(self, args, inserting_arg):
+ """
+ Fixing video filter argumets to be one long string
+
+ Args:
+ args (list): list of string arguments
+ inserting_arg (str): string argument we want to add
+ (without flag `-vf`)
+
+ Returns:
+ str: long joined argument to be added back to list of arguments
+
+ """
+ # find all video format settings
+ vf_settings = [p for p in args
+ for v in ["-filter:v", "-vf"]
+ if v in p]
+ self.log.debug("_ vf_settings: `{}`".format(vf_settings))
+
+ # remove them from output args list
+ for p in vf_settings:
+ self.log.debug("_ remove p: `{}`".format(p))
+ args.remove(p)
+ self.log.debug("_ args: `{}`".format(args))
+
+ # strip them from all flags
+ vf_fixed = [p.replace("-vf ", "").replace("-filter:v ", "")
+ for p in vf_settings]
+
+ self.log.debug("_ vf_fixed: `{}`".format(vf_fixed))
+ vf_fixed.insert(0, inserting_arg)
+ self.log.debug("_ vf_fixed: `{}`".format(vf_fixed))
+ # create new video filter setting
+ vf_back = "-vf " + ",".join(vf_fixed)
+
+ return vf_back
diff --git a/pype/plugins/global/publish/integrate.py b/pype/plugins/global/publish/integrate.py
index 59e05ee2aa..e24bad362d 100644
--- a/pype/plugins/global/publish/integrate.py
+++ b/pype/plugins/global/publish/integrate.py
@@ -84,9 +84,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
project = io.find_one({"type": "project"})
- asset = io.find_one({"type": "asset",
- "name": ASSET,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": ASSET,
+ "parent": project["_id"]
+ })
assert all([project, asset]), ("Could not find current project or "
"asset '%s'" % ASSET)
@@ -94,10 +96,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
subset = self.get_subset(asset, instance)
# get next version
- latest_version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- {"name": True},
- sort=[("name", -1)])
+ latest_version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ {"name": True},
+ sort=[("name", -1)]
+ )
next_version = 1
if latest_version is not None:
@@ -318,9 +324,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
def get_subset(self, asset, instance):
- subset = io.find_one({"type": "subset",
- "parent": asset["_id"],
- "name": instance.data["subset"]})
+ subset = io.find_one({
+ "type": "subset",
+ "parent": asset["_id"],
+ "name": instance.data["subset"]
+ })
if subset is None:
subset_name = instance.data["subset"]
diff --git a/pype/plugins/global/publish/integrate_assumed_destination.py b/pype/plugins/global/publish/integrate_assumed_destination.py
index a26529fc2c..d090e2711a 100644
--- a/pype/plugins/global/publish/integrate_assumed_destination.py
+++ b/pype/plugins/global/publish/integrate_assumed_destination.py
@@ -82,31 +82,40 @@ class IntegrateAssumedDestination(pyblish.api.InstancePlugin):
project_name = api.Session["AVALON_PROJECT"]
a_template = anatomy.templates
- project = io.find_one({"type": "project",
- "name": project_name},
- projection={"config": True, "data": True})
+ project = io.find_one(
+ {"type": "project", "name": project_name},
+ projection={"config": True, "data": True}
+ )
template = a_template['publish']['path']
# anatomy = instance.context.data['anatomy']
- asset = io.find_one({"type": "asset",
- "name": asset_name,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
assert asset, ("No asset found by the name '{}' "
"in project '{}'".format(asset_name, project_name))
- subset = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset["_id"]})
+ subset = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
# assume there is no version yet, we start at `1`
version = None
version_number = 1
if subset is not None:
- version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[("name", -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
+ )
# if there is a subset there ought to be version
if version is not None:
diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py
index 360b97e4be..87e1e50fc4 100644
--- a/pype/plugins/global/publish/integrate_new.py
+++ b/pype/plugins/global/publish/integrate_new.py
@@ -7,6 +7,7 @@ import errno
import pyblish.api
from avalon import api, io
from avalon.vendor import filelink
+
# this is needed until speedcopy for linux is fixed
if sys.platform == "win32":
from speedcopy import copyfile
@@ -154,9 +155,11 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
io.install()
project = io.find_one({"type": "project"})
- asset = io.find_one({"type": "asset",
- "name": ASSET,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": ASSET,
+ "parent": project["_id"]
+ })
assert all([project, asset]), ("Could not find current project or "
"asset '%s'" % ASSET)
@@ -164,10 +167,14 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
subset = self.get_subset(asset, instance)
# get next version
- latest_version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- {"name": True},
- sort=[("name", -1)])
+ latest_version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ {"name": True},
+ sort=[("name", -1)]
+ )
next_version = 1
if latest_version is not None:
@@ -176,16 +183,6 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
if instance.data.get('version'):
next_version = int(instance.data.get('version'))
- # self.log.info("Verifying version from assumed destination")
-
- # assumed_data = instance.data["assumedTemplateData"]
- # assumed_version = assumed_data["version"]
- # if assumed_version != next_version:
- # raise AttributeError("Assumed version 'v{0:03d}' does not match"
- # "next version in database "
- # "('v{1:03d}')".format(assumed_version,
- # next_version))
-
self.log.debug("Next version: v{0:03d}".format(next_version))
version_data = self.create_version_data(context, instance)
@@ -271,6 +268,17 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"version": int(version["name"]),
"hierarchy": hierarchy}
+ resolution_width = repre.get("resolutionWidth")
+ resolution_height = repre.get("resolutionHeight")
+ fps = instance.data.get("fps")
+
+ if resolution_width:
+ template_data["resolution_width"] = resolution_width
+ if resolution_width:
+ template_data["resolution_height"] = resolution_height
+ if resolution_width:
+ template_data["fps"] = fps
+
files = repre['files']
if repre.get('stagingDir'):
stagingdir = repre['stagingDir']
@@ -324,6 +332,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
repre.get("frameEnd")))
index_frame_start = int(repre.get("frameStart"))
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ index_frame_start -= 1
+
dst_padding_exp = src_padding_exp
dst_start_frame = None
for i in src_collection.indexes:
@@ -358,7 +370,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
dst_head,
dst_start_frame,
dst_tail).replace("..", ".")
- repre['published_path'] = dst
+ repre['published_path'] = self.unc_convert(dst)
else:
# Single file
@@ -387,7 +399,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
instance.data["transfers"].append([src, dst])
- repre['published_path'] = dst
+ repre['published_path'] = self.unc_convert(dst)
self.log.debug("__ dst: {}".format(dst))
representation = {
@@ -415,6 +427,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
}
}
+ if repre.get("outputName"):
+ representation["context"]["output"] = repre['outputName']
+
if sequence_repre and repre.get("frameStart"):
representation['context']['frame'] = src_padding_exp % int(repre.get("frameStart"))
@@ -461,6 +476,23 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
self.log.debug("Hardlinking file .. {} -> {}".format(src, dest))
self.hardlink_file(src, dest)
+ def unc_convert(self, path):
+ self.log.debug("> __ path: `{}`".format(path))
+ drive, _path = os.path.splitdrive(path)
+ self.log.debug("> __ drive, _path: `{}`, `{}`".format(drive, _path))
+
+ if not os.path.exists(drive + "/"):
+ self.log.info("Converting to unc from environments ..")
+
+ path_replace = os.getenv("PYPE_STUDIO_PROJECTS_PATH")
+ path_mount = os.getenv("PYPE_STUDIO_PROJECTS_MOUNT")
+
+ if "/" in path_mount:
+ path = path.replace(path_mount[0:-1], path_replace)
+ else:
+ path = path.replace(path_mount, path_replace)
+ return path
+
def copy_file(self, src, dst):
""" Copy given source to destination
@@ -470,8 +502,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
Returns:
None
"""
- src = os.path.normpath(src)
- dst = os.path.normpath(dst)
+ src = self.unc_convert(src)
+ dst = self.unc_convert(dst)
self.log.debug("Copying file .. {} -> {}".format(src, dst))
dirname = os.path.dirname(dst)
@@ -492,6 +524,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
def hardlink_file(self, src, dst):
dirname = os.path.dirname(dst)
+
+ src = self.unc_convert(src)
+ dst = self.unc_convert(dst)
+
try:
os.makedirs(dirname)
except OSError as e:
@@ -504,9 +540,11 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
filelink.create(src, dst, filelink.HARDLINK)
def get_subset(self, asset, instance):
- subset = io.find_one({"type": "subset",
- "parent": asset["_id"],
- "name": instance.data["subset"]})
+ subset = io.find_one({
+ "type": "subset",
+ "parent": asset["_id"],
+ "name": instance.data["subset"]
+ })
if subset is None:
subset_name = instance.data["subset"]
@@ -597,7 +635,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"source": source,
"comment": context.data.get("comment"),
"machine": context.data.get("machine"),
- "fps": context.data.get("fps")}
+ "fps": context.data.get(
+ "fps", instance.data.get("fps"))}
# Include optional data if present in
optionals = [
diff --git a/pype/plugins/global/publish/integrate_rendered_frames.py b/pype/plugins/global/publish/integrate_rendered_frames.py
index 086b03802e..5819051146 100644
--- a/pype/plugins/global/publish/integrate_rendered_frames.py
+++ b/pype/plugins/global/publish/integrate_rendered_frames.py
@@ -88,9 +88,11 @@ class IntegrateFrames(pyblish.api.InstancePlugin):
project = io.find_one({"type": "project"})
- asset = io.find_one({"type": "asset",
- "name": ASSET,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": ASSET,
+ "parent": project["_id"]
+ })
assert all([project, asset]), ("Could not find current project or "
"asset '%s'" % ASSET)
@@ -98,10 +100,14 @@ class IntegrateFrames(pyblish.api.InstancePlugin):
subset = self.get_subset(asset, instance)
# get next version
- latest_version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- {"name": True},
- sort=[("name", -1)])
+ latest_version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ {"name": True},
+ sort=[("name", -1)]
+ )
next_version = 1
if latest_version is not None:
@@ -251,9 +257,6 @@ class IntegrateFrames(pyblish.api.InstancePlugin):
self.log.debug("path_to_save: {}".format(path_to_save))
-
-
-
representation = {
"schema": "pype:representation-2.0",
"type": "representation",
@@ -332,9 +335,11 @@ class IntegrateFrames(pyblish.api.InstancePlugin):
def get_subset(self, asset, instance):
- subset = io.find_one({"type": "subset",
- "parent": asset["_id"],
- "name": instance.data["subset"]})
+ subset = io.find_one({
+ "type": "subset",
+ "parent": asset["_id"],
+ "name": instance.data["subset"]
+ })
if subset is None:
subset_name = instance.data["subset"]
diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py
index 3495dc6cd5..d5a6b9a62c 100644
--- a/pype/plugins/global/publish/submit_publish_job.py
+++ b/pype/plugins/global/publish/submit_publish_job.py
@@ -21,20 +21,34 @@ def _get_script():
if module_path.endswith(".pyc"):
module_path = module_path[:-len(".pyc")] + ".py"
+ module_path = os.path.normpath(module_path)
+ mount_root = os.path.normpath(os.environ['PYPE_STUDIO_CORE_MOUNT'])
+ network_root = os.path.normpath(os.environ['PYPE_STUDIO_CORE_PATH'])
+
+ module_path = module_path.replace(mount_root, network_root)
+
return module_path
# Logic to retrieve latest files concerning extendFrames
def get_latest_version(asset_name, subset_name, family):
# Get asset
- asset_name = io.find_one({"type": "asset",
- "name": asset_name},
- projection={"name": True})
+ asset_name = io.find_one(
+ {
+ "type": "asset",
+ "name": asset_name
+ },
+ projection={"name": True}
+ )
- subset = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset_name["_id"]},
- projection={"_id": True, "name": True})
+ subset = io.find_one(
+ {
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset_name["_id"]
+ },
+ projection={"_id": True, "name": True}
+ )
# Check if subsets actually exists (pre-run check)
assert subset, "No subsets found, please publish with `extendFrames` off"
@@ -45,11 +59,15 @@ def get_latest_version(asset_name, subset_name, family):
"data.endFrame": True,
"parent": True}
- version = io.find_one({"type": "version",
- "parent": subset["_id"],
- "data.families": family},
- projection=version_projection,
- sort=[("name", -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"],
+ "data.families": family
+ },
+ projection=version_projection,
+ sort=[("name", -1)]
+ )
assert version, "No version found, this is a bug"
@@ -143,7 +161,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"FTRACK_API_USER",
"FTRACK_API_KEY",
"FTRACK_SERVER",
- "PYPE_ROOT"
+ "PYPE_ROOT",
+ "PYPE_STUDIO_PROJECTS_PATH",
+ "PYPE_STUDIO_PROJECTS_MOUNT"
]
def _submit_deadline_post_job(self, instance, job):
@@ -154,7 +174,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"""
data = instance.data.copy()
subset = data["subset"]
- state = data.get("publishJobState", "Suspended")
job_name = "{batch} - {subset} [publish image sequence]".format(
batch=job["Props"]["Name"],
subset=subset
@@ -164,6 +183,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
output_dir = instance.data["outputDir"]
metadata_path = os.path.join(output_dir, metadata_filename)
+ metadata_path = os.path.normpath(metadata_path)
+ mount_root = os.path.normpath(os.environ['PYPE_STUDIO_PROJECTS_MOUNT'])
+ network_root = os.path.normpath(
+ os.environ['PYPE_STUDIO_PROJECTS_PATH'])
+
+ metadata_path = metadata_path.replace(mount_root, network_root)
+
# Generate the payload for Deadline submission
payload = {
"JobInfo": {
@@ -174,7 +200,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"JobDependency0": job["_id"],
"UserName": job["Props"]["User"],
"Comment": instance.context.data.get("comment", ""),
- "InitialStatus": state,
"Priority": job["Props"]["Pri"]
},
"PluginInfo": {
@@ -192,6 +217,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
# job so they use the same environment
environment = job["Props"].get("Env", {})
+
i = 0
for index, key in enumerate(environment):
self.log.info("KEY: {}".format(key))
@@ -307,6 +333,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
"user": context.data["user"],
"version": context.data["version"],
"attachTo": attach_subset_versions,
+ "intent": context.data.get("intent"),
+ "comment": context.data.get("comment"),
# Optional metadata (for debugging)
"metadata": {
"instance": data,
@@ -315,6 +343,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin):
}
}
+ if api.Session["AVALON_APP"] == "nuke":
+ metadata['subset'] = subset
+
if submission_type == "muster":
ftrack = {
"FTRACK_API_USER": os.environ.get("FTRACK_API_USER"),
diff --git a/pype/plugins/maya/load/load_look.py b/pype/plugins/maya/load/load_look.py
index b1c88bcd18..04ac9b23e4 100644
--- a/pype/plugins/maya/load/load_look.py
+++ b/pype/plugins/maya/load/load_look.py
@@ -116,9 +116,11 @@ class LookLoader(pype.maya.plugin.ReferenceLoader):
shapes=True))
nodes = set(nodes_list)
- json_representation = io.find_one({"type": "representation",
- "parent": representation['parent'],
- "name": "json"})
+ json_representation = io.find_one({
+ "type": "representation",
+ "parent": representation['parent'],
+ "name": "json"
+ })
# Load relationships
shader_relation = api.get_representation_path(json_representation)
diff --git a/pype/plugins/maya/publish/collect_ass.py b/pype/plugins/maya/publish/collect_ass.py
index c0174e7026..8e6691120a 100644
--- a/pype/plugins/maya/publish/collect_ass.py
+++ b/pype/plugins/maya/publish/collect_ass.py
@@ -21,15 +21,17 @@ class CollectAssData(pyblish.api.InstancePlugin):
objsets = instance.data['setMembers']
for objset in objsets:
+ objset = str(objset)
members = cmds.sets(objset, query=True)
if members is None:
self.log.warning("Skipped empty instance: \"%s\" " % objset)
continue
- if objset == "content_SET":
+ if "content_SET" in objset:
instance.data['setMembers'] = members
- elif objset == "proxy_SET":
+ self.log.debug('content members: {}'.format(members))
+ elif objset.startswith("proxy_SET"):
assert len(members) == 1, "You have multiple proxy meshes, please only use one"
instance.data['proxy'] = members
-
+ self.log.debug('proxy members: {}'.format(members))
self.log.debug("data: {}".format(instance.data))
diff --git a/pype/plugins/maya/publish/collect_yeti_rig.py b/pype/plugins/maya/publish/collect_yeti_rig.py
index 7ab5649c0b..c743b2c00b 100644
--- a/pype/plugins/maya/publish/collect_yeti_rig.py
+++ b/pype/plugins/maya/publish/collect_yeti_rig.py
@@ -119,11 +119,15 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
texture_filenames = []
if image_search_paths:
+
# TODO: Somehow this uses OS environment path separator, `:` vs `;`
# Later on check whether this is pipeline OS cross-compatible.
image_search_paths = [p for p in
image_search_paths.split(os.path.pathsep) if p]
+ # find all ${TOKEN} tokens and replace them with $TOKEN env. variable
+ image_search_paths = self._replace_tokens(image_search_paths)
+
# List all related textures
texture_filenames = cmds.pgYetiCommand(node, listTextures=True)
self.log.info("Found %i texture(s)" % len(texture_filenames))
@@ -140,6 +144,8 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
"atttribute'" % node)
# Collect all texture files
+ # find all ${TOKEN} tokens and replace them with $TOKEN env. variable
+ texture_filenames = self._replace_tokens(texture_filenames)
for texture in texture_filenames:
files = []
@@ -283,3 +289,20 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
collection, remainder = clique.assemble(files, patterns=pattern)
return collection
+
+ def _replace_tokens(self, strings):
+ env_re = re.compile(r"\$\{(\w+)\}")
+
+ replaced = []
+ for s in strings:
+ matches = re.finditer(env_re, s)
+ for m in matches:
+ try:
+ s = s.replace(m.group(), os.environ[m.group(1)])
+ except KeyError:
+ msg = "Cannot find requested {} in environment".format(
+ m.group(1))
+ self.log.error(msg)
+ raise RuntimeError(msg)
+ replaced.append(s)
+ return replaced
diff --git a/pype/plugins/maya/publish/extract_ass.py b/pype/plugins/maya/publish/extract_ass.py
index 71f3e0d84c..4cf394aefe 100644
--- a/pype/plugins/maya/publish/extract_ass.py
+++ b/pype/plugins/maya/publish/extract_ass.py
@@ -17,6 +17,7 @@ class ExtractAssStandin(pype.api.Extractor):
label = "Ass Standin (.ass)"
hosts = ["maya"]
families = ["ass"]
+ asciiAss = False
def process(self, instance):
@@ -47,7 +48,7 @@ class ExtractAssStandin(pype.api.Extractor):
exported_files = cmds.arnoldExportAss(filename=file_path,
selected=True,
- asciiAss=True,
+ asciiAss=self.asciiAss,
shadowLinks=True,
lightLinks=True,
boundingBox=True,
@@ -59,13 +60,15 @@ class ExtractAssStandin(pype.api.Extractor):
filenames.append(os.path.split(file)[1])
self.log.info("Exported: {}".format(filenames))
else:
+ self.log.info("Extracting ass")
cmds.arnoldExportAss(filename=file_path,
selected=True,
- asciiAss=True,
+ asciiAss=False,
shadowLinks=True,
lightLinks=True,
boundingBox=True
)
+ self.log.info("Extracted {}".format(filename))
filenames = filename
optionals = [
"frameStart", "frameEnd", "step", "handles",
diff --git a/pype/plugins/maya/publish/extract_look.py b/pype/plugins/maya/publish/extract_look.py
index 5226f80f7a..fa6ecd72c3 100644
--- a/pype/plugins/maya/publish/extract_look.py
+++ b/pype/plugins/maya/publish/extract_look.py
@@ -429,33 +429,42 @@ class ExtractLook(pype.api.Extractor):
a_template = anatomy.templates
project = io.find_one(
- {"type": "project", "name": project_name},
- projection={"config": True, "data": True},
+ {
+ "type": "project",
+ "name": project_name
+ },
+ projection={"config": True, "data": True}
)
template = a_template["publish"]["path"]
# anatomy = instance.context.data['anatomy']
- asset = io.find_one(
- {"type": "asset", "name": asset_name, "parent": project["_id"]}
- )
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
assert asset, ("No asset found by the name '{}' "
"in project '{}'").format(asset_name, project_name)
silo = asset.get("silo")
- subset = io.find_one(
- {"type": "subset", "name": subset_name, "parent": asset["_id"]}
- )
+ subset = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
# assume there is no version yet, we start at `1`
version = None
version_number = 1
if subset is not None:
version = io.find_one(
- {"type": "version",
- "parent": subset["_id"]
- }, sort=[("name", -1)]
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
)
# if there is a subset there ought to be version
diff --git a/pype/plugins/maya/publish/validate_node_ids_related.py b/pype/plugins/maya/publish/validate_node_ids_related.py
index 4872f438d4..191ac0c2f8 100644
--- a/pype/plugins/maya/publish/validate_node_ids_related.py
+++ b/pype/plugins/maya/publish/validate_node_ids_related.py
@@ -38,9 +38,13 @@ class ValidateNodeIDsRelated(pyblish.api.InstancePlugin):
invalid = list()
asset = instance.data['asset']
- asset_data = io.find_one({"name": asset,
- "type": "asset"},
- projection={"_id": True})
+ asset_data = io.find_one(
+ {
+ "name": asset,
+ "type": "asset"
+ },
+ projection={"_id": True}
+ )
asset_id = str(asset_data['_id'])
# We do want to check the referenced nodes as we it might be
diff --git a/pype/plugins/maya/publish/validate_renderlayer_aovs.py b/pype/plugins/maya/publish/validate_renderlayer_aovs.py
index e14c92a8b4..686a11e906 100644
--- a/pype/plugins/maya/publish/validate_renderlayer_aovs.py
+++ b/pype/plugins/maya/publish/validate_renderlayer_aovs.py
@@ -49,9 +49,10 @@ class ValidateRenderLayerAOVs(pyblish.api.InstancePlugin):
"""Check if subset is registered in the database under the asset"""
asset = io.find_one({"type": "asset", "name": asset_name})
- is_valid = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset["_id"]})
+ is_valid = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
return is_valid
-
diff --git a/pype/plugins/nuke/create/create_read.py b/pype/plugins/nuke/create/create_read.py
index 87bb45a6ad..1aa7e68746 100644
--- a/pype/plugins/nuke/create/create_read.py
+++ b/pype/plugins/nuke/create/create_read.py
@@ -6,9 +6,6 @@ from pype import api as pype
import nuke
-log = pype.Logger().get_logger(__name__, "nuke")
-
-
class CrateRead(avalon.nuke.Creator):
# change this to template preset
name = "ReadCopy"
diff --git a/pype/plugins/nuke/create/create_read_plate b/pype/plugins/nuke/create/create_read_plate
deleted file mode 100644
index 90a47cb55e..0000000000
--- a/pype/plugins/nuke/create/create_read_plate
+++ /dev/null
@@ -1,8 +0,0 @@
-# create publishable read node usually used for enabling version tracking
-# also useful for sharing across shots or assets
-
-# if read nodes are selected it will convert them to centainer
-# if no read node selected it will create read node and offer browser to shot resource folder
-
-# type movie > mov or imagesequence
-# type still > matpaint .psd, .tif, .png,
diff --git a/pype/plugins/nuke/create/create_write.py b/pype/plugins/nuke/create/create_write.py
index 042826d4d9..a85408cab3 100644
--- a/pype/plugins/nuke/create/create_write.py
+++ b/pype/plugins/nuke/create/create_write.py
@@ -1,22 +1,14 @@
from collections import OrderedDict
-import avalon.api
-import avalon.nuke
-from pype import api as pype
from pype.nuke import plugin
-from pypeapp import config
-
import nuke
-log = pype.Logger().get_logger(__name__, "nuke")
-
-
class CreateWriteRender(plugin.PypeCreator):
# change this to template preset
name = "WriteRender"
label = "Create Write Render"
hosts = ["nuke"]
- nClass = "write"
+ n_class = "write"
family = "render"
icon = "sign-out"
defaults = ["Main", "Mask"]
@@ -27,7 +19,7 @@ class CreateWriteRender(plugin.PypeCreator):
data = OrderedDict()
data["family"] = self.family
- data["families"] = self.nClass
+ data["families"] = self.n_class
for k, v in self.data.items():
if k not in data.keys():
@@ -35,7 +27,100 @@ class CreateWriteRender(plugin.PypeCreator):
self.data = data
self.nodes = nuke.selectedNodes()
- self.log.info("self.data: '{}'".format(self.data))
+ self.log.debug("_ self.data: '{}'".format(self.data))
+
+ def process(self):
+ from pype.nuke import lib as pnlib
+
+ inputs = []
+ outputs = []
+ instance = nuke.toNode(self.data["subset"])
+ selected_node = None
+
+ # use selection
+ if (self.options or {}).get("useSelection"):
+ nodes = self.nodes
+
+ assert len(nodes) < 2, self.log.error(
+ "Select only one node. The node you want to connect to, "
+ "or tick off `Use selection`")
+
+ selected_node = nodes[0]
+ inputs = [selected_node]
+ outputs = selected_node.dependent()
+
+ if instance:
+ if (instance.name() in selected_node.name()):
+ selected_node = instance.dependencies()[0]
+
+ # if node already exist
+ if instance:
+ # collect input / outputs
+ inputs = instance.dependencies()
+ outputs = instance.dependent()
+ selected_node = inputs[0]
+ # remove old one
+ nuke.delete(instance)
+
+ # recreate new
+ write_data = {
+ "class": self.n_class,
+ "families": [self.family],
+ "avalon": self.data
+ }
+
+ if self.presets.get('fpath_template'):
+ self.log.info("Adding template path from preset")
+ write_data.update(
+ {"fpath_template": self.presets["fpath_template"]}
+ )
+ else:
+ self.log.info("Adding template path from plugin")
+ write_data.update({
+ "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}"})
+
+ write_node = pnlib.create_write_node(
+ self.data["subset"],
+ write_data,
+ input=selected_node)
+
+ # relinking to collected connections
+ for i, input in enumerate(inputs):
+ write_node.setInput(i, input)
+
+ write_node.autoplace()
+
+ for output in outputs:
+ output.setInput(0, write_node)
+
+ return write_node
+
+
+class CreateWritePrerender(plugin.PypeCreator):
+ # change this to template preset
+ name = "WritePrerender"
+ label = "Create Write Prerender"
+ hosts = ["nuke"]
+ n_class = "write"
+ family = "prerender"
+ icon = "sign-out"
+ defaults = ["Key01", "Bg01", "Fg01", "Branch01", "Part01"]
+
+ def __init__(self, *args, **kwargs):
+ super(CreateWritePrerender, self).__init__(*args, **kwargs)
+
+ data = OrderedDict()
+
+ data["family"] = self.family
+ data["families"] = self.n_class
+
+ for k, v in self.data.items():
+ if k not in data.keys():
+ data.update({k: v})
+
+ self.data = data
+ self.nodes = nuke.selectedNodes()
+ self.log.debug("_ self.data: '{}'".format(self.data))
def process(self):
from pype.nuke import lib as pnlib
@@ -70,7 +155,7 @@ class CreateWriteRender(plugin.PypeCreator):
# recreate new
write_data = {
- "class": self.nClass,
+ "class": self.n_class,
"families": [self.family],
"avalon": self.data
}
@@ -83,12 +168,13 @@ class CreateWriteRender(plugin.PypeCreator):
else:
self.log.info("Adding template path from plugin")
write_data.update({
- "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}"})
+ "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}"})
write_node = pnlib.create_write_node(
self.data["subset"],
write_data,
- input=selected_node)
+ input=selected_node,
+ prenodes=[])
# relinking to collected connections
for i, input in enumerate(inputs):
@@ -99,77 +185,27 @@ class CreateWriteRender(plugin.PypeCreator):
for output in outputs:
output.setInput(0, write_node)
- return write_node
+ # open group node
+ write_node.begin()
+ for n in nuke.allNodes():
+ # get write node
+ if n.Class() in "Write":
+ w_node = n
+ write_node.end()
-#
-# class CreateWritePrerender(avalon.nuke.Creator):
-# # change this to template preset
-# preset = "prerender"
-#
-# name = "WritePrerender"
-# label = "Create Write Prerender"
-# hosts = ["nuke"]
-# family = "{}_write".format(preset)
-# families = preset
-# icon = "sign-out"
-# defaults = ["Main", "Mask"]
-#
-# def __init__(self, *args, **kwargs):
-# super(CreateWritePrerender, self).__init__(*args, **kwargs)
-# self.presets = config.get_presets()['plugins']["nuke"]["create"].get(
-# self.__class__.__name__, {}
-# )
-#
-# data = OrderedDict()
-#
-# data["family"] = self.family.split("_")[1]
-# data["families"] = self.families
-#
-# {data.update({k: v}) for k, v in self.data.items()
-# if k not in data.keys()}
-# self.data = data
-#
-# def process(self):
-# self.name = self.data["subset"]
-#
-# instance = nuke.toNode(self.data["subset"])
-# node = 'write'
-#
-# if not instance:
-# write_data = {
-# "class": node,
-# "preset": self.preset,
-# "avalon": self.data
-# }
-#
-# if self.presets.get('fpath_template'):
-# self.log.info("Adding template path from preset")
-# write_data.update(
-# {"fpath_template": self.presets["fpath_template"]}
-# )
-# else:
-# self.log.info("Adding template path from plugin")
-# write_data.update({
-# "fpath_template": "{work}/prerenders/{subset}/{subset}.{frame}.{ext}"})
-#
-# # get group node
-# group_node = create_write_node(self.data["subset"], write_data)
-#
-# # open group node
-# group_node.begin()
-# for n in nuke.allNodes():
-# # get write node
-# if n.Class() in "Write":
-# write_node = n
-# group_node.end()
-#
-# # linking knobs to group property panel
-# linking_knobs = ["first", "last", "use_limit"]
-# for k in linking_knobs:
-# lnk = nuke.Link_Knob(k)
-# lnk.makeLink(write_node.name(), k)
-# lnk.setName(k.replace('_', ' ').capitalize())
-# lnk.clearFlag(nuke.STARTLINE)
-# group_node.addKnob(lnk)
-#
-# return
+ # add inner write node Tab
+ write_node.addKnob(nuke.Tab_Knob("WriteLinkedKnobs"))
+
+ # linking knobs to group property panel
+ linking_knobs = ["channels", "___", "first", "last", "use_limit"]
+ for k in linking_knobs:
+ if "___" in k:
+ write_node.addKnob(nuke.Text_Knob(''))
+ else:
+ lnk = nuke.Link_Knob(k)
+ lnk.makeLink(w_node.name(), k)
+ lnk.setName(k.replace('_', ' ').capitalize())
+ lnk.clearFlag(nuke.STARTLINE)
+ write_node.addKnob(lnk)
+
+ return write_node
diff --git a/pype/plugins/nuke/load/load_backdrop.py b/pype/plugins/nuke/load/load_backdrop.py
new file mode 100644
index 0000000000..7f58d4e9ec
--- /dev/null
+++ b/pype/plugins/nuke/load/load_backdrop.py
@@ -0,0 +1,319 @@
+from avalon import api, style, io
+import nuke
+import nukescripts
+from pype.nuke import lib as pnlib
+from avalon.nuke import lib as anlib
+from avalon.nuke import containerise, update_container
+reload(pnlib)
+
+class LoadBackdropNodes(api.Loader):
+ """Loading Published Backdrop nodes (workfile, nukenodes)"""
+
+ representations = ["nk"]
+ families = ["workfile", "nukenodes"]
+
+ label = "Iport Nuke Nodes"
+ order = 0
+ icon = "eye"
+ color = style.colors.light
+ node_color = "0x7533c1ff"
+
+ def load(self, context, name, namespace, data):
+ """
+ Loading function to import .nk file into script and wrap
+ it on backdrop
+
+ Arguments:
+ context (dict): context of version
+ name (str): name of the version
+ namespace (str): asset name
+ data (dict): compulsory attribute > not used
+
+ Returns:
+ nuke node: containerised nuke node object
+ """
+
+ # get main variables
+ version = context['version']
+ version_data = version.get("data", {})
+ vname = version.get("name", None)
+ first = version_data.get("frameStart", None)
+ last = version_data.get("frameEnd", None)
+ namespace = namespace or context['asset']['name']
+ colorspace = version_data.get("colorspace", None)
+ object_name = "{}_{}".format(name, namespace)
+
+ # prepare data for imprinting
+ # add additional metadata from the version to imprint to Avalon knob
+ add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd",
+ "source", "author", "fps"]
+
+ data_imprint = {"frameStart": first,
+ "frameEnd": last,
+ "version": vname,
+ "colorspaceInput": colorspace,
+ "objectName": object_name}
+
+ for k in add_keys:
+ data_imprint.update({k: version_data[k]})
+
+ # getting file path
+ file = self.fname.replace("\\", "/")
+
+ # adding nodes to node graph
+ # just in case we are in group lets jump out of it
+ nuke.endGroup()
+
+ # Get mouse position
+ n = nuke.createNode("NoOp")
+ xcursor, ycursor = (n.xpos(), n.ypos())
+ anlib.reset_selection()
+ nuke.delete(n)
+
+ bdn_frame = 50
+
+ with anlib.maintained_selection():
+
+ # add group from nk
+ nuke.nodePaste(file)
+
+ # get all pasted nodes
+ new_nodes = list()
+ nodes = nuke.selectedNodes()
+
+ # get pointer position in DAG
+ xpointer, ypointer = pnlib.find_free_space_to_paste_nodes(nodes, direction="right", offset=200+bdn_frame)
+
+ # reset position to all nodes and replace inputs and output
+ for n in nodes:
+ anlib.reset_selection()
+ xpos = (n.xpos() - xcursor) + xpointer
+ ypos = (n.ypos() - ycursor) + ypointer
+ n.setXYpos(xpos, ypos)
+
+ # replace Input nodes for dots
+ if n.Class() in "Input":
+ dot = nuke.createNode("Dot")
+ new_name = n.name().replace("INP", "DOT")
+ dot.setName(new_name)
+ dot["label"].setValue(new_name)
+ dot.setXYpos(xpos, ypos)
+ new_nodes.append(dot)
+
+ # rewire
+ dep = n.dependent()
+ for d in dep:
+ index = next((i for i, dpcy in enumerate(
+ d.dependencies())
+ if n is dpcy), 0)
+ d.setInput(index, dot)
+
+ # remove Input node
+ anlib.reset_selection()
+ nuke.delete(n)
+ continue
+
+ # replace Input nodes for dots
+ elif n.Class() in "Output":
+ dot = nuke.createNode("Dot")
+ new_name = n.name() + "_DOT"
+ dot.setName(new_name)
+ dot["label"].setValue(new_name)
+ dot.setXYpos(xpos, ypos)
+ new_nodes.append(dot)
+
+ # rewire
+ dep = next((d for d in n.dependencies()), None)
+ if dep:
+ dot.setInput(0, dep)
+
+ # remove Input node
+ anlib.reset_selection()
+ nuke.delete(n)
+ continue
+ else:
+ new_nodes.append(n)
+
+ # reselect nodes with new Dot instead of Inputs and Output
+ anlib.reset_selection()
+ anlib.select_nodes(new_nodes)
+ # place on backdrop
+ bdn = nukescripts.autoBackdrop()
+
+ # add frame offset
+ xpos = bdn.xpos() - bdn_frame
+ ypos = bdn.ypos() - bdn_frame
+ bdwidth = bdn["bdwidth"].value() + (bdn_frame*2)
+ bdheight = bdn["bdheight"].value() + (bdn_frame*2)
+
+ bdn["xpos"].setValue(xpos)
+ bdn["ypos"].setValue(ypos)
+ bdn["bdwidth"].setValue(bdwidth)
+ bdn["bdheight"].setValue(bdheight)
+
+ bdn["name"].setValue(object_name)
+ bdn["label"].setValue("Version tracked frame: \n`{}`\n\nPLEASE DO NOT REMOVE OR MOVE \nANYTHING FROM THIS FRAME!".format(object_name))
+ bdn["note_font_size"].setValue(20)
+
+ return containerise(
+ node=bdn,
+ name=name,
+ namespace=namespace,
+ context=context,
+ loader=self.__class__.__name__,
+ data=data_imprint)
+
+ def update(self, container, representation):
+ """Update the Loader's path
+
+ Nuke automatically tries to reset some variables when changing
+ the loader's path to a new file. These automatic changes are to its
+ inputs:
+
+ """
+
+ # get main variables
+ # Get version from io
+ version = io.find_one({
+ "type": "version",
+ "_id": representation["parent"]
+ })
+ # get corresponding node
+ GN = nuke.toNode(container['objectName'])
+
+ file = api.get_representation_path(representation).replace("\\", "/")
+ context = representation["context"]
+ name = container['name']
+ version_data = version.get("data", {})
+ vname = version.get("name", None)
+ first = version_data.get("frameStart", None)
+ last = version_data.get("frameEnd", None)
+ namespace = container['namespace']
+ colorspace = version_data.get("colorspace", None)
+ object_name = "{}_{}".format(name, namespace)
+
+ add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd",
+ "source", "author", "fps"]
+
+ data_imprint = {"representation": str(representation["_id"]),
+ "frameStart": first,
+ "frameEnd": last,
+ "version": vname,
+ "colorspaceInput": colorspace,
+ "objectName": object_name}
+
+ for k in add_keys:
+ data_imprint.update({k: version_data[k]})
+
+ # adding nodes to node graph
+ # just in case we are in group lets jump out of it
+ nuke.endGroup()
+
+ with anlib.maintained_selection():
+ xpos = GN.xpos()
+ ypos = GN.ypos()
+ avalon_data = anlib.get_avalon_knob_data(GN)
+ nuke.delete(GN)
+ # add group from nk
+ nuke.nodePaste(file)
+
+ GN = nuke.selectedNode()
+ anlib.set_avalon_knob_data(GN, avalon_data)
+ GN.setXYpos(xpos, ypos)
+ GN["name"].setValue(object_name)
+
+ # get all versions in list
+ versions = io.find({
+ "type": "version",
+ "parent": version["parent"]
+ }).distinct('name')
+
+ max_version = max(versions)
+
+ # change color of node
+ if version.get("name") not in [max_version]:
+ GN["tile_color"].setValue(int("0xd88467ff", 16))
+ else:
+ GN["tile_color"].setValue(int(self.node_color, 16))
+
+ self.log.info("udated to version: {}".format(version.get("name")))
+
+ return update_container(GN, data_imprint)
+
+ def connect_active_viewer(self, group_node):
+ """
+ Finds Active viewer and
+ place the node under it, also adds
+ name of group into Input Process of the viewer
+
+ Arguments:
+ group_node (nuke node): nuke group node object
+
+ """
+ group_node_name = group_node["name"].value()
+
+ viewer = [n for n in nuke.allNodes() if "Viewer1" in n["name"].value()]
+ if len(viewer) > 0:
+ viewer = viewer[0]
+ else:
+ self.log.error("Please create Viewer node before you "
+ "run this action again")
+ return None
+
+ # get coordinates of Viewer1
+ xpos = viewer["xpos"].value()
+ ypos = viewer["ypos"].value()
+
+ ypos += 150
+
+ viewer["ypos"].setValue(ypos)
+
+ # set coordinates to group node
+ group_node["xpos"].setValue(xpos)
+ group_node["ypos"].setValue(ypos + 50)
+
+ # add group node name to Viewer Input Process
+ viewer["input_process_node"].setValue(group_node_name)
+
+ # put backdrop under
+ pnlib.create_backdrop(label="Input Process", layer=2,
+ nodes=[viewer, group_node], color="0x7c7faaff")
+
+ return True
+
+ def get_item(self, data, trackIndex, subTrackIndex):
+ return {key: val for key, val in data.items()
+ if subTrackIndex == val["subTrackIndex"]
+ if trackIndex == val["trackIndex"]}
+
+ def byteify(self, input):
+ """
+ Converts unicode strings to strings
+ It goes trought all dictionary
+
+ Arguments:
+ input (dict/str): input
+
+ Returns:
+ dict: with fixed values and keys
+
+ """
+
+ if isinstance(input, dict):
+ return {self.byteify(key): self.byteify(value)
+ for key, value in input.iteritems()}
+ elif isinstance(input, list):
+ return [self.byteify(element) for element in input]
+ elif isinstance(input, unicode):
+ return input.encode('utf-8')
+ else:
+ return input
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ from avalon.nuke import viewer_update_and_undo_stop
+ node = nuke.toNode(container['objectName'])
+ with viewer_update_and_undo_stop():
+ nuke.delete(node)
diff --git a/pype/plugins/nuke/publish/collect_asset_info.py b/pype/plugins/nuke/publish/collect_asset_info.py
index 76b93ef3d0..8a8791ec36 100644
--- a/pype/plugins/nuke/publish/collect_asset_info.py
+++ b/pype/plugins/nuke/publish/collect_asset_info.py
@@ -13,8 +13,10 @@ class CollectAssetInfo(pyblish.api.ContextPlugin):
]
def process(self, context):
- asset_data = io.find_one({"type": "asset",
- "name": api.Session["AVALON_ASSET"]})
+ asset_data = io.find_one({
+ "type": "asset",
+ "name": api.Session["AVALON_ASSET"]
+ })
self.log.info("asset_data: {}".format(asset_data))
context.data['handles'] = int(asset_data["data"].get("handles", 0))
diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py
index c5fb289a1e..5b123ed7b9 100644
--- a/pype/plugins/nuke/publish/collect_instances.py
+++ b/pype/plugins/nuke/publish/collect_instances.py
@@ -15,9 +15,10 @@ class CollectNukeInstances(pyblish.api.ContextPlugin):
hosts = ["nuke", "nukeassist"]
def process(self, context):
-
- asset_data = io.find_one({"type": "asset",
- "name": api.Session["AVALON_ASSET"]})
+ asset_data = io.find_one({
+ "type": "asset",
+ "name": api.Session["AVALON_ASSET"]
+ })
self.log.debug("asset_data: {}".format(asset_data["data"]))
instances = []
diff --git a/pype/plugins/nuke/publish/collect_script_version.py b/pype/plugins/nuke/publish/collect_script_version.py
new file mode 100644
index 0000000000..9a6b5bf572
--- /dev/null
+++ b/pype/plugins/nuke/publish/collect_script_version.py
@@ -0,0 +1,22 @@
+import os
+import pype.api as pype
+import pyblish.api
+
+
+class CollectScriptVersion(pyblish. api.ContextPlugin):
+ """Collect Script Version."""
+
+ order = pyblish.api.CollectorOrder
+ label = "Collect Script Version"
+ hosts = [
+ "nuke",
+ "nukeassist"
+ ]
+
+ def process(self, context):
+ file_path = context.data["currentFile"]
+ base_name = os.path.basename(file_path)
+ # get version string
+ version = pype.get_version_from_path(base_name)
+
+ context.data['version'] = version
diff --git a/pype/plugins/nuke/publish/collect_slate_node.py b/pype/plugins/nuke/publish/collect_slate_node.py
new file mode 100644
index 0000000000..d8d6b50f05
--- /dev/null
+++ b/pype/plugins/nuke/publish/collect_slate_node.py
@@ -0,0 +1,40 @@
+import pyblish.api
+import nuke
+
+
+class CollectSlate(pyblish.api.InstancePlugin):
+ """Check if SLATE node is in scene and connected to rendering tree"""
+
+ order = pyblish.api.CollectorOrder + 0.09
+ label = "Collect Slate Node"
+ hosts = ["nuke"]
+ families = ["write"]
+
+ def process(self, instance):
+ node = instance[0]
+
+ slate = next((n for n in nuke.allNodes()
+ if "slate" in n.name().lower()
+ if not n["disable"].getValue()),
+ None)
+
+ if slate:
+ # check if slate node is connected to write node tree
+ slate_check = 0
+ slate_node = None
+ while slate_check == 0:
+ try:
+ node = node.dependencies()[0]
+ if slate.name() in node.name():
+ slate_node = node
+ slate_check = 1
+ except IndexError:
+ break
+
+ if slate_node:
+ instance.data["slateNode"] = slate_node
+ instance.data["families"].append("slate")
+ self.log.info(
+ "Slate node is in node graph: `{}`".format(slate.name()))
+ self.log.debug(
+ "__ instance: `{}`".format(instance))
diff --git a/pype/plugins/nuke/publish/collect_workfile.py b/pype/plugins/nuke/publish/collect_workfile.py
index aaee554fbf..4fff9f46ed 100644
--- a/pype/plugins/nuke/publish/collect_workfile.py
+++ b/pype/plugins/nuke/publish/collect_workfile.py
@@ -2,8 +2,6 @@ import nuke
import pyblish.api
import os
-import pype.api as pype
-
from avalon.nuke import (
get_avalon_knob_data,
add_publish_knob
@@ -11,7 +9,7 @@ from avalon.nuke import (
class CollectWorkfile(pyblish.api.ContextPlugin):
- """Publish current script version."""
+ """Collect current script for publish."""
order = pyblish.api.CollectorOrder + 0.1
label = "Collect Workfile"
@@ -31,9 +29,6 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
base_name = os.path.basename(file_path)
subset = "{0}_{1}".format(os.getenv("AVALON_TASK", None), family)
- # get version string
- version = pype.get_version_from_path(base_name)
-
# Get frame range
first_frame = int(root["first_frame"].getValue())
last_frame = int(root["last_frame"].getValue())
@@ -53,7 +48,6 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
script_data = {
"asset": os.getenv("AVALON_ASSET", None),
- "version": version,
"frameStart": first_frame + handle_start,
"frameEnd": last_frame - handle_end,
"resolutionWidth": resolution_width,
diff --git a/pype/plugins/nuke/publish/collect_writes.py b/pype/plugins/nuke/publish/collect_writes.py
index dd3049834d..37c86978b6 100644
--- a/pype/plugins/nuke/publish/collect_writes.py
+++ b/pype/plugins/nuke/publish/collect_writes.py
@@ -50,9 +50,10 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
output_dir = os.path.dirname(path)
self.log.debug('output dir: {}'.format(output_dir))
- # get version
- version = pype.get_version_from_path(nuke.root().name())
- instance.data['version'] = version
+ # get version to instance for integration
+ instance.data['version'] = instance.context.data.get(
+ "version", pype.get_version_from_path(nuke.root().name()))
+
self.log.debug('Write Version: %s' % instance.data('version'))
# create label
@@ -94,12 +95,13 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
"handleEnd": handle_end,
"frameStart": first_frame + handle_start,
"frameEnd": last_frame - handle_end,
- "version": int(version),
+ "version": int(instance.data['version']),
"colorspace": node["colorspace"].value(),
"families": [instance.data["family"]],
"subset": instance.data["subset"],
"fps": instance.context.data["fps"]
}
+
instance.data["family"] = "write"
group_node = [x for x in instance if x.Class() == "Group"][0]
deadlineChunkSize = 1
@@ -129,5 +131,4 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
"subsetGroup": "renders"
})
-
self.log.debug("instance.data: {}".format(instance.data))
diff --git a/pype/plugins/nuke/publish/extract_render_local.py b/pype/plugins/nuke/publish/extract_render_local.py
index 825db67e9d..9b8baa468b 100644
--- a/pype/plugins/nuke/publish/extract_render_local.py
+++ b/pype/plugins/nuke/publish/extract_render_local.py
@@ -28,6 +28,11 @@ class NukeRenderLocal(pype.api.Extractor):
self.log.debug("instance collected: {}".format(instance.data))
first_frame = instance.data.get("frameStart", None)
+
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ first_frame -= 1
+
last_frame = instance.data.get("frameEnd", None)
node_subset_name = instance.data.get("name", None)
@@ -47,6 +52,10 @@ class NukeRenderLocal(pype.api.Extractor):
int(last_frame)
)
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ first_frame += 1
+
path = node['file'].value()
out_dir = os.path.dirname(path)
ext = node["file_type"].value()
diff --git a/pype/plugins/nuke/publish/extract_review_data_lut.py b/pype/plugins/nuke/publish/extract_review_data_lut.py
index dfc10952cd..4373309363 100644
--- a/pype/plugins/nuke/publish/extract_review_data_lut.py
+++ b/pype/plugins/nuke/publish/extract_review_data_lut.py
@@ -6,7 +6,7 @@ import pype
reload(pnlib)
-class ExtractReviewLutData(pype.api.Extractor):
+class ExtractReviewDataLut(pype.api.Extractor):
"""Extracts movie and thumbnail with baked in luts
must be run after extract_render_local.py
@@ -37,8 +37,9 @@ class ExtractReviewLutData(pype.api.Extractor):
self.log.info(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
+ # generate data
with anlib.maintained_selection():
- exporter = pnlib.Exporter_review_lut(
+ exporter = pnlib.ExporterReviewLut(
self, instance
)
data = exporter.generate_lut()
diff --git a/pype/plugins/nuke/publish/extract_review_data_mov.py b/pype/plugins/nuke/publish/extract_review_data_mov.py
new file mode 100644
index 0000000000..39c338b62c
--- /dev/null
+++ b/pype/plugins/nuke/publish/extract_review_data_mov.py
@@ -0,0 +1,62 @@
+import os
+import pyblish.api
+from avalon.nuke import lib as anlib
+from pype.nuke import lib as pnlib
+import pype
+reload(pnlib)
+
+
+class ExtractReviewDataMov(pype.api.Extractor):
+ """Extracts movie and thumbnail with baked in luts
+
+ must be run after extract_render_local.py
+
+ """
+
+ order = pyblish.api.ExtractorOrder + 0.01
+ label = "Extract Review Data Mov"
+
+ families = ["review", "render", "render.local"]
+ hosts = ["nuke"]
+
+ def process(self, instance):
+ families = instance.data["families"]
+ self.log.info("Creating staging dir...")
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = list()
+
+ staging_dir = os.path.normpath(
+ os.path.dirname(instance.data['path']))
+
+ instance.data["stagingDir"] = staging_dir
+
+ self.log.info(
+ "StagingDir `{0}`...".format(instance.data["stagingDir"]))
+
+ # generate data
+ with anlib.maintained_selection():
+ exporter = pnlib.ExporterReviewMov(
+ self, instance)
+
+ if "render.farm" in families:
+ instance.data["families"].remove("review")
+ instance.data["families"].remove("ftrack")
+ data = exporter.generate_mov(farm=True)
+
+ self.log.debug(
+ "_ data: {}".format(data))
+
+ instance.data.update({
+ "bakeRenderPath": data.get("bakeRenderPath"),
+ "bakeScriptPath": data.get("bakeScriptPath"),
+ "bakeWriteNodeName": data.get("bakeWriteNodeName")
+ })
+ else:
+ data = exporter.generate_mov()
+
+ # assign to representations
+ instance.data["representations"] += data["representations"]
+
+ self.log.debug(
+ "_ representations: {}".format(instance.data["representations"]))
diff --git a/pype/plugins/nuke/publish/extract_slate_frame.py b/pype/plugins/nuke/publish/extract_slate_frame.py
new file mode 100644
index 0000000000..7e43b3cd6f
--- /dev/null
+++ b/pype/plugins/nuke/publish/extract_slate_frame.py
@@ -0,0 +1,154 @@
+import os
+import nuke
+from avalon.nuke import lib as anlib
+import pyblish.api
+import pype
+
+
+class ExtractSlateFrame(pype.api.Extractor):
+ """Extracts movie and thumbnail with baked in luts
+
+ must be run after extract_render_local.py
+
+ """
+
+ order = pyblish.api.ExtractorOrder + 0.01
+ label = "Extract Slate Frame"
+
+ families = ["slate"]
+ hosts = ["nuke"]
+
+
+ def process(self, instance):
+ if hasattr(self, "viewer_lut_raw"):
+ self.viewer_lut_raw = self.viewer_lut_raw
+ else:
+ self.viewer_lut_raw = False
+
+ with anlib.maintained_selection():
+ self.log.debug("instance: {}".format(instance))
+ self.log.debug("instance.data[families]: {}".format(
+ instance.data["families"]))
+
+ self.render_slate(instance)
+
+ def render_slate(self, instance):
+ node = instance[0] # group node
+ self.log.info("Creating staging dir...")
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = list()
+
+ staging_dir = os.path.normpath(
+ os.path.dirname(instance.data['path']))
+
+ instance.data["stagingDir"] = staging_dir
+
+ self.log.info(
+ "StagingDir `{0}`...".format(instance.data["stagingDir"]))
+
+ temporary_nodes = []
+ collection = instance.data.get("collection", None)
+
+ if collection:
+ # get path
+ fname = os.path.basename(collection.format(
+ "{head}{padding}{tail}"))
+ fhead = collection.format("{head}")
+
+ # get first and last frame
+ first_frame = min(collection.indexes) - 1
+
+ if "slate" in instance.data["families"]:
+ first_frame += 1
+
+ last_frame = first_frame
+ else:
+ fname = os.path.basename(instance.data.get("path", None))
+ fhead = os.path.splitext(fname)[0] + "."
+ first_frame = instance.data.get("frameStart", None) - 1
+ last_frame = first_frame
+
+ if "#" in fhead:
+ fhead = fhead.replace("#", "")[:-1]
+
+ previous_node = node
+
+ # get input process and connect it to baking
+ ipn = self.get_view_process_node()
+ if ipn is not None:
+ ipn.setInput(0, previous_node)
+ previous_node = ipn
+ temporary_nodes.append(ipn)
+
+ if not self.viewer_lut_raw:
+ dag_node = nuke.createNode("OCIODisplay")
+ dag_node.setInput(0, previous_node)
+ previous_node = dag_node
+ temporary_nodes.append(dag_node)
+
+ # create write node
+ write_node = nuke.createNode("Write")
+ file = fhead + "slate.png"
+ path = os.path.join(staging_dir, file).replace("\\", "/")
+ instance.data["slateFrame"] = path
+ write_node["file"].setValue(path)
+ write_node["file_type"].setValue("png")
+ write_node["raw"].setValue(1)
+ write_node.setInput(0, previous_node)
+ temporary_nodes.append(write_node)
+
+ # fill slate node with comments
+ self.add_comment_slate_node(instance)
+
+ # Render frames
+ nuke.execute(write_node.name(), int(first_frame), int(last_frame))
+
+ self.log.debug(
+ "slate frame path: {}".format(instance.data["slateFrame"]))
+
+ # Clean up
+ for node in temporary_nodes:
+ nuke.delete(node)
+
+
+ def get_view_process_node(self):
+
+ # Select only the target node
+ if nuke.selectedNodes():
+ [n.setSelected(False) for n in nuke.selectedNodes()]
+
+ ipn_orig = None
+ for v in [n for n in nuke.allNodes()
+ if "Viewer" in n.Class()]:
+ ip = v['input_process'].getValue()
+ ipn = v['input_process_node'].getValue()
+ if "VIEWER_INPUT" not in ipn and ip:
+ ipn_orig = nuke.toNode(ipn)
+ ipn_orig.setSelected(True)
+
+ if ipn_orig:
+ nuke.nodeCopy('%clipboard%')
+
+ [n.setSelected(False) for n in nuke.selectedNodes()] # Deselect all
+
+ nuke.nodePaste('%clipboard%')
+
+ ipn = nuke.selectedNode()
+
+ return ipn
+
+ def add_comment_slate_node(self, instance):
+ node = instance.data.get("slateNode")
+ if not node:
+ return
+
+ comment = instance.context.data.get("comment")
+ intent = instance.context.data.get("intent")
+
+ try:
+ node["f_submission_note"].setValue(comment)
+ node["f_submitting_for"].setValue(intent)
+ except NameError:
+ return
+ instance.data.pop("slateNode")
diff --git a/pype/plugins/nuke/publish/extract_thumbnail.py b/pype/plugins/nuke/publish/extract_thumbnail.py
index 450bb39928..55ba34a0d4 100644
--- a/pype/plugins/nuke/publish/extract_thumbnail.py
+++ b/pype/plugins/nuke/publish/extract_thumbnail.py
@@ -28,19 +28,16 @@ class ExtractThumbnail(pype.api.Extractor):
self.render_thumbnail(instance)
def render_thumbnail(self, instance):
- node = instance[0] # group node
+ node = instance[0] # group node
self.log.info("Creating staging dir...")
- if "representations" in instance.data:
- staging_dir = instance.data[
- "representations"][0]["stagingDir"].replace("\\", "/")
- instance.data["stagingDir"] = staging_dir
- instance.data["representations"][0]["tags"] = ["review"]
- else:
- instance.data["representations"] = []
- # get output path
- render_path = instance.data['path']
- staging_dir = os.path.normpath(os.path.dirname(render_path))
- instance.data["stagingDir"] = staging_dir
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = list()
+
+ staging_dir = os.path.normpath(
+ os.path.dirname(instance.data['path']))
+
+ instance.data["stagingDir"] = staging_dir
self.log.info(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
@@ -165,7 +162,7 @@ class ExtractThumbnail(pype.api.Extractor):
if ipn_orig:
nuke.nodeCopy('%clipboard%')
- [n.setSelected(False) for n in nuke.selectedNodes()] # Deselect all
+ [n.setSelected(False) for n in nuke.selectedNodes()] # Deselect all
nuke.nodePaste('%clipboard%')
diff --git a/pype/plugins/nuke/publish/submit_nuke_deadline.py b/pype/plugins/nuke/publish/submit_nuke_deadline.py
index d9207d2bfc..71108189c0 100644
--- a/pype/plugins/nuke/publish/submit_nuke_deadline.py
+++ b/pype/plugins/nuke/publish/submit_nuke_deadline.py
@@ -1,7 +1,7 @@
import os
import json
import getpass
-
+
from avalon import api
from avalon.vendor import requests
import re
@@ -26,31 +26,69 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
def process(self, instance):
node = instance[0]
- # for x in instance:
- # if x.Class() == "Write":
- # node = x
- #
- # if node is None:
- # return
+ context = instance.context
DEADLINE_REST_URL = os.environ.get("DEADLINE_REST_URL",
"http://localhost:8082")
assert DEADLINE_REST_URL, "Requires DEADLINE_REST_URL"
- context = instance.context
+ self.deadline_url = "{}/api/jobs".format(DEADLINE_REST_URL)
+ self._comment = context.data.get("comment", "")
+ self._ver = re.search(r"\d+\.\d+", context.data.get("hostVersion"))
+ self._deadline_user = context.data.get(
+ "deadlineUser", getpass.getuser())
+ self._frame_start = int(instance.data["frameStart"])
+ self._frame_end = int(instance.data["frameEnd"])
# get output path
render_path = instance.data['path']
- render_dir = os.path.normpath(os.path.dirname(render_path))
-
script_path = context.data["currentFile"]
- script_name = os.path.basename(script_path)
- comment = context.data.get("comment", "")
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ self._frame_start -= 1
- deadline_user = context.data.get("deadlineUser", getpass.getuser())
+ response = self.payload_submit(instance,
+ script_path,
+ render_path,
+ node.name()
+ )
+ # Store output dir for unified publisher (filesequence)
+ instance.data["deadlineSubmissionJob"] = response.json()
+ instance.data["publishJobState"] = "Active"
+
+ if instance.data.get("bakeScriptPath"):
+ render_path = instance.data.get("bakeRenderPath")
+ script_path = instance.data.get("bakeScriptPath")
+ exe_node_name = instance.data.get("bakeWriteNodeName")
+
+ # exception for slate workflow
+ if "slate" in instance.data["families"]:
+ self._frame_start += 1
+
+ resp = self.payload_submit(instance,
+ script_path,
+ render_path,
+ exe_node_name,
+ response.json()
+ )
+ # Store output dir for unified publisher (filesequence)
+ instance.data["deadlineSubmissionJob"] = resp.json()
+ instance.data["publishJobState"] = "Suspended"
+
+ def payload_submit(self,
+ instance,
+ script_path,
+ render_path,
+ exe_node_name,
+ responce_data=None
+ ):
+ render_dir = os.path.normpath(os.path.dirname(render_path))
+ script_name = os.path.basename(script_path)
jobname = "%s - %s" % (script_name, instance.name)
- ver = re.search(r"\d+\.\d+", context.data.get("hostVersion"))
+
+ if not responce_data:
+ responce_data = {}
try:
# Ensure render folder exists
@@ -58,10 +96,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
except OSError:
pass
- # Documentation for keys available at:
- # https://docs.thinkboxsoftware.com
- # /products/deadline/8.0/1_User%20Manual/manual
- # /manual-submission.html#job-info-file-options
payload = {
"JobInfo": {
# Top-level group name
@@ -71,21 +105,20 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
"Name": jobname,
# Arbitrary username, for visualisation in Monitor
- "UserName": deadline_user,
+ "UserName": self._deadline_user,
+
+ "Priority": instance.data["deadlinePriority"],
+
+ "Pool": "2d",
+ "SecondaryPool": "2d",
"Plugin": "Nuke",
"Frames": "{start}-{end}".format(
- start=int(instance.data["frameStart"]),
- end=int(instance.data["frameEnd"])
+ start=self._frame_start,
+ end=self._frame_end
),
- "ChunkSize": instance.data["deadlineChunkSize"],
- "Priority": instance.data["deadlinePriority"],
+ "Comment": self._comment,
- "Comment": comment,
-
- # Optional, enable double-click to preview rendered
- # frames from Deadline Monitor
- # "OutputFilename0": output_filename_0.replace("\\", "/"),
},
"PluginInfo": {
# Input
@@ -96,27 +129,29 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
# "OutputFilePrefix": render_variables["filename_prefix"],
# Mandatory for Deadline
- "Version": ver.group(),
+ "Version": self._ver.group(),
# Resolve relative references
"ProjectPath": script_path,
"AWSAssetFile0": render_path,
# Only the specific write node is rendered.
- "WriteNode": node.name()
+ "WriteNode": exe_node_name
},
# Mandatory for Deadline, may be empty
"AuxFiles": []
}
+ if responce_data.get("_id"):
+ payload["JobInfo"].update({
+ "JobType": "Normal",
+ "BatchName": responce_data["Props"]["Batch"],
+ "JobDependency0": responce_data["_id"],
+ "ChunkSize": 99999999
+ })
+
# Include critical environment variables with submission
keys = [
- # This will trigger `userSetup.py` on the slave
- # such that proper initialisation happens the same
- # way as it does on a local machine.
- # TODO(marcus): This won't work if the slaves don't
- # have accesss to these paths, such as if slaves are
- # running Linux and the submitter is on Windows.
"PYTHONPATH",
"PATH",
"AVALON_SCHEMA",
@@ -162,11 +197,12 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
if key == "PYTHONPATH":
clean_path = clean_path.replace('python2', 'python3')
+
clean_path = clean_path.replace(
- os.path.normpath(
- environment['PYPE_STUDIO_CORE_MOUNT']), # noqa
- os.path.normpath(
- environment['PYPE_STUDIO_CORE_PATH'])) # noqa
+ os.path.normpath(
+ environment['PYPE_STUDIO_CORE_MOUNT']), # noqa
+ os.path.normpath(
+ environment['PYPE_STUDIO_CORE_PATH'])) # noqa
clean_environment[key] = clean_path
environment = clean_environment
@@ -181,20 +217,15 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin):
plugin = payload["JobInfo"]["Plugin"]
self.log.info("using render plugin : {}".format(plugin))
- self.preflight_check(instance)
-
self.log.info("Submitting..")
self.log.info(json.dumps(payload, indent=4, sort_keys=True))
- # E.g. http://192.168.0.1:8082/api/jobs
- url = "{}/api/jobs".format(DEADLINE_REST_URL)
- response = requests.post(url, json=payload)
+ response = requests.post(self.deadline_url, json=payload)
+
if not response.ok:
raise Exception(response.text)
- # Store output dir for unified publisher (filesequence)
- instance.data["deadlineSubmissionJob"] = response.json()
- instance.data["publishJobState"] = "Active"
+ return response
def preflight_check(self, instance):
"""Ensure the startFrame, endFrame and byFrameStep are integers"""
diff --git a/pype/plugins/nuke/publish/validate_rendered_frames.py b/pype/plugins/nuke/publish/validate_rendered_frames.py
index 3887b5d5b7..c63c289947 100644
--- a/pype/plugins/nuke/publish/validate_rendered_frames.py
+++ b/pype/plugins/nuke/publish/validate_rendered_frames.py
@@ -75,6 +75,9 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
self.log.info(
'len(collection.indexes): {}'.format(collected_frames_len)
)
+
+ if "slate" in instance.data["families"]:
+ collected_frames_len -= 1
assert (collected_frames_len == frame_length), (
"{} missing frames. Use repair to render all frames"
diff --git a/pype/plugins/nuke/publish/validate_write_knobs.py b/pype/plugins/nuke/publish/validate_write_knobs.py
index 072ffd4b17..24572bedb3 100644
--- a/pype/plugins/nuke/publish/validate_write_knobs.py
+++ b/pype/plugins/nuke/publish/validate_write_knobs.py
@@ -8,24 +8,31 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin):
"""Ensure knobs are consistent.
Knobs to validate and their values comes from the
- "nuke/knobs.json" preset, which needs this structure:
- {
- "family": {
- "knob_name": knob_value
- }
- }
+
+ Example for presets in config:
+ "presets/plugins/nuke/publish.json" preset, which needs this structure:
+ "ValidateNukeWriteKnobs": {
+ "enabled": true,
+ "knobs": {
+ "family": {
+ "knob_name": knob_value
+ }
+ }
+ }
"""
order = pyblish.api.ValidatorOrder
- label = "Knobs"
+ label = "Validate Write Knobs"
hosts = ["nuke"]
actions = [pype.api.RepairContextAction]
optional = True
def process(self, context):
# Check for preset existence.
- if not context.data["presets"]["nuke"].get("knobs"):
+ if not getattr(self, "knobs"):
return
+
+ self.log.debug("__ self.knobs: {}".format(self.knobs))
invalid = self.get_invalid(context, compute=True)
if invalid:
@@ -43,7 +50,6 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin):
@classmethod
def get_invalid_knobs(cls, context):
- presets = context.data["presets"]["nuke"]["knobs"]
invalid_knobs = []
for instance in context:
# Filter publisable instances.
@@ -53,15 +59,15 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin):
# Filter families.
families = [instance.data["family"]]
families += instance.data.get("families", [])
- families = list(set(families) & set(presets.keys()))
+ families = list(set(families) & set(cls.knobs.keys()))
if not families:
continue
# Get all knobs to validate.
knobs = {}
for family in families:
- for preset in presets[family]:
- knobs.update({preset: presets[family][preset]})
+ for preset in cls.knobs[family]:
+ knobs.update({preset: cls.knobs[family][preset]})
# Get invalid knobs.
nodes = []
diff --git a/pype/plugins/nukestudio/publish/extract_effects.py b/pype/plugins/nukestudio/publish/extract_effects.py
index 7aa79d6cc3..15d2a80a55 100644
--- a/pype/plugins/nukestudio/publish/extract_effects.py
+++ b/pype/plugins/nukestudio/publish/extract_effects.py
@@ -169,32 +169,44 @@ class ExtractVideoTracksLuts(pyblish.api.InstancePlugin):
project_name = api.Session["AVALON_PROJECT"]
a_template = anatomy.templates
- project = io.find_one({"type": "project",
- "name": project_name},
- projection={"config": True, "data": True})
+ project = io.find_one(
+ {
+ "type": "project",
+ "name": project_name
+ },
+ projection={"config": True, "data": True}
+ )
template = a_template['publish']['path']
# anatomy = instance.context.data['anatomy']
- asset = io.find_one({"type": "asset",
- "name": asset_name,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
assert asset, ("No asset found by the name '{}' "
"in project '{}'".format(asset_name, project_name))
silo = asset.get('silo')
- subset = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset["_id"]})
+ subset = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
# assume there is no version yet, we start at `1`
version = None
version_number = 1
if subset is not None:
- version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[("name", -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
+ )
# if there is a subset there ought to be version
if version is not None:
diff --git a/pype/plugins/nukestudio/publish/validate_version.py b/pype/plugins/nukestudio/publish/validate_version.py
index 194b270d51..ebb8f357f8 100644
--- a/pype/plugins/nukestudio/publish/validate_version.py
+++ b/pype/plugins/nukestudio/publish/validate_version.py
@@ -3,6 +3,7 @@ from avalon import io
from pype.action import get_errored_instances_from_context
import pype.api as pype
+
@pyblish.api.log
class RepairNukestudioVersionUp(pyblish.api.Action):
label = "Version Up Workfile"
@@ -53,13 +54,17 @@ class ValidateVersion(pyblish.api.InstancePlugin):
io.install()
project = io.find_one({"type": "project"})
- asset = io.find_one({"type": "asset",
- "name": asset_name,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
- subset = io.find_one({"type": "subset",
- "parent": asset["_id"],
- "name": subset_name})
+ subset = io.find_one({
+ "type": "subset",
+ "parent": asset["_id"],
+ "name": subset_name
+ })
version_db = io.find_one({
'type': 'version',
diff --git a/pype/plugins/premiere/publish/integrate_assumed_destination.py b/pype/plugins/premiere/publish/integrate_assumed_destination.py
index c82b70c66f..a0393e8a43 100644
--- a/pype/plugins/premiere/publish/integrate_assumed_destination.py
+++ b/pype/plugins/premiere/publish/integrate_assumed_destination.py
@@ -77,32 +77,44 @@ class IntegrateAssumedDestination(pyblish.api.InstancePlugin):
asset_name = instance.data["asset"]
project_name = api.Session["AVALON_PROJECT"]
- project = io.find_one({"type": "project",
- "name": project_name},
- projection={"config": True, "data": True})
+ project = io.find_one(
+ {
+ "type": "project",
+ "name": project_name
+ },
+ projection={"config": True, "data": True}
+ )
template = project["config"]["template"]["publish"]
# anatomy = instance.context.data['anatomy']
- asset = io.find_one({"type": "asset",
- "name": asset_name,
- "parent": project["_id"]})
+ asset = io.find_one({
+ "type": "asset",
+ "name": asset_name,
+ "parent": project["_id"]
+ })
assert asset, ("No asset found by the name '{}' "
"in project '{}'".format(asset_name, project_name))
silo = asset.get('silo')
- subset = io.find_one({"type": "subset",
- "name": subset_name,
- "parent": asset["_id"]})
+ subset = io.find_one({
+ "type": "subset",
+ "name": subset_name,
+ "parent": asset["_id"]
+ })
# assume there is no version yet, we start at `1`
version = None
version_number = 1
if subset is not None:
- version = io.find_one({"type": "version",
- "parent": subset["_id"]},
- sort=[("name", -1)])
+ version = io.find_one(
+ {
+ "type": "version",
+ "parent": subset["_id"]
+ },
+ sort=[("name", -1)]
+ )
# if there is a subset there ought to be version
if version is not None:
diff --git a/pype/scripts/fusion_switch_shot.py b/pype/scripts/fusion_switch_shot.py
index 26a93b9b9a..539bcf4f68 100644
--- a/pype/scripts/fusion_switch_shot.py
+++ b/pype/scripts/fusion_switch_shot.py
@@ -170,8 +170,10 @@ def switch(asset_name, filepath=None, new=True):
assert asset, "Could not find '%s' in the database" % asset_name
# Get current project
- self._project = io.find_one({"type": "project",
- "name": api.Session["AVALON_PROJECT"]})
+ self._project = io.find_one({
+ "type": "project",
+ "name": api.Session["AVALON_PROJECT"]
+ })
# Go to comp
if not filepath:
diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py
index 3e8cb3b0c4..d5bc2594a4 100644
--- a/pype/scripts/otio_burnin.py
+++ b/pype/scripts/otio_burnin.py
@@ -39,6 +39,25 @@ def _streams(source):
return json.loads(out)['streams']
+def get_fps(str_value):
+ if str_value == "0/0":
+ print("Source has \"r_frame_rate\" value set to \"0/0\".")
+ return "Unknown"
+
+ items = str_value.split("/")
+ if len(items) == 1:
+ fps = float(items[0])
+
+ elif len(items) == 2:
+ fps = float(items[0]) / float(items[1])
+
+ # Check if fps is integer or float number
+ if int(fps) == fps:
+ fps = int(fps)
+
+ return str(fps)
+
+
class ModifiedBurnins(ffmpeg_burnins.Burnins):
'''
This is modification of OTIO FFmpeg Burnin adapter.
@@ -95,6 +114,7 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
streams = _streams(source)
super().__init__(source, streams)
+
if options_init:
self.options_init.update(options_init)
@@ -139,12 +159,13 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
options['frame_offset'] = start_frame
expr = r'%%{eif\:n+%d\:d}' % options['frame_offset']
+ _text = str(int(self.end_frame + options['frame_offset']))
if text and isinstance(text, str):
text = r"{}".format(text)
expr = text.replace("{current_frame}", expr)
+ text = text.replace("{current_frame}", _text)
options['expression'] = expr
- text = str(int(self.end_frame + options['frame_offset']))
self._add_burnin(text, align, options, ffmpeg_burnins.DRAWTEXT)
def add_timecode(self, align, options=None, start_frame=None):
@@ -328,6 +349,17 @@ def burnins_from_data(input_path, codec_data, output_path, data, overwrite=True)
frame_start = data.get("frame_start")
frame_start_tc = data.get('frame_start_tc', frame_start)
+
+ stream = burnin._streams[0]
+ if "resolution_width" not in data:
+ data["resolution_width"] = stream.get("width", "Unknown")
+
+ if "resolution_height" not in data:
+ data["resolution_height"] = stream.get("height", "Unknown")
+
+ if "fps" not in data:
+ data["fps"] = get_fps(stream.get("r_frame_rate", "0/0"))
+
for align_text, preset in presets.get('burnins', {}).items():
align = None
if align_text == 'TOP_LEFT':
@@ -382,12 +414,14 @@ def burnins_from_data(input_path, codec_data, output_path, data, overwrite=True)
elif bi_func == 'timecode':
burnin.add_timecode(align, start_frame=frame_start_tc)
+
elif bi_func == 'text':
if not preset.get('text'):
log.error('Text is not set for text function burnin!')
return
text = preset['text'].format(**data)
burnin.add_text(text, align)
+
elif bi_func == "datetime":
date_format = preset["format"]
burnin.add_datetime(date_format, align)
@@ -414,4 +448,4 @@ if __name__ == '__main__':
data['codec'],
data['output'],
data['burnin_data']
- )
+ )
diff --git a/pype/setdress_api.py b/pype/setdress_api.py
index 55a6b4a2fb..707a5b713f 100644
--- a/pype/setdress_api.py
+++ b/pype/setdress_api.py
@@ -462,8 +462,12 @@ def update_scene(set_container, containers, current_data, new_data, new_file):
# Check whether the conversion can be done by the Loader.
# They *must* use the same asset, subset and Loader for
# `api.update` to make sense.
- old = io.find_one({"_id": io.ObjectId(representation_current)})
- new = io.find_one({"_id": io.ObjectId(representation_new)})
+ old = io.find_one({
+ "_id": io.ObjectId(representation_current)
+ })
+ new = io.find_one({
+ "_id": io.ObjectId(representation_new)
+ })
is_valid = compare_representations(old=old, new=new)
if not is_valid:
log.error("Skipping: %s. See log for details.",
diff --git a/res/app_icons/blender.png b/res/app_icons/blender.png
new file mode 100644
index 0000000000..6070a51fae
Binary files /dev/null and b/res/app_icons/blender.png differ
diff --git a/res/ftrack/action_icons/Delivery.svg b/res/ftrack/action_icons/Delivery.svg
new file mode 100644
index 0000000000..3380487c31
--- /dev/null
+++ b/res/ftrack/action_icons/Delivery.svg
@@ -0,0 +1,34 @@
+
+
diff --git a/setup/blender/init.py b/setup/blender/init.py
new file mode 100644
index 0000000000..05c15eaeb2
--- /dev/null
+++ b/setup/blender/init.py
@@ -0,0 +1,3 @@
+from pype import blender
+
+blender.install()
diff --git a/setup/nuke/nuke_path/KnobScripter/__init__.py b/setup/nuke/nuke_path/KnobScripter/__init__.py
new file mode 100644
index 0000000000..8fe91d63f5
--- /dev/null
+++ b/setup/nuke/nuke_path/KnobScripter/__init__.py
@@ -0,0 +1 @@
+import knob_scripter
\ No newline at end of file
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_clearConsole.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_clearConsole.png
new file mode 100644
index 0000000000..75ac04ef84
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_clearConsole.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_download.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_download.png
new file mode 100644
index 0000000000..1e3e9b7631
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_download.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_exitnode.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_exitnode.png
new file mode 100644
index 0000000000..7714cd2b92
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_exitnode.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_pick.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_pick.png
new file mode 100644
index 0000000000..2395537550
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_pick.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs.png
new file mode 100644
index 0000000000..efef5ffc92
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs2.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs2.png
new file mode 100644
index 0000000000..5c3c941d59
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_prefs2.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_refresh.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_refresh.png
new file mode 100644
index 0000000000..559bfd74ab
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_refresh.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_run.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_run.png
new file mode 100644
index 0000000000..6b2e4ddc23
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_run.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_save.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_save.png
new file mode 100644
index 0000000000..e29c667f34
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_save.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_search.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_search.png
new file mode 100644
index 0000000000..d4ed2e1a2b
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_search.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/icons/icon_snippets.png b/setup/nuke/nuke_path/KnobScripter/icons/icon_snippets.png
new file mode 100644
index 0000000000..479c44f19e
Binary files /dev/null and b/setup/nuke/nuke_path/KnobScripter/icons/icon_snippets.png differ
diff --git a/setup/nuke/nuke_path/KnobScripter/knob_scripter.py b/setup/nuke/nuke_path/KnobScripter/knob_scripter.py
new file mode 100644
index 0000000000..f03067aa4b
--- /dev/null
+++ b/setup/nuke/nuke_path/KnobScripter/knob_scripter.py
@@ -0,0 +1,4196 @@
+# -------------------------------------------------
+# KnobScripter by Adrian Pueyo
+# Complete python sript editor for Nuke
+# adrianpueyo.com, 2016-2019
+import string
+import traceback
+from webbrowser import open as openUrl
+from threading import Event, Thread
+import platform
+import subprocess
+from functools import partial
+import re
+import sys
+from nukescripts import panels
+import json
+import os
+import nuke
+version = "2.3 wip"
+date = "Aug 12 2019"
+# -------------------------------------------------
+
+
+# Symlinks on windows...
+if os.name == "nt":
+ def symlink_ms(source, link_name):
+ import ctypes
+ csl = ctypes.windll.kernel32.CreateSymbolicLinkW
+ csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ csl.restype = ctypes.c_ubyte
+ flags = 1 if os.path.isdir(source) else 0
+ try:
+ if csl(link_name, source.replace('/', '\\'), flags) == 0:
+ raise ctypes.WinError()
+ except:
+ pass
+ os.symlink = symlink_ms
+
+try:
+ if nuke.NUKE_VERSION_MAJOR < 11:
+ from PySide import QtCore, QtGui, QtGui as QtWidgets
+ from PySide.QtCore import Qt
+ else:
+ from PySide2 import QtWidgets, QtGui, QtCore
+ from PySide2.QtCore import Qt
+except ImportError:
+ from Qt import QtCore, QtGui, QtWidgets
+
+KS_DIR = os.path.dirname(__file__)
+icons_path = KS_DIR + "/icons/"
+DebugMode = False
+AllKnobScripters = [] # All open instances at a given time
+
+PrefsPanel = ""
+SnippetEditPanel = ""
+
+nuke.tprint('KnobScripter v{}, built {}.\nCopyright (c) 2016-2019 Adrian Pueyo. All Rights Reserved.'.format(version, date))
+
+
+class KnobScripter(QtWidgets.QWidget):
+
+ def __init__(self, node="", knob="knobChanged"):
+ super(KnobScripter, self).__init__()
+
+ # Autosave the other knobscripters and add this one
+ for ks in AllKnobScripters:
+ try:
+ ks.autosave()
+ except:
+ pass
+ if self not in AllKnobScripters:
+ AllKnobScripters.append(self)
+
+ self.nodeMode = (node != "")
+ if node == "":
+ self.node = nuke.toNode("root")
+ else:
+ self.node = node
+
+ self.isPane = False
+ self.knob = knob
+ # For the option to also display the knob labels on the knob dropdown
+ self.show_labels = False
+ self.unsavedKnobs = {}
+ self.modifiedKnobs = set()
+ self.scrollPos = {}
+ self.cursorPos = {}
+ self.fontSize = 10
+ self.font = "Monospace"
+ self.tabSpaces = 4
+ self.windowDefaultSize = [500, 300]
+ self.color_scheme = "sublime" # Can be nuke or sublime
+ self.pinned = 1
+ self.toLoadKnob = True
+ self.frw_open = False # Find replace widget closed by default
+ self.icon_size = 17
+ self.btn_size = 24
+ self.qt_icon_size = QtCore.QSize(self.icon_size, self.icon_size)
+ self.qt_btn_size = QtCore.QSize(self.btn_size, self.btn_size)
+ self.origConsoleText = ""
+ self.nukeSE = self.findSE()
+ self.nukeSEOutput = self.findSEOutput(self.nukeSE)
+ self.nukeSEInput = self.findSEInput(self.nukeSE)
+ self.nukeSERunBtn = self.findSERunBtn(self.nukeSE)
+
+ self.scripts_dir = os.path.expandvars(
+ os.path.expanduser("~/.nuke/KnobScripter_Scripts"))
+ self.current_folder = "scripts"
+ self.folder_index = 0
+ self.current_script = "Untitled.py"
+ self.current_script_modified = False
+ self.script_index = 0
+ self.toAutosave = False
+
+ # Load prefs
+ self.prefs_txt = os.path.expandvars(
+ os.path.expanduser("~/.nuke/KnobScripter_Prefs.txt"))
+ self.loadedPrefs = self.loadPrefs()
+ if self.loadedPrefs != []:
+ try:
+ if "font_size" in self.loadedPrefs:
+ self.fontSize = self.loadedPrefs['font_size']
+ self.windowDefaultSize = [
+ self.loadedPrefs['window_default_w'], self.loadedPrefs['window_default_h']]
+ self.tabSpaces = self.loadedPrefs['tab_spaces']
+ self.pinned = self.loadedPrefs['pin_default']
+ if "font" in self.loadedPrefs:
+ self.font = self.loadedPrefs['font']
+ if "color_scheme" in self.loadedPrefs:
+ self.color_scheme = self.loadedPrefs['color_scheme']
+ if "show_labels" in self.loadedPrefs:
+ self.show_labels = self.loadedPrefs['show_labels']
+ except TypeError:
+ log("KnobScripter: Failed to load preferences.")
+
+ # Load snippets
+ self.snippets_txt_path = os.path.expandvars(
+ os.path.expanduser("~/.nuke/KnobScripter_Snippets.txt"))
+ self.snippets = self.loadSnippets(maxDepth=5)
+
+ # Current state of script (loaded when exiting node mode)
+ self.state_txt_path = os.path.expandvars(
+ os.path.expanduser("~/.nuke/KnobScripter_State.txt"))
+
+ # Init UI
+ self.initUI()
+
+ # Talk to Nuke's Script Editor
+ self.setSEOutputEvent() # Make the output windowS listen!
+ self.clearConsole()
+
+ def initUI(self):
+ ''' Initializes the tool UI'''
+ # -------------------
+ # 1. MAIN WINDOW
+ # -------------------
+ self.resize(self.windowDefaultSize[0], self.windowDefaultSize[1])
+ self.setWindowTitle("KnobScripter - %s %s" %
+ (self.node.fullName(), self.knob))
+ self.setObjectName("com.adrianpueyo.knobscripter")
+ self.move(QtGui.QCursor().pos() - QtCore.QPoint(32, 74))
+
+ # ---------------------
+ # 2. TOP BAR
+ # ---------------------
+ # ---
+ # 2.1. Left buttons
+ self.change_btn = QtWidgets.QToolButton()
+ # self.exit_node_btn.setIcon(QtGui.QIcon(KS_DIR+"/KnobScripter/icons/icons8-delete-26.png"))
+ self.change_btn.setIcon(QtGui.QIcon(icons_path + "icon_pick.png"))
+ self.change_btn.setIconSize(self.qt_icon_size)
+ self.change_btn.setFixedSize(self.qt_btn_size)
+ self.change_btn.setToolTip(
+ "Change to node if selected. Otherwise, change to Script Mode.")
+ self.change_btn.clicked.connect(self.changeClicked)
+
+ # ---
+ # 2.2.A. Node mode UI
+ self.exit_node_btn = QtWidgets.QToolButton()
+ self.exit_node_btn.setIcon(QtGui.QIcon(
+ icons_path + "icon_exitnode.png"))
+ self.exit_node_btn.setIconSize(self.qt_icon_size)
+ self.exit_node_btn.setFixedSize(self.qt_btn_size)
+ self.exit_node_btn.setToolTip(
+ "Exit the node, and change to Script Mode.")
+ self.exit_node_btn.clicked.connect(self.exitNodeMode)
+ self.current_node_label_node = QtWidgets.QLabel(" Node:")
+ self.current_node_label_name = QtWidgets.QLabel(self.node.fullName())
+ self.current_node_label_name.setStyleSheet("font-weight:bold;")
+ self.current_knob_label = QtWidgets.QLabel("Knob: ")
+ self.current_knob_dropdown = QtWidgets.QComboBox()
+ self.current_knob_dropdown.setSizeAdjustPolicy(
+ QtWidgets.QComboBox.AdjustToContents)
+ self.updateKnobDropdown()
+ self.current_knob_dropdown.currentIndexChanged.connect(
+ lambda: self.loadKnobValue(False, updateDict=True))
+
+ # Layout
+ self.node_mode_bar_layout = QtWidgets.QHBoxLayout()
+ self.node_mode_bar_layout.addWidget(self.exit_node_btn)
+ self.node_mode_bar_layout.addSpacing(2)
+ self.node_mode_bar_layout.addWidget(self.current_node_label_node)
+ self.node_mode_bar_layout.addWidget(self.current_node_label_name)
+ self.node_mode_bar_layout.addSpacing(2)
+ self.node_mode_bar_layout.addWidget(self.current_knob_dropdown)
+ self.node_mode_bar = QtWidgets.QWidget()
+ self.node_mode_bar.setLayout(self.node_mode_bar_layout)
+
+ self.node_mode_bar_layout.setContentsMargins(0, 0, 0, 0)
+
+ # ---
+ # 2.2.B. Script mode UI
+ self.script_label = QtWidgets.QLabel("Script: ")
+
+ self.current_folder_dropdown = QtWidgets.QComboBox()
+ self.current_folder_dropdown.setSizeAdjustPolicy(
+ QtWidgets.QComboBox.AdjustToContents)
+ self.current_folder_dropdown.currentIndexChanged.connect(
+ self.folderDropdownChanged)
+ # self.current_folder_dropdown.setEditable(True)
+ # self.current_folder_dropdown.lineEdit().setReadOnly(True)
+ # self.current_folder_dropdown.lineEdit().setAlignment(Qt.AlignRight)
+
+ self.current_script_dropdown = QtWidgets.QComboBox()
+ self.current_script_dropdown.setSizeAdjustPolicy(
+ QtWidgets.QComboBox.AdjustToContents)
+ self.updateFoldersDropdown()
+ self.updateScriptsDropdown()
+ self.current_script_dropdown.currentIndexChanged.connect(
+ self.scriptDropdownChanged)
+
+ # Layout
+ self.script_mode_bar_layout = QtWidgets.QHBoxLayout()
+ self.script_mode_bar_layout.addWidget(self.script_label)
+ self.script_mode_bar_layout.addSpacing(2)
+ self.script_mode_bar_layout.addWidget(self.current_folder_dropdown)
+ self.script_mode_bar_layout.addWidget(self.current_script_dropdown)
+ self.script_mode_bar = QtWidgets.QWidget()
+ self.script_mode_bar.setLayout(self.script_mode_bar_layout)
+
+ self.script_mode_bar_layout.setContentsMargins(0, 0, 0, 0)
+
+ # ---
+ # 2.3. File-system buttons
+ # Refresh dropdowns
+ self.refresh_btn = QtWidgets.QToolButton()
+ self.refresh_btn.setIcon(QtGui.QIcon(icons_path + "icon_refresh.png"))
+ self.refresh_btn.setIconSize(QtCore.QSize(50, 50))
+ self.refresh_btn.setIconSize(self.qt_icon_size)
+ self.refresh_btn.setFixedSize(self.qt_btn_size)
+ self.refresh_btn.setToolTip("Refresh the dropdowns.\nShortcut: F5")
+ self.refresh_btn.setShortcut('F5')
+ self.refresh_btn.clicked.connect(self.refreshClicked)
+
+ # Reload script
+ self.reload_btn = QtWidgets.QToolButton()
+ self.reload_btn.setIcon(QtGui.QIcon(icons_path + "icon_download.png"))
+ self.reload_btn.setIconSize(QtCore.QSize(50, 50))
+ self.reload_btn.setIconSize(self.qt_icon_size)
+ self.reload_btn.setFixedSize(self.qt_btn_size)
+ self.reload_btn.setToolTip(
+ "Reload the current script. Will overwrite any changes made to it.\nShortcut: Ctrl+R")
+ self.reload_btn.setShortcut('Ctrl+R')
+ self.reload_btn.clicked.connect(self.reloadClicked)
+
+ # Save script
+ self.save_btn = QtWidgets.QToolButton()
+ self.save_btn.setIcon(QtGui.QIcon(icons_path + "icon_save.png"))
+ self.save_btn.setIconSize(QtCore.QSize(50, 50))
+ self.save_btn.setIconSize(self.qt_icon_size)
+ self.save_btn.setFixedSize(self.qt_btn_size)
+ self.save_btn.setToolTip(
+ "Save the script into the selected knob or python file.\nShortcut: Ctrl+S")
+ self.save_btn.setShortcut('Ctrl+S')
+ self.save_btn.clicked.connect(self.saveClicked)
+
+ # Layout
+ self.top_file_bar_layout = QtWidgets.QHBoxLayout()
+ self.top_file_bar_layout.addWidget(self.refresh_btn)
+ self.top_file_bar_layout.addWidget(self.reload_btn)
+ self.top_file_bar_layout.addWidget(self.save_btn)
+
+ # ---
+ # 2.4. Right Side buttons
+
+ # Run script
+ self.run_script_button = QtWidgets.QToolButton()
+ self.run_script_button.setIcon(
+ QtGui.QIcon(icons_path + "icon_run.png"))
+ self.run_script_button.setIconSize(self.qt_icon_size)
+ # self.run_script_button.setIconSize(self.qt_icon_size)
+ self.run_script_button.setFixedSize(self.qt_btn_size)
+ self.run_script_button.setToolTip(
+ "Execute the current selection on the KnobScripter, or the whole script if no selection.\nShortcut: Ctrl+Enter")
+ self.run_script_button.clicked.connect(self.runScript)
+
+ # Clear console
+ self.clear_console_button = QtWidgets.QToolButton()
+ self.clear_console_button.setIcon(
+ QtGui.QIcon(icons_path + "icon_clearConsole.png"))
+ self.clear_console_button.setIconSize(QtCore.QSize(50, 50))
+ self.clear_console_button.setIconSize(self.qt_icon_size)
+ self.clear_console_button.setFixedSize(self.qt_btn_size)
+ self.clear_console_button.setToolTip(
+ "Clear the text in the console window.\nShortcut: Click Backspace on the console.")
+ self.clear_console_button.clicked.connect(self.clearConsole)
+
+ # FindReplace button
+ self.find_button = QtWidgets.QToolButton()
+ self.find_button.setIcon(QtGui.QIcon(icons_path + "icon_search.png"))
+ self.find_button.setIconSize(self.qt_icon_size)
+ self.find_button.setFixedSize(self.qt_btn_size)
+ self.find_button.setToolTip(
+ "Call the snippets by writing the shortcut and pressing Tab.\nShortcut: Ctrl+F")
+ self.find_button.setShortcut('Ctrl+F')
+ #self.find_button.setMaximumWidth(self.find_button.fontMetrics().boundingRect("Find").width() + 20)
+ self.find_button.setCheckable(True)
+ self.find_button.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.find_button.clicked[bool].connect(self.toggleFRW)
+ if self.frw_open:
+ self.find_button.toggle()
+
+ # Snippets
+ self.snippets_button = QtWidgets.QToolButton()
+ self.snippets_button.setIcon(
+ QtGui.QIcon(icons_path + "icon_snippets.png"))
+ self.snippets_button.setIconSize(QtCore.QSize(50, 50))
+ self.snippets_button.setIconSize(self.qt_icon_size)
+ self.snippets_button.setFixedSize(self.qt_btn_size)
+ self.snippets_button.setToolTip(
+ "Call the snippets by writing the shortcut and pressing Tab.")
+ self.snippets_button.clicked.connect(self.openSnippets)
+
+ # PIN
+ '''
+ self.pin_button = QtWidgets.QPushButton("P")
+ self.pin_button.setCheckable(True)
+ if self.pinned:
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
+ self.pin_button.toggle()
+ self.pin_button.setToolTip("Toggle 'Always On Top'. Keeps the KnobScripter on top of all other windows.")
+ self.pin_button.setFocusPolicy(QtCore.Qt.NoFocus)
+ self.pin_button.setFixedSize(self.qt_btn_size)
+ self.pin_button.clicked[bool].connect(self.pin)
+ '''
+
+ # Prefs
+ self.createPrefsMenu()
+ self.prefs_button = QtWidgets.QPushButton()
+ self.prefs_button.setIcon(QtGui.QIcon(icons_path + "icon_prefs.png"))
+ self.prefs_button.setIconSize(self.qt_icon_size)
+ self.prefs_button.setFixedSize(
+ QtCore.QSize(self.btn_size + 10, self.btn_size))
+ # self.prefs_button.clicked.connect(self.openPrefs)
+ self.prefs_button.setMenu(self.prefsMenu)
+ self.prefs_button.setStyleSheet("text-align:left;padding-left:2px;")
+ #self.prefs_button.setMaximumWidth(self.prefs_button.fontMetrics().boundingRect("Prefs").width() + 12)
+
+ # Layout
+ self.top_right_bar_layout = QtWidgets.QHBoxLayout()
+ self.top_right_bar_layout.addWidget(self.run_script_button)
+ self.top_right_bar_layout.addWidget(self.clear_console_button)
+ self.top_right_bar_layout.addWidget(self.find_button)
+ # self.top_right_bar_layout.addWidget(self.snippets_button)
+ # self.top_right_bar_layout.addWidget(self.pin_button)
+ # self.top_right_bar_layout.addSpacing(10)
+ self.top_right_bar_layout.addWidget(self.prefs_button)
+
+ # ---
+ # Layout
+ self.top_layout = QtWidgets.QHBoxLayout()
+ self.top_layout.setContentsMargins(0, 0, 0, 0)
+ # self.top_layout.setSpacing(10)
+ self.top_layout.addWidget(self.change_btn)
+ self.top_layout.addWidget(self.node_mode_bar)
+ self.top_layout.addWidget(self.script_mode_bar)
+ self.node_mode_bar.setVisible(False)
+ # self.top_layout.addSpacing(10)
+ self.top_layout.addLayout(self.top_file_bar_layout)
+ self.top_layout.addStretch()
+ self.top_layout.addLayout(self.top_right_bar_layout)
+
+ # ----------------------
+ # 3. SCRIPTING SECTION
+ # ----------------------
+ # Splitter
+ self.splitter = QtWidgets.QSplitter(Qt.Vertical)
+
+ # Output widget
+ self.script_output = ScriptOutputWidget(parent=self)
+ self.script_output.setReadOnly(1)
+ self.script_output.setAcceptRichText(0)
+ self.script_output.setTabStopWidth(
+ self.script_output.tabStopWidth() / 4)
+ self.script_output.setFocusPolicy(Qt.ClickFocus)
+ self.script_output.setAutoFillBackground(0)
+ self.script_output.installEventFilter(self)
+
+ # Script Editor
+ self.script_editor = KnobScripterTextEditMain(self, self.script_output)
+ self.script_editor.setMinimumHeight(30)
+ self.script_editor.setStyleSheet(
+ 'background:#282828;color:#EEE;') # Main Colors
+ self.script_editor.textChanged.connect(self.setModified)
+ self.highlighter = KSScriptEditorHighlighter(
+ self.script_editor.document(), self)
+ self.script_editor.cursorPositionChanged.connect(self.setTextSelection)
+ self.script_editor_font = QtGui.QFont()
+ self.script_editor_font.setFamily(self.font)
+ self.script_editor_font.setStyleHint(QtGui.QFont.Monospace)
+ self.script_editor_font.setFixedPitch(True)
+ self.script_editor_font.setPointSize(self.fontSize)
+ self.script_editor.setFont(self.script_editor_font)
+ self.script_editor.setTabStopWidth(
+ self.tabSpaces * QtGui.QFontMetrics(self.script_editor_font).width(' '))
+
+ # Add input and output to splitter
+ self.splitter.addWidget(self.script_output)
+ self.splitter.addWidget(self.script_editor)
+ self.splitter.setStretchFactor(0, 0)
+
+ # FindReplace widget
+ self.frw = FindReplaceWidget(self)
+ self.frw.setVisible(self.frw_open)
+
+ # ---
+ # Layout
+ self.scripting_layout = QtWidgets.QVBoxLayout()
+ self.scripting_layout.setContentsMargins(0, 0, 0, 0)
+ self.scripting_layout.setSpacing(0)
+ self.scripting_layout.addWidget(self.splitter)
+ self.scripting_layout.addWidget(self.frw)
+
+ # ---------------
+ # MASTER LAYOUT
+ # ---------------
+ self.master_layout = QtWidgets.QVBoxLayout()
+ self.master_layout.setSpacing(5)
+ self.master_layout.setContentsMargins(8, 8, 8, 8)
+ self.master_layout.addLayout(self.top_layout)
+ self.master_layout.addLayout(self.scripting_layout)
+ # self.master_layout.addLayout(self.bottom_layout)
+ self.setLayout(self.master_layout)
+
+ # ----------------
+ # MAIN WINDOW UI
+ # ----------------
+ size_policy = QtWidgets.QSizePolicy(
+ QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
+ self.setSizePolicy(size_policy)
+ self.setMinimumWidth(160)
+
+ if self.pinned:
+ self.setWindowFlags(self.windowFlags() |
+ QtCore.Qt.WindowStaysOnTopHint)
+
+ # Set default values based on mode
+ if self.nodeMode:
+ self.current_knob_dropdown.blockSignals(True)
+ self.node_mode_bar.setVisible(True)
+ self.script_mode_bar.setVisible(False)
+ self.setCurrentKnob(self.knob)
+ self.loadKnobValue(check=False)
+ self.setKnobModified(False)
+ self.current_knob_dropdown.blockSignals(False)
+ self.splitter.setSizes([0, 1])
+ else:
+ self.exitNodeMode()
+ self.script_editor.setFocus()
+
+ # Preferences submenus
+ def createPrefsMenu(self):
+
+ # Actions
+ self.echoAct = QtWidgets.QAction("Echo python commands", self, checkable=True,
+ statusTip="Toggle nuke's 'Echo all python commands to ScriptEditor'", triggered=self.toggleEcho)
+ if nuke.toNode("preferences").knob("echoAllCommands").value():
+ self.echoAct.toggle()
+ self.pinAct = QtWidgets.QAction("Always on top", self, checkable=True,
+ statusTip="Keeps the KnobScripter window always on top or not.", triggered=self.togglePin)
+ if self.pinned:
+ self.setWindowFlags(self.windowFlags() |
+ QtCore.Qt.WindowStaysOnTopHint)
+ self.pinAct.toggle()
+ self.helpAct = QtWidgets.QAction(
+ "&Help", self, statusTip="Open the KnobScripter help in your browser.", shortcut="F1", triggered=self.showHelp)
+ self.nukepediaAct = QtWidgets.QAction(
+ "Show in Nukepedia", self, statusTip="Open the KnobScripter download page on Nukepedia.", triggered=self.showInNukepedia)
+ self.githubAct = QtWidgets.QAction(
+ "Show in GitHub", self, statusTip="Open the KnobScripter repo on GitHub.", triggered=self.showInGithub)
+ self.snippetsAct = QtWidgets.QAction(
+ "Snippets", self, statusTip="Open the Snippets editor.", triggered=self.openSnippets)
+ self.snippetsAct.setIcon(QtGui.QIcon(icons_path + "icon_snippets.png"))
+ # self.snippetsAct = QtWidgets.QAction("Keywords", self, statusTip="Add custom keywords.", triggered=self.openSnippets) #TODO THIS
+ self.prefsAct = QtWidgets.QAction(
+ "Preferences", self, statusTip="Open the Preferences panel.", triggered=self.openPrefs)
+ self.prefsAct.setIcon(QtGui.QIcon(icons_path + "icon_prefs.png"))
+
+ # Menus
+ self.prefsMenu = QtWidgets.QMenu("Preferences")
+ self.prefsMenu.addAction(self.echoAct)
+ self.prefsMenu.addAction(self.pinAct)
+ self.prefsMenu.addSeparator()
+ self.prefsMenu.addAction(self.nukepediaAct)
+ self.prefsMenu.addAction(self.githubAct)
+ self.prefsMenu.addSeparator()
+ self.prefsMenu.addAction(self.helpAct)
+ self.prefsMenu.addSeparator()
+ self.prefsMenu.addAction(self.snippetsAct)
+ self.prefsMenu.addAction(self.prefsAct)
+
+ def initEcho(self):
+ ''' Initializes the echo chechable QAction based on nuke's state '''
+ echo_knob = nuke.toNode("preferences").knob("echoAllCommands")
+ self.echoAct.setChecked(echo_knob.value())
+
+ def toggleEcho(self):
+ ''' Toggle the "Echo python commands" from Nuke '''
+ echo_knob = nuke.toNode("preferences").knob("echoAllCommands")
+ echo_knob.setValue(self.echoAct.isChecked())
+
+ def togglePin(self):
+ ''' Toggle "always on top" based on the submenu button '''
+ self.pin(self.pinAct.isChecked())
+
+ def showInNukepedia(self):
+ openUrl("http://www.nukepedia.com/python/ui/knobscripter")
+
+ def showInGithub(self):
+ openUrl("https://github.com/adrianpueyo/KnobScripter")
+
+ def showHelp(self):
+ openUrl("https://vimeo.com/adrianpueyo/knobscripter2")
+
+ # Node Mode
+
+ def updateKnobDropdown(self):
+ ''' Populate knob dropdown list '''
+ self.current_knob_dropdown.clear() # First remove all items
+ defaultKnobs = ["knobChanged", "onCreate", "onScriptLoad", "onScriptSave", "onScriptClose", "onDestroy",
+ "updateUI", "autolabel", "beforeRender", "beforeFrameRender", "afterFrameRender", "afterRender"]
+ permittedKnobClasses = ["PyScript_Knob", "PythonCustomKnob"]
+ counter = 0
+ for i in self.node.knobs():
+ if i not in defaultKnobs and self.node.knob(i).Class() in permittedKnobClasses:
+ if self.show_labels:
+ i_full = "{} ({})".format(self.node.knob(i).label(), i)
+ else:
+ i_full = i
+
+ if i in self.unsavedKnobs.keys():
+ self.current_knob_dropdown.addItem(i_full + "(*)", i)
+ else:
+ self.current_knob_dropdown.addItem(i_full, i)
+
+ counter += 1
+ if counter > 0:
+ self.current_knob_dropdown.insertSeparator(counter)
+ counter += 1
+ self.current_knob_dropdown.insertSeparator(counter)
+ counter += 1
+ for i in self.node.knobs():
+ if i in defaultKnobs:
+ if i in self.unsavedKnobs.keys():
+ self.current_knob_dropdown.addItem(i + "(*)", i)
+ else:
+ self.current_knob_dropdown.addItem(i, i)
+ counter += 1
+ return
+
+ def loadKnobValue(self, check=True, updateDict=False):
+ ''' Get the content of the knob value and populate the editor '''
+ if self.toLoadKnob == False:
+ return
+ dropdown_value = self.current_knob_dropdown.itemData(
+ self.current_knob_dropdown.currentIndex()) # knobChanged...
+ try:
+ obtained_knobValue = str(self.node[dropdown_value].value())
+ obtained_scrollValue = 0
+ edited_knobValue = self.script_editor.toPlainText()
+ except:
+ error_message = QtWidgets.QMessageBox.information(
+ None, "", "Unable to find %s.%s" % (self.node.name(), dropdown_value))
+ error_message.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ error_message.exec_()
+ return
+ # If there were changes to the previous knob, update the dictionary
+ if updateDict == True:
+ self.unsavedKnobs[self.knob] = edited_knobValue
+ self.scrollPos[self.knob] = self.script_editor.verticalScrollBar(
+ ).value()
+ prev_knob = self.knob # knobChanged...
+
+ self.knob = self.current_knob_dropdown.itemData(
+ self.current_knob_dropdown.currentIndex()) # knobChanged...
+
+ if check and obtained_knobValue != edited_knobValue:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("The Script Editor has been modified.")
+ msgBox.setInformativeText(
+ "Do you want to overwrite the current code on this editor?")
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ self.setCurrentKnob(prev_knob)
+ return
+ # If order comes from a dropdown update, update value from dictionary if possible, otherwise update normally
+ self.setWindowTitle("KnobScripter - %s %s" %
+ (self.node.name(), self.knob))
+ if updateDict:
+ if self.knob in self.unsavedKnobs:
+ if self.unsavedKnobs[self.knob] == obtained_knobValue:
+ self.script_editor.setPlainText(obtained_knobValue)
+ self.setKnobModified(False)
+ else:
+ obtained_knobValue = self.unsavedKnobs[self.knob]
+ self.script_editor.setPlainText(obtained_knobValue)
+ self.setKnobModified(True)
+ else:
+ self.script_editor.setPlainText(obtained_knobValue)
+ self.setKnobModified(False)
+
+ if self.knob in self.scrollPos:
+ obtained_scrollValue = self.scrollPos[self.knob]
+ else:
+ self.script_editor.setPlainText(obtained_knobValue)
+
+ cursor = self.script_editor.textCursor()
+ self.script_editor.setTextCursor(cursor)
+ self.script_editor.verticalScrollBar().setValue(obtained_scrollValue)
+ return
+
+ def loadAllKnobValues(self):
+ ''' Load all knobs button's function '''
+ if len(self.unsavedKnobs) >= 1:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText(
+ "Do you want to reload all python and callback knobs?")
+ msgBox.setInformativeText(
+ "Unsaved changes on this editor will be lost.")
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return
+ self.unsavedKnobs = {}
+ return
+
+ def saveKnobValue(self, check=True):
+ ''' Save the text from the editor to the node's knobChanged knob '''
+ dropdown_value = self.current_knob_dropdown.itemData(
+ self.current_knob_dropdown.currentIndex())
+ try:
+ obtained_knobValue = str(self.node[dropdown_value].value())
+ self.knob = dropdown_value
+ except:
+ error_message = QtWidgets.QMessageBox.information(
+ None, "", "Unable to find %s.%s" % (self.node.name(), dropdown_value))
+ error_message.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ error_message.exec_()
+ return
+ edited_knobValue = self.script_editor.toPlainText()
+ if check and obtained_knobValue != edited_knobValue:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("Do you want to overwrite %s.%s?" %
+ (self.node.name(), dropdown_value))
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return
+ self.node[dropdown_value].setValue(edited_knobValue)
+ self.setKnobModified(
+ modified=False, knob=dropdown_value, changeTitle=True)
+ nuke.tcl("modified 1")
+ if self.knob in self.unsavedKnobs:
+ del self.unsavedKnobs[self.knob]
+ return
+
+ def saveAllKnobValues(self, check=True):
+ ''' Save all knobs button's function '''
+ if self.updateUnsavedKnobs() > 0 and check:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText(
+ "Do you want to save all modified python and callback knobs?")
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return
+ saveErrors = 0
+ savedCount = 0
+ for k in self.unsavedKnobs.copy():
+ try:
+ self.node.knob(k).setValue(self.unsavedKnobs[k])
+ del self.unsavedKnobs[k]
+ savedCount += 1
+ nuke.tcl("modified 1")
+ except:
+ saveErrors += 1
+ if saveErrors > 0:
+ errorBox = QtWidgets.QMessageBox()
+ errorBox.setText("Error saving %s knob%s." %
+ (str(saveErrors), int(saveErrors > 1) * "s"))
+ errorBox.setIcon(QtWidgets.QMessageBox.Warning)
+ errorBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ errorBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = errorBox.exec_()
+ else:
+ log("KnobScripter: %s knobs saved" % str(savedCount))
+ return
+
+ def setCurrentKnob(self, knobToSet):
+ ''' Set current knob '''
+ KnobDropdownItems = []
+ for i in range(self.current_knob_dropdown.count()):
+ if self.current_knob_dropdown.itemData(i) is not None:
+ KnobDropdownItems.append(
+ self.current_knob_dropdown.itemData(i))
+ else:
+ KnobDropdownItems.append("---")
+ if knobToSet in KnobDropdownItems:
+ index = KnobDropdownItems.index(knobToSet)
+ self.current_knob_dropdown.setCurrentIndex(index)
+ return
+
+ def updateUnsavedKnobs(self, first_time=False):
+ ''' Clear unchanged knobs from the dict and return the number of unsaved knobs '''
+ if not self.node:
+ # Node has been deleted, so simply return 0. Who cares.
+ return 0
+ edited_knobValue = self.script_editor.toPlainText()
+ self.unsavedKnobs[self.knob] = edited_knobValue
+ if len(self.unsavedKnobs) > 0:
+ for k in self.unsavedKnobs.copy():
+ if self.node.knob(k):
+ if str(self.node.knob(k).value()) == str(self.unsavedKnobs[k]):
+ del self.unsavedKnobs[k]
+ else:
+ del self.unsavedKnobs[k]
+ # Set appropriate knobs modified...
+ knobs_dropdown = self.current_knob_dropdown
+ all_knobs = [knobs_dropdown.itemData(i)
+ for i in range(knobs_dropdown.count())]
+ for key in all_knobs:
+ if key in self.unsavedKnobs.keys():
+ self.setKnobModified(
+ modified=True, knob=key, changeTitle=False)
+ else:
+ self.setKnobModified(
+ modified=False, knob=key, changeTitle=False)
+
+ return len(self.unsavedKnobs)
+
+ def setKnobModified(self, modified=True, knob="", changeTitle=True):
+ ''' Sets the current knob modified, title and whatever else we need '''
+ if knob == "":
+ knob = self.knob
+ if modified:
+ self.modifiedKnobs.add(knob)
+ else:
+ self.modifiedKnobs.discard(knob)
+
+ if changeTitle:
+ title_modified_string = " [modified]"
+ windowTitle = self.windowTitle().split(title_modified_string)[0]
+ if modified == True:
+ windowTitle += title_modified_string
+ self.setWindowTitle(windowTitle)
+
+ try:
+ knobs_dropdown = self.current_knob_dropdown
+ kd_index = knobs_dropdown.currentIndex()
+ kd_data = knobs_dropdown.itemData(kd_index)
+ if self.show_labels and i not in defaultKnobs:
+ kd_data = "{} ({})".format(
+ self.node.knob(kd_data).label(), kd_data)
+ if modified == False:
+ knobs_dropdown.setItemText(kd_index, kd_data)
+ else:
+ knobs_dropdown.setItemText(kd_index, kd_data + "(*)")
+ except:
+ pass
+
+ # Script Mode
+ def updateFoldersDropdown(self):
+ ''' Populate folders dropdown list '''
+ self.current_folder_dropdown.blockSignals(True)
+ self.current_folder_dropdown.clear() # First remove all items
+ defaultFolders = ["scripts"]
+ scriptFolders = []
+ counter = 0
+ for f in defaultFolders:
+ self.makeScriptFolder(f)
+ self.current_folder_dropdown.addItem(f + "/", f)
+ counter += 1
+
+ try:
+ scriptFolders = sorted([f for f in os.listdir(self.scripts_dir) if os.path.isdir(
+ os.path.join(self.scripts_dir, f))]) # Accepts symlinks!!!
+ except:
+ log("Couldn't read any script folders.")
+
+ for f in scriptFolders:
+ fname = f.split("/")[-1]
+ if fname in defaultFolders:
+ continue
+ self.current_folder_dropdown.addItem(fname + "/", fname)
+ counter += 1
+
+ # print scriptFolders
+ if counter > 0:
+ self.current_folder_dropdown.insertSeparator(counter)
+ counter += 1
+ # self.current_folder_dropdown.insertSeparator(counter)
+ #counter += 1
+ self.current_folder_dropdown.addItem("New", "create new")
+ self.current_folder_dropdown.addItem("Open...", "open in browser")
+ self.current_folder_dropdown.addItem("Add custom", "add custom path")
+ self.folder_index = self.current_folder_dropdown.currentIndex()
+ self.current_folder = self.current_folder_dropdown.itemData(
+ self.folder_index)
+ self.current_folder_dropdown.blockSignals(False)
+ return
+
+ def updateScriptsDropdown(self):
+ ''' Populate py scripts dropdown list '''
+ self.current_script_dropdown.blockSignals(True)
+ self.current_script_dropdown.clear() # First remove all items
+ QtWidgets.QApplication.processEvents()
+ log("# Updating scripts dropdown...")
+ log("scripts dir:" + self.scripts_dir)
+ log("current folder:" + self.current_folder)
+ log("previous current script:" + self.current_script)
+ #current_folder = self.current_folder_dropdown.itemData(self.current_folder_dropdown.currentIndex())
+ current_folder_path = os.path.join(
+ self.scripts_dir, self.current_folder)
+ defaultScripts = ["Untitled.py"]
+ found_scripts = []
+ counter = 0
+ # All files and folders inside of the folder
+ dir_list = os.listdir(current_folder_path)
+ try:
+ found_scripts = sorted([f for f in dir_list if f.endswith(".py")])
+ found_temp_scripts = [
+ f for f in dir_list if f.endswith(".py.autosave")]
+ except:
+ log("Couldn't find any scripts in the selected folder.")
+ if not len(found_scripts):
+ for s in defaultScripts:
+ if s + ".autosave" in found_temp_scripts:
+ self.current_script_dropdown.addItem(s + "(*)", s)
+ else:
+ self.current_script_dropdown.addItem(s, s)
+ counter += 1
+ else:
+ for s in defaultScripts:
+ if s + ".autosave" in found_temp_scripts:
+ self.current_script_dropdown.addItem(s + "(*)", s)
+ elif s in found_scripts:
+ self.current_script_dropdown.addItem(s, s)
+ for s in found_scripts:
+ if s in defaultScripts:
+ continue
+ sname = s.split("/")[-1]
+ if s + ".autosave" in found_temp_scripts:
+ self.current_script_dropdown.addItem(sname + "(*)", sname)
+ else:
+ self.current_script_dropdown.addItem(sname, sname)
+ counter += 1
+ # else: #Add the found scripts to the dropdown
+ if counter > 0:
+ counter += 1
+ self.current_script_dropdown.insertSeparator(counter)
+ counter += 1
+ self.current_script_dropdown.insertSeparator(counter)
+ self.current_script_dropdown.addItem("New", "create new")
+ self.current_script_dropdown.addItem("Duplicate", "create duplicate")
+ self.current_script_dropdown.addItem("Delete", "delete script")
+ self.current_script_dropdown.addItem("Open", "open in browser")
+ #self.script_index = self.current_script_dropdown.currentIndex()
+ self.script_index = 0
+ self.current_script = self.current_script_dropdown.itemData(
+ self.script_index)
+ log("Finished updating scripts dropdown.")
+ log("current_script:" + self.current_script)
+ self.current_script_dropdown.blockSignals(False)
+ return
+
+ def makeScriptFolder(self, name="scripts"):
+ folder_path = os.path.join(self.scripts_dir, name)
+ if not os.path.exists(folder_path):
+ try:
+ os.makedirs(folder_path)
+ return True
+ except:
+ print "Couldn't create the scripting folders.\nPlease check your OS write permissions."
+ return False
+
+ def makeScriptFile(self, name="Untitled.py", folder="scripts", empty=True):
+ script_path = os.path.join(self.scripts_dir, self.current_folder, name)
+ if not os.path.isfile(script_path):
+ try:
+ self.current_script_file = open(script_path, 'w')
+ return True
+ except:
+ print "Couldn't create the scripting folders.\nPlease check your OS write permissions."
+ return False
+
+ def setCurrentFolder(self, folderName):
+ ''' Set current folder ON THE DROPDOWN ONLY'''
+ folderList = [self.current_folder_dropdown.itemData(
+ i) for i in range(self.current_folder_dropdown.count())]
+ if folderName in folderList:
+ index = folderList.index(folderName)
+ self.current_folder_dropdown.setCurrentIndex(index)
+ self.current_folder = folderName
+ self.folder_index = self.current_folder_dropdown.currentIndex()
+ self.current_folder = self.current_folder_dropdown.itemData(
+ self.folder_index)
+ return
+
+ def setCurrentScript(self, scriptName):
+ ''' Set current script ON THE DROPDOWN ONLY '''
+ scriptList = [self.current_script_dropdown.itemData(
+ i) for i in range(self.current_script_dropdown.count())]
+ if scriptName in scriptList:
+ index = scriptList.index(scriptName)
+ self.current_script_dropdown.setCurrentIndex(index)
+ self.current_script = scriptName
+ self.script_index = self.current_script_dropdown.currentIndex()
+ self.current_script = self.current_script_dropdown.itemData(
+ self.script_index)
+ return
+
+ def loadScriptContents(self, check=False, pyOnly=False, folder=""):
+ ''' Get the contents of the selected script and populate the editor '''
+ log("# About to load script contents now.")
+ obtained_scrollValue = 0
+ obtained_cursorPosValue = [0, 0] # Position, anchor
+ if folder == "":
+ folder = self.current_folder
+ script_path = os.path.join(
+ self.scripts_dir, folder, self.current_script)
+ script_path_temp = script_path + ".autosave"
+ if (self.current_folder + "/" + self.current_script) in self.scrollPos:
+ obtained_scrollValue = self.scrollPos[self.current_folder +
+ "/" + self.current_script]
+ if (self.current_folder + "/" + self.current_script) in self.cursorPos:
+ obtained_cursorPosValue = self.cursorPos[self.current_folder +
+ "/" + self.current_script]
+
+ # 1: If autosave exists and pyOnly is false, load it
+ if os.path.isfile(script_path_temp) and not pyOnly:
+ log("Loading .py.autosave file\n---")
+ with open(script_path_temp, 'r') as script:
+ content = script.read()
+ self.script_editor.setPlainText(content)
+ self.setScriptModified(True)
+ self.script_editor.verticalScrollBar().setValue(obtained_scrollValue)
+
+ # 2: Try to load the .py as first priority, if it exists
+ elif os.path.isfile(script_path):
+ log("Loading .py file\n---")
+ with open(script_path, 'r') as script:
+ content = script.read()
+ current_text = self.script_editor.toPlainText().encode("utf8")
+ if check and current_text != content and current_text.strip() != "":
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("The script has been modified.")
+ msgBox.setInformativeText(
+ "Do you want to overwrite the current code on this editor?")
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return
+ # Clear trash
+ if os.path.isfile(script_path_temp):
+ os.remove(script_path_temp)
+ log("Removed " + script_path_temp)
+ self.setScriptModified(False)
+ self.script_editor.setPlainText(content)
+ self.script_editor.verticalScrollBar().setValue(obtained_scrollValue)
+ self.setScriptModified(False)
+ self.loadScriptState()
+ self.setScriptState()
+
+ # 3: If .py doesn't exist... only then stick to the autosave
+ elif os.path.isfile(script_path_temp):
+ with open(script_path_temp, 'r') as script:
+ content = script.read()
+
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("The .py file hasn't been found.")
+ msgBox.setInformativeText(
+ "Do you want to clear the current code on this editor?")
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return
+
+ # Clear trash
+ os.remove(script_path_temp)
+ log("Removed " + script_path_temp)
+ self.script_editor.setPlainText("")
+ self.updateScriptsDropdown()
+ self.loadScriptContents(check=False)
+ self.loadScriptState()
+ self.setScriptState()
+
+ else:
+ content = ""
+ self.script_editor.setPlainText(content)
+ self.setScriptModified(False)
+ if self.current_folder + "/" + self.current_script in self.scrollPos:
+ del self.scrollPos[self.current_folder +
+ "/" + self.current_script]
+ if self.current_folder + "/" + self.current_script in self.cursorPos:
+ del self.cursorPos[self.current_folder +
+ "/" + self.current_script]
+
+ self.setWindowTitle("KnobScripter - %s/%s" %
+ (self.current_folder, self.current_script))
+ return
+
+ def saveScriptContents(self, temp=True):
+ ''' Save the current contents of the editor into the python file. If temp == True, saves a .py.autosave file '''
+ log("\n# About to save script contents now.")
+ log("Temp mode is: " + str(temp))
+ log("self.current_folder: " + self.current_folder)
+ log("self.current_script: " + self.current_script)
+ script_path = os.path.join(
+ self.scripts_dir, self.current_folder, self.current_script)
+ script_path_temp = script_path + ".autosave"
+ orig_content = ""
+ content = self.script_editor.toPlainText().encode('utf8')
+
+ if temp == True:
+ if os.path.isfile(script_path):
+ with open(script_path, 'r') as script:
+ orig_content = script.read()
+ # If script path doesn't exist and autosave does but the script is empty...
+ elif content == "" and os.path.isfile(script_path_temp):
+ os.remove(script_path_temp)
+ return
+ if content != orig_content:
+ with open(script_path_temp, 'w') as script:
+ script.write(content)
+ else:
+ if os.path.isfile(script_path_temp):
+ os.remove(script_path_temp)
+ log("Nothing to save")
+ return
+ else:
+ with open(script_path, 'w') as script:
+ script.write(self.script_editor.toPlainText().encode('utf8'))
+ # Clear trash
+ if os.path.isfile(script_path_temp):
+ os.remove(script_path_temp)
+ log("Removed " + script_path_temp)
+ self.setScriptModified(False)
+ self.saveScrollValue()
+ self.saveCursorPosValue()
+ log("Saved " + script_path + "\n---")
+ return
+
+ def deleteScript(self, check=True, folder=""):
+ ''' Get the contents of the selected script and populate the editor '''
+ log("# About to delete the .py and/or autosave script now.")
+ if folder == "":
+ folder = self.current_folder
+ script_path = os.path.join(
+ self.scripts_dir, folder, self.current_script)
+ script_path_temp = script_path + ".autosave"
+ if check:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("You're about to delete this script.")
+ msgBox.setInformativeText(
+ "Are you sure you want to delete {}?".format(self.current_script))
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ msgBox.setIcon(QtWidgets.QMessageBox.Question)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.No)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.No:
+ return False
+
+ if os.path.isfile(script_path_temp):
+ os.remove(script_path_temp)
+ log("Removed " + script_path_temp)
+
+ if os.path.isfile(script_path):
+ os.remove(script_path)
+ log("Removed " + script_path)
+
+ return True
+
+ def folderDropdownChanged(self):
+ '''Executed when the current folder dropdown is changed'''
+ self.saveScriptState()
+ log("# folder dropdown changed")
+ folders_dropdown = self.current_folder_dropdown
+ fd_value = folders_dropdown.currentText()
+ fd_index = folders_dropdown.currentIndex()
+ fd_data = folders_dropdown.itemData(fd_index)
+ if fd_data == "create new":
+ panel = FileNameDialog(self, mode="folder")
+ # panel.setWidth(260)
+ # panel.addSingleLineInput("Name:","")
+ if panel.exec_():
+ # Accepted
+ folder_name = panel.text
+ if os.path.isdir(os.path.join(self.scripts_dir, folder_name)):
+ self.messageBox("Folder already exists.")
+ self.setCurrentFolder(self.current_folder)
+ if self.makeScriptFolder(name=folder_name):
+ self.saveScriptContents(temp=True)
+ # Success creating the folder
+ self.current_folder = folder_name
+ self.updateFoldersDropdown()
+ self.setCurrentFolder(folder_name)
+ self.updateScriptsDropdown()
+ self.loadScriptContents(check=False)
+ else:
+ self.messageBox("There was a problem creating the folder.")
+ self.current_folder_dropdown.blockSignals(True)
+ self.current_folder_dropdown.setCurrentIndex(
+ self.folder_index)
+ self.current_folder_dropdown.blockSignals(False)
+ else:
+ # Canceled/rejected
+ self.current_folder_dropdown.blockSignals(True)
+ self.current_folder_dropdown.setCurrentIndex(self.folder_index)
+ self.current_folder_dropdown.blockSignals(False)
+ return
+
+ elif fd_data == "open in browser":
+ current_folder_path = os.path.join(
+ self.scripts_dir, self.current_folder)
+ self.openInFileBrowser(current_folder_path)
+ self.current_folder_dropdown.blockSignals(True)
+ self.current_folder_dropdown.setCurrentIndex(self.folder_index)
+ self.current_folder_dropdown.blockSignals(False)
+ return
+
+ elif fd_data == "add custom path":
+ folder_path = nuke.getFilename('Select custom folder.')
+ if folder_path is not None:
+ if folder_path.endswith("/"):
+ aliasName = folder_path.split("/")[-2]
+ else:
+ aliasName = folder_path.split("/")[-1]
+ if not os.path.isdir(folder_path):
+ self.messageBox(
+ "Folder not found. Please try again with the full path to a folder.")
+ elif not len(aliasName):
+ self.messageBox(
+ "Folder with the same name already exists. Please delete or rename it first.")
+ else:
+ # All good
+ os.symlink(folder_path, os.path.join(
+ self.scripts_dir, aliasName))
+ self.saveScriptContents(temp=True)
+ self.current_folder = aliasName
+ self.updateFoldersDropdown()
+ self.setCurrentFolder(aliasName)
+ self.updateScriptsDropdown()
+ self.loadScriptContents(check=False)
+ self.script_editor.setFocus()
+ return
+ self.current_folder_dropdown.blockSignals(True)
+ self.current_folder_dropdown.setCurrentIndex(self.folder_index)
+ self.current_folder_dropdown.blockSignals(False)
+ else:
+ # 1: Save current script as temp if needed
+ self.saveScriptContents(temp=True)
+ # 2: Set the new folder in the variables
+ self.current_folder = fd_data
+ self.folder_index = fd_index
+ # 3: Update the scripts dropdown
+ self.updateScriptsDropdown()
+ # 4: Load the current script!
+ self.loadScriptContents()
+ self.script_editor.setFocus()
+
+ self.loadScriptState()
+ self.setScriptState()
+
+ return
+
+ def scriptDropdownChanged(self):
+ '''Executed when the current script dropdown is changed. Should only be called by the manual dropdown change. Not by other functions.'''
+ self.saveScriptState()
+ scripts_dropdown = self.current_script_dropdown
+ sd_value = scripts_dropdown.currentText()
+ sd_index = scripts_dropdown.currentIndex()
+ sd_data = scripts_dropdown.itemData(sd_index)
+ if sd_data == "create new":
+ self.current_script_dropdown.blockSignals(True)
+ panel = FileNameDialog(self, mode="script")
+ if panel.exec_():
+ # Accepted
+ script_name = panel.text + ".py"
+ script_path = os.path.join(
+ self.scripts_dir, self.current_folder, script_name)
+ log(script_name)
+ log(script_path)
+ if os.path.isfile(script_path):
+ self.messageBox("Script already exists.")
+ self.current_script_dropdown.setCurrentIndex(
+ self.script_index)
+ if self.makeScriptFile(name=script_name, folder=self.current_folder):
+ # Success creating the folder
+ self.saveScriptContents(temp=True)
+ self.updateScriptsDropdown()
+ if self.current_script != "Untitled.py":
+ self.script_editor.setPlainText("")
+ self.current_script = script_name
+ self.setCurrentScript(script_name)
+ self.saveScriptContents(temp=False)
+ # self.loadScriptContents()
+ else:
+ self.messageBox("There was a problem creating the script.")
+ self.current_script_dropdown.setCurrentIndex(
+ self.script_index)
+ else:
+ # Canceled/rejected
+ self.current_script_dropdown.setCurrentIndex(self.script_index)
+ return
+ self.current_script_dropdown.blockSignals(False)
+
+ elif sd_data == "create duplicate":
+ self.current_script_dropdown.blockSignals(True)
+ current_folder_path = os.path.join(
+ self.scripts_dir, self.current_folder, self.current_script)
+ current_script_path = os.path.join(
+ self.scripts_dir, self.current_folder, self.current_script)
+
+ current_name = self.current_script
+ if self.current_script.endswith(".py"):
+ current_name = current_name[:-3]
+
+ test_name = current_name
+ while True:
+ test_name += "_copy"
+ new_script_path = os.path.join(
+ self.scripts_dir, self.current_folder, test_name + ".py")
+ if not os.path.isfile(new_script_path):
+ break
+
+ script_name = test_name + ".py"
+
+ if self.makeScriptFile(name=script_name, folder=self.current_folder):
+ # Success creating the folder
+ self.saveScriptContents(temp=True)
+ self.updateScriptsDropdown()
+ # self.script_editor.setPlainText("")
+ self.current_script = script_name
+ self.setCurrentScript(script_name)
+ self.script_editor.setFocus()
+ else:
+ self.messageBox("There was a problem duplicating the script.")
+ self.current_script_dropdown.setCurrentIndex(self.script_index)
+
+ self.current_script_dropdown.blockSignals(False)
+
+ elif sd_data == "open in browser":
+ current_script_path = os.path.join(
+ self.scripts_dir, self.current_folder, self.current_script)
+ self.openInFileBrowser(current_script_path)
+ self.current_script_dropdown.blockSignals(True)
+ self.current_script_dropdown.setCurrentIndex(self.script_index)
+ self.current_script_dropdown.blockSignals(False)
+ return
+
+ elif sd_data == "delete script":
+ if self.deleteScript():
+ self.updateScriptsDropdown()
+ self.loadScriptContents()
+ else:
+ self.current_script_dropdown.blockSignals(True)
+ self.current_script_dropdown.setCurrentIndex(self.script_index)
+ self.current_script_dropdown.blockSignals(False)
+
+ else:
+ self.saveScriptContents()
+ self.current_script = sd_data
+ self.script_index = sd_index
+ self.setCurrentScript(self.current_script)
+ self.loadScriptContents()
+ self.script_editor.setFocus()
+ self.loadScriptState()
+ self.setScriptState()
+ return
+
+ def setScriptModified(self, modified=True):
+ ''' Sets self.current_script_modified, title and whatever else we need '''
+ self.current_script_modified = modified
+ title_modified_string = " [modified]"
+ windowTitle = self.windowTitle().split(title_modified_string)[0]
+ if modified == True:
+ windowTitle += title_modified_string
+ self.setWindowTitle(windowTitle)
+ try:
+ scripts_dropdown = self.current_script_dropdown
+ sd_index = scripts_dropdown.currentIndex()
+ sd_data = scripts_dropdown.itemData(sd_index)
+ if modified == False:
+ scripts_dropdown.setItemText(sd_index, sd_data)
+ else:
+ scripts_dropdown.setItemText(sd_index, sd_data + "(*)")
+ except:
+ pass
+
+ def openInFileBrowser(self, path=""):
+ OS = platform.system()
+ if not os.path.exists(path):
+ path = KS_DIR
+ if OS == "Windows":
+ os.startfile(path)
+ elif OS == "Darwin":
+ subprocess.Popen(["open", path])
+ else:
+ subprocess.Popen(["xdg-open", path])
+
+ def loadScriptState(self):
+ '''
+ Loads the last state of the script from a file inside the SE directory's root.
+ SAVES self.scroll_pos, self.cursor_pos, self.last_open_script
+ '''
+ self.state_dict = {}
+ if not os.path.isfile(self.state_txt_path):
+ return False
+ else:
+ with open(self.state_txt_path, "r") as f:
+ self.state_dict = json.load(f)
+
+ log("Loading script state into self.state_dict, self.scrollPos, self.cursorPos")
+ log(self.state_dict)
+
+ if "scroll_pos" in self.state_dict:
+ self.scrollPos = self.state_dict["scroll_pos"]
+ if "cursor_pos" in self.state_dict:
+ self.cursorPos = self.state_dict["cursor_pos"]
+
+ def setScriptState(self):
+ '''
+ Sets the already script state from self.state_dict into the current script if applicable
+ '''
+ script_fullname = self.current_folder + "/" + self.current_script
+
+ if "scroll_pos" in self.state_dict:
+ if script_fullname in self.state_dict["scroll_pos"]:
+ self.script_editor.verticalScrollBar().setValue(
+ int(self.state_dict["scroll_pos"][script_fullname]))
+
+ if "cursor_pos" in self.state_dict:
+ if script_fullname in self.state_dict["cursor_pos"]:
+ cursor = self.script_editor.textCursor()
+ cursor.setPosition(int(
+ self.state_dict["cursor_pos"][script_fullname][1]), QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(int(
+ self.state_dict["cursor_pos"][script_fullname][0]), QtGui.QTextCursor.KeepAnchor)
+ self.script_editor.setTextCursor(cursor)
+
+ if 'splitter_sizes' in self.state_dict:
+ self.splitter.setSizes(self.state_dict['splitter_sizes'])
+
+ def setLastScript(self):
+ if 'last_folder' in self.state_dict and 'last_script' in self.state_dict:
+ self.updateFoldersDropdown()
+ self.setCurrentFolder(self.state_dict['last_folder'])
+ self.updateScriptsDropdown()
+ self.setCurrentScript(self.state_dict['last_script'])
+ self.loadScriptContents()
+ self.script_editor.setFocus()
+
+ def saveScriptState(self):
+ ''' Stores the current state of the script into a file inside the SE directory's root '''
+ log("About to save script state...")
+ '''
+ # self.state_dict = {}
+ if os.path.isfile(self.state_txt_path):
+ with open(self.state_txt_path, "r") as f:
+ self.state_dict = json.load(f)
+
+ if "scroll_pos" in self.state_dict:
+ self.scrollPos = self.state_dict["scroll_pos"]
+ if "cursor_pos" in self.state_dict:
+ self.cursorPos = self.state_dict["cursor_pos"]
+
+ '''
+ self.loadScriptState()
+
+ # Overwrite current values into the scriptState
+ self.saveScrollValue()
+ self.saveCursorPosValue()
+
+ self.state_dict['scroll_pos'] = self.scrollPos
+ self.state_dict['cursor_pos'] = self.cursorPos
+ self.state_dict['last_folder'] = self.current_folder
+ self.state_dict['last_script'] = self.current_script
+ self.state_dict['splitter_sizes'] = self.splitter.sizes()
+
+ with open(self.state_txt_path, "w") as f:
+ state = json.dump(self.state_dict, f, sort_keys=True, indent=4)
+ return state
+
+ # Autosave background loop
+ def autosave(self):
+ if self.toAutosave:
+ # Save the script...
+ self.saveScriptContents()
+ self.toAutosave = False
+ self.saveScriptState()
+ log("autosaving...")
+ return
+
+ # Global stuff
+ def setTextSelection(self):
+ self.highlighter.selected_text = self.script_editor.textCursor().selection().toPlainText()
+ return
+
+ def eventFilter(self, object, event):
+ if event.type() == QtCore.QEvent.KeyPress:
+ return QtWidgets.QWidget.eventFilter(self, object, event)
+ else:
+ return QtWidgets.QWidget.eventFilter(self, object, event)
+
+ def resizeEvent(self, res_event):
+ w = self.frameGeometry().width()
+ self.current_node_label_node.setVisible(w > 460)
+ self.script_label.setVisible(w > 460)
+ return super(KnobScripter, self).resizeEvent(res_event)
+
+ def changeClicked(self, newNode=""):
+ ''' Change node '''
+ try:
+ print "Changing from " + self.node.name()
+ except:
+ self.node = None
+ if not len(nuke.selectedNodes()):
+ self.exitNodeMode()
+ return
+ nuke.menu("Nuke").findItem(
+ "Edit/Node/Update KnobScripter Context").invoke()
+ selection = knobScripterSelectedNodes
+ if self.nodeMode: # Only update the number of unsaved knobs if we were already in node mode
+ if self.node is not None:
+ updatedCount = self.updateUnsavedKnobs()
+ else:
+ updatedCount = 0
+ else:
+ updatedCount = 0
+ self.autosave()
+ if newNode != "" and nuke.exists(newNode):
+ selection = [newNode]
+ elif not len(selection):
+ node_dialog = ChooseNodeDialog(self)
+ if node_dialog.exec_():
+ # Accepted
+ selection = [nuke.toNode(node_dialog.name)]
+ else:
+ return
+
+ # Change to node mode...
+ self.node_mode_bar.setVisible(True)
+ self.script_mode_bar.setVisible(False)
+ if not self.nodeMode:
+ self.saveScriptContents()
+ self.toAutosave = False
+ self.saveScriptState()
+ self.splitter.setSizes([0, 1])
+ self.nodeMode = True
+
+ # If already selected, pass
+ if self.node is not None and selection[0].fullName() == self.node.fullName():
+ self.messageBox("Please select a different node first!")
+ return
+ elif updatedCount > 0:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText(
+ "Save changes to %s knob%s before changing the node?" % (str(updatedCount), int(updatedCount > 1) * "s"))
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.Yes:
+ self.saveAllKnobValues(check=False)
+ elif reply == QtWidgets.QMessageBox.Cancel:
+ return
+ if len(selection) > 1:
+ self.messageBox(
+ "More than one node selected.\nChanging knobChanged editor to %s" % selection[0].fullName())
+ # Reinitialise everything, wooo!
+ self.current_knob_dropdown.blockSignals(True)
+ self.node = selection[0]
+
+ self.script_editor.setPlainText("")
+ self.unsavedKnobs = {}
+ self.scrollPos = {}
+ self.setWindowTitle("KnobScripter - %s %s" %
+ (self.node.fullName(), self.knob))
+ self.current_node_label_name.setText(self.node.fullName())
+
+ self.toLoadKnob = False
+ self.updateKnobDropdown() # onee
+ # self.current_knob_dropdown.repaint()
+ # self.current_knob_dropdown.setMinimumWidth(self.current_knob_dropdown.minimumSizeHint().width())
+ self.toLoadKnob = True
+ self.setCurrentKnob(self.knob)
+ self.loadKnobValue(False)
+ self.script_editor.setFocus()
+ self.setKnobModified(False)
+ self.current_knob_dropdown.blockSignals(False)
+ # self.current_knob_dropdown.setMinimumContentsLength(80)
+ return
+
+ def exitNodeMode(self):
+ self.nodeMode = False
+ self.setWindowTitle("KnobScripter - Script Mode")
+ self.node_mode_bar.setVisible(False)
+ self.script_mode_bar.setVisible(True)
+ self.node = nuke.toNode("root")
+ # self.updateFoldersDropdown()
+ # self.updateScriptsDropdown()
+ self.splitter.setSizes([1, 1])
+ self.loadScriptState()
+ self.setLastScript()
+
+ self.loadScriptContents(check=False)
+ self.setScriptState()
+
+ def clearConsole(self):
+ self.origConsoleText = self.nukeSEOutput.document().toPlainText().encode("utf8")
+ self.script_output.setPlainText("")
+
+ def toggleFRW(self, frw_pressed):
+ self.frw_open = frw_pressed
+ self.frw.setVisible(self.frw_open)
+ if self.frw_open:
+ self.frw.find_lineEdit.setFocus()
+ self.frw.find_lineEdit.selectAll()
+ else:
+ self.script_editor.setFocus()
+ return
+
+ def openSnippets(self):
+ ''' Whenever the 'snippets' button is pressed... open the panel '''
+ global SnippetEditPanel
+ if SnippetEditPanel == "":
+ SnippetEditPanel = SnippetsPanel(self)
+
+ if not SnippetEditPanel.isVisible():
+ SnippetEditPanel.reload()
+
+ if SnippetEditPanel.show():
+ self.snippets = self.loadSnippets(maxDepth=5)
+ SnippetEditPanel = ""
+
+ def loadSnippets(self, path="", maxDepth=5, depth=0):
+ '''
+ Load prefs recursive. When maximum recursion depth, ignores paths.
+ '''
+ max_depth = maxDepth
+ cur_depth = depth
+ if path == "":
+ path = self.snippets_txt_path
+ if not os.path.isfile(path):
+ return {}
+ else:
+ loaded_snippets = {}
+ with open(path, "r") as f:
+ file = json.load(f)
+ for i, (key, val) in enumerate(file.items()):
+ if re.match(r"\[custom-path-[0-9]+\]$", key):
+ if cur_depth < max_depth:
+ new_dict = self.loadSnippets(
+ path=val, maxDepth=max_depth, depth=cur_depth + 1)
+ loaded_snippets.update(new_dict)
+ else:
+ loaded_snippets[key] = val
+ return loaded_snippets
+
+ def messageBox(self, the_text=""):
+ ''' Just a simple message box '''
+ if self.isPane:
+ msgBox = QtWidgets.QMessageBox()
+ else:
+ msgBox = QtWidgets.QMessageBox(self)
+ msgBox.setText(the_text)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.exec_()
+
+ def openPrefs(self):
+ ''' Open the preferences panel '''
+ global PrefsPanel
+ if PrefsPanel == "":
+ PrefsPanel = KnobScripterPrefs(self)
+
+ if PrefsPanel.show():
+ PrefsPanel = ""
+
+ def loadPrefs(self):
+ ''' Load prefs '''
+ if not os.path.isfile(self.prefs_txt):
+ return []
+ else:
+ with open(self.prefs_txt, "r") as f:
+ prefs = json.load(f)
+ return prefs
+
+ def runScript(self):
+ ''' Run the current script... '''
+ self.script_editor.runScript()
+
+ def saveScrollValue(self):
+ ''' Save scroll values '''
+ if self.nodeMode:
+ self.scrollPos[self.knob] = self.script_editor.verticalScrollBar(
+ ).value()
+ else:
+ self.scrollPos[self.current_folder + "/" +
+ self.current_script] = self.script_editor.verticalScrollBar().value()
+
+ def saveCursorPosValue(self):
+ ''' Save cursor pos and anchor values '''
+ self.cursorPos[self.current_folder + "/" + self.current_script] = [
+ self.script_editor.textCursor().position(), self.script_editor.textCursor().anchor()]
+
+ def closeEvent(self, close_event):
+ if self.nodeMode:
+ updatedCount = self.updateUnsavedKnobs()
+ if updatedCount > 0:
+ msgBox = QtWidgets.QMessageBox()
+ msgBox.setText("Save changes to %s knob%s before closing?" % (
+ str(updatedCount), int(updatedCount > 1) * "s"))
+ msgBox.setStandardButtons(
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)
+ msgBox.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
+ msgBox.setDefaultButton(QtWidgets.QMessageBox.Yes)
+ reply = msgBox.exec_()
+ if reply == QtWidgets.QMessageBox.Yes:
+ self.saveAllKnobValues(check=False)
+ close_event.accept()
+ return
+ elif reply == QtWidgets.QMessageBox.Cancel:
+ close_event.ignore()
+ return
+ else:
+ close_event.accept()
+ else:
+ self.autosave()
+ if self in AllKnobScripters:
+ AllKnobScripters.remove(self)
+ close_event.accept()
+
+ # Landing functions
+
+ def refreshClicked(self):
+ ''' Function to refresh the dropdowns '''
+ if self.nodeMode:
+ knob = self.current_knob_dropdown.itemData(
+ self.current_knob_dropdown.currentIndex()).encode('UTF8')
+ self.current_knob_dropdown.blockSignals(True)
+ self.current_knob_dropdown.clear() # First remove all items
+ self.updateKnobDropdown()
+ availableKnobs = []
+ for i in range(self.current_knob_dropdown.count()):
+ if self.current_knob_dropdown.itemData(i) is not None:
+ availableKnobs.append(
+ self.current_knob_dropdown.itemData(i).encode('UTF8'))
+ if knob in availableKnobs:
+ self.setCurrentKnob(knob)
+ self.current_knob_dropdown.blockSignals(False)
+ else:
+ folder = self.current_folder
+ script = self.current_script
+ self.autosave()
+ self.updateFoldersDropdown()
+ self.setCurrentFolder(folder)
+ self.updateScriptsDropdown()
+ self.setCurrentScript(script)
+ self.script_editor.setFocus()
+
+ def reloadClicked(self):
+ if self.nodeMode:
+ self.loadKnobValue()
+ else:
+ log("Node mode is off")
+ self.loadScriptContents(check=True, pyOnly=True)
+
+ def saveClicked(self):
+ if self.nodeMode:
+ self.saveKnobValue(False)
+ else:
+ self.saveScriptContents(temp=False)
+
+ def setModified(self):
+ if self.nodeMode:
+ self.setKnobModified(True)
+ elif not self.current_script_modified:
+ self.setScriptModified(True)
+ if not self.nodeMode:
+ self.toAutosave = True
+
+ def pin(self, pressed):
+ if pressed:
+ self.setWindowFlags(self.windowFlags() |
+ QtCore.Qt.WindowStaysOnTopHint)
+ self.pinned = True
+ self.show()
+ else:
+ self.setWindowFlags(self.windowFlags() & ~
+ QtCore.Qt.WindowStaysOnTopHint)
+ self.pinned = False
+ self.show()
+
+ def findSE(self):
+ for widget in QtWidgets.QApplication.allWidgets():
+ if "Script Editor" in widget.windowTitle():
+ return widget
+
+ # FunctiosaveScrollValuens for Nuke's Script Editor
+ def findScriptEditors(self):
+ script_editors = []
+ for widget in QtWidgets.QApplication.allWidgets():
+ if "Script Editor" in widget.windowTitle() and len(widget.children()) > 5:
+ script_editors.append(widget)
+ return script_editors
+
+ def findSEInput(self, se):
+ return se.children()[-1].children()[0]
+
+ def findSEOutput(self, se):
+ return se.children()[-1].children()[1]
+
+ def findSERunBtn(self, se):
+ for btn in se.children():
+ try:
+ if "Run the current script" in btn.toolTip():
+ return btn
+ except:
+ pass
+ return False
+
+ def setSEOutputEvent(self):
+ nukeScriptEditors = self.findScriptEditors()
+ # Take the console from the first script editor found...
+ self.origConsoleText = self.nukeSEOutput.document().toPlainText().encode("utf8")
+ for se in nukeScriptEditors:
+ se_output = self.findSEOutput(se)
+ se_output.textChanged.connect(
+ partial(consoleChanged, se_output, self))
+ consoleChanged(se_output, self) # Initialise.
+
+
+class KnobScripterPane(KnobScripter):
+ def __init__(self, node="", knob="knobChanged"):
+ super(KnobScripterPane, self).__init__()
+ self.isPane = True
+
+ def showEvent(self, the_event):
+ try:
+ killPaneMargins(self)
+ except:
+ pass
+ return KnobScripter.showEvent(self, the_event)
+
+ def hideEvent(self, the_event):
+ self.autosave()
+ return KnobScripter.hideEvent(self, the_event)
+
+
+def consoleChanged(self, ks):
+ ''' This will be called every time the ScriptEditor Output text is changed '''
+ try:
+ if ks: # KS exists
+ ksOutput = ks.script_output # The console TextEdit widget
+ ksText = self.document().toPlainText().encode("utf8")
+ # The text from the console that will be omitted
+ origConsoleText = ks.origConsoleText
+ if ksText.startswith(origConsoleText):
+ ksText = ksText[len(origConsoleText):]
+ else:
+ ks.origConsoleText = ""
+ ksOutput.setPlainText(ksText)
+ ksOutput.verticalScrollBar().setValue(ksOutput.verticalScrollBar().maximum())
+ except:
+ pass
+
+
+def killPaneMargins(widget_object):
+ if widget_object:
+ target_widgets = set()
+ target_widgets.add(widget_object.parentWidget().parentWidget())
+ target_widgets.add(widget_object.parentWidget(
+ ).parentWidget().parentWidget().parentWidget())
+
+ for widget_layout in target_widgets:
+ try:
+ widget_layout.layout().setContentsMargins(0, 0, 0, 0)
+ except:
+ pass
+
+
+def debug(lev=0):
+ ''' Convenience function to set the KnobScripter on debug mode'''
+ # levels = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
+ # for handler in logging.root.handlers[:]:
+ # logging.root.removeHandler(handler)
+ # logging.basicConfig(level=levels[lev])
+ # Changed to a shitty way for now
+ global DebugMode
+ DebugMode = True
+
+
+def log(text):
+ ''' Display a debug info message. Yes, in a stupid way. I know.'''
+ global DebugMode
+ if DebugMode:
+ print(text)
+
+
+# ---------------------------------------------------------------------
+# Dialogs
+# ---------------------------------------------------------------------
+class FileNameDialog(QtWidgets.QDialog):
+ '''
+ Dialog for creating new... (mode = "folder", "script" or "knob").
+ '''
+
+ def __init__(self, parent=None, mode="folder", text=""):
+ if parent.isPane:
+ super(FileNameDialog, self).__init__()
+ else:
+ super(FileNameDialog, self).__init__(parent)
+ #self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
+ self.mode = mode
+ self.text = text
+
+ title = "Create new {}.".format(self.mode)
+ self.setWindowTitle(title)
+
+ self.initUI()
+
+ def initUI(self):
+ # Widgets
+ self.name_label = QtWidgets.QLabel("Name: ")
+ self.name_label.setAlignment(
+ QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ self.name_lineEdit = QtWidgets.QLineEdit()
+ self.name_lineEdit.setText(self.text)
+ self.name_lineEdit.textChanged.connect(self.nameChanged)
+
+ # Buttons
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
+ self.button_box.button(
+ QtWidgets.QDialogButtonBox.Ok).setEnabled(self.text != "")
+ self.button_box.accepted.connect(self.clickedOk)
+ self.button_box.rejected.connect(self.clickedCancel)
+
+ # Layout
+ self.master_layout = QtWidgets.QVBoxLayout()
+ self.name_layout = QtWidgets.QHBoxLayout()
+ self.name_layout.addWidget(self.name_label)
+ self.name_layout.addWidget(self.name_lineEdit)
+ self.master_layout.addLayout(self.name_layout)
+ self.master_layout.addWidget(self.button_box)
+ self.setLayout(self.master_layout)
+
+ self.name_lineEdit.setFocus()
+ self.setMinimumWidth(250)
+
+ def nameChanged(self):
+ txt = self.name_lineEdit.text()
+ m = r"[\w]*$"
+ if self.mode == "knob": # Knobs can't start with a number...
+ m = r"[a-zA-Z_]+" + m
+
+ if re.match(m, txt) or txt == "":
+ self.text = txt
+ else:
+ self.name_lineEdit.setText(self.text)
+
+ self.button_box.button(
+ QtWidgets.QDialogButtonBox.Ok).setEnabled(self.text != "")
+ return
+
+ def clickedOk(self):
+ self.accept()
+ return
+
+ def clickedCancel(self):
+ self.reject()
+ return
+
+
+class TextInputDialog(QtWidgets.QDialog):
+ '''
+ Simple dialog for a text input.
+ '''
+
+ def __init__(self, parent=None, name="", text="", title=""):
+ if parent.isPane:
+ super(TextInputDialog, self).__init__()
+ else:
+ super(TextInputDialog, self).__init__(parent)
+ #self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
+
+ self.name = name # title of textinput
+ self.text = text # default content of textinput
+
+ self.setWindowTitle(title)
+
+ self.initUI()
+
+ def initUI(self):
+ # Widgets
+ self.name_label = QtWidgets.QLabel(self.name + ": ")
+ self.name_label.setAlignment(
+ QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ self.name_lineEdit = QtWidgets.QLineEdit()
+ self.name_lineEdit.setText(self.text)
+ self.name_lineEdit.textChanged.connect(self.nameChanged)
+
+ # Buttons
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
+ #self.button_box.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(self.text != "")
+ self.button_box.accepted.connect(self.clickedOk)
+ self.button_box.rejected.connect(self.clickedCancel)
+
+ # Layout
+ self.master_layout = QtWidgets.QVBoxLayout()
+ self.name_layout = QtWidgets.QHBoxLayout()
+ self.name_layout.addWidget(self.name_label)
+ self.name_layout.addWidget(self.name_lineEdit)
+ self.master_layout.addLayout(self.name_layout)
+ self.master_layout.addWidget(self.button_box)
+ self.setLayout(self.master_layout)
+
+ self.name_lineEdit.setFocus()
+ self.setMinimumWidth(250)
+
+ def nameChanged(self):
+ self.text = self.name_lineEdit.text()
+
+ def clickedOk(self):
+ self.accept()
+ return
+
+ def clickedCancel(self):
+ self.reject()
+ return
+
+
+class ChooseNodeDialog(QtWidgets.QDialog):
+ '''
+ Dialog for selecting a node by its name. Only admits nodes that exist (including root, preferences...)
+ '''
+
+ def __init__(self, parent=None, name=""):
+ if parent.isPane:
+ super(ChooseNodeDialog, self).__init__()
+ else:
+ super(ChooseNodeDialog, self).__init__(parent)
+
+ self.name = name # Name of node (will be "" by default)
+ self.allNodes = []
+
+ self.setWindowTitle("Enter the node's name...")
+
+ self.initUI()
+
+ def initUI(self):
+ # Widgets
+ self.name_label = QtWidgets.QLabel("Name: ")
+ self.name_label.setAlignment(
+ QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ self.name_lineEdit = QtWidgets.QLineEdit()
+ self.name_lineEdit.setText(self.name)
+ self.name_lineEdit.textChanged.connect(self.nameChanged)
+
+ self.allNodes = self.getAllNodes()
+ completer = QtWidgets.QCompleter(self.allNodes, self)
+ completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ self.name_lineEdit.setCompleter(completer)
+
+ # Buttons
+ self.button_box = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
+ self.button_box.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(
+ nuke.exists(self.name))
+ self.button_box.accepted.connect(self.clickedOk)
+ self.button_box.rejected.connect(self.clickedCancel)
+
+ # Layout
+ self.master_layout = QtWidgets.QVBoxLayout()
+ self.name_layout = QtWidgets.QHBoxLayout()
+ self.name_layout.addWidget(self.name_label)
+ self.name_layout.addWidget(self.name_lineEdit)
+ self.master_layout.addLayout(self.name_layout)
+ self.master_layout.addWidget(self.button_box)
+ self.setLayout(self.master_layout)
+
+ self.name_lineEdit.setFocus()
+ self.setMinimumWidth(250)
+
+ def getAllNodes(self):
+ self.allNodes = [n.fullName() for n in nuke.allNodes(
+ recurseGroups=True)] # if parent is in current context??
+ self.allNodes.extend(["root", "preferences"])
+ return self.allNodes
+
+ def nameChanged(self):
+ self.name = self.name_lineEdit.text()
+ self.button_box.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(
+ self.name in self.allNodes)
+
+ def clickedOk(self):
+ self.accept()
+ return
+
+ def clickedCancel(self):
+ self.reject()
+ return
+
+
+# ------------------------------------------------------------------------------------------------------
+# Script Editor Widget
+# Wouter Gilsing built an incredibly useful python script editor for his Hotbox Manager, so I had it
+# really easy for this part!
+# Starting from his script editor, I changed the style and added the sublime-like functionality.
+# I think this bit of code has the potential to get used in many nuke tools.
+# Credit to him: http://www.woutergilsing.com/
+# Originally used on W_Hotbox v1.5: http://www.nukepedia.com/python/ui/w_hotbox
+# ------------------------------------------------------------------------------------------------------
+class KnobScripterTextEdit(QtWidgets.QPlainTextEdit):
+ # Signal that will be emitted when the user has changed the text
+ userChangedEvent = QtCore.Signal()
+
+ def __init__(self, knobScripter=""):
+ super(KnobScripterTextEdit, self).__init__()
+
+ self.knobScripter = knobScripter
+ self.selected_text = ""
+
+ # Setup line numbers
+ if self.knobScripter != "":
+ self.tabSpaces = self.knobScripter.tabSpaces
+ else:
+ self.tabSpaces = 4
+ self.lineNumberArea = KSLineNumberArea(self)
+ self.blockCountChanged.connect(self.updateLineNumberAreaWidth)
+ self.updateRequest.connect(self.updateLineNumberArea)
+ self.updateLineNumberAreaWidth()
+
+ # Highlight line
+ self.cursorPositionChanged.connect(self.highlightCurrentLine)
+
+ # --------------------------------------------------------------------------------------------------
+ # This is adapted from an original version by Wouter Gilsing.
+ # Extract from his original comments:
+ # While researching the implementation of line number, I had a look at Nuke's Blinkscript node. [..]
+ # thefoundry.co.uk/products/nuke/developers/100/pythonreference/nukescripts.blinkscripteditor-pysrc.html
+ # I stripped and modified the useful bits of the line number related parts of the code [..]
+ # Credits to theFoundry for writing the blinkscripteditor, best example code I could wish for.
+ # --------------------------------------------------------------------------------------------------
+
+ def lineNumberAreaWidth(self):
+ digits = 1
+ maxNum = max(1, self.blockCount())
+ while (maxNum >= 10):
+ maxNum /= 10
+ digits += 1
+
+ space = 7 + self.fontMetrics().width('9') * digits
+ return space
+
+ def updateLineNumberAreaWidth(self):
+ self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0)
+
+ def updateLineNumberArea(self, rect, dy):
+
+ if (dy):
+ self.lineNumberArea.scroll(0, dy)
+ else:
+ self.lineNumberArea.update(
+ 0, rect.y(), self.lineNumberArea.width(), rect.height())
+
+ if (rect.contains(self.viewport().rect())):
+ self.updateLineNumberAreaWidth()
+
+ def resizeEvent(self, event):
+ QtWidgets.QPlainTextEdit.resizeEvent(self, event)
+
+ cr = self.contentsRect()
+ self.lineNumberArea.setGeometry(QtCore.QRect(
+ cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height()))
+
+ def lineNumberAreaPaintEvent(self, event):
+
+ if self.isReadOnly():
+ return
+
+ painter = QtGui.QPainter(self.lineNumberArea)
+ painter.fillRect(event.rect(), QtGui.QColor(36, 36, 36)) # Number bg
+
+ block = self.firstVisibleBlock()
+ blockNumber = block.blockNumber()
+ top = int(self.blockBoundingGeometry(
+ block).translated(self.contentOffset()).top())
+ bottom = top + int(self.blockBoundingRect(block).height())
+ currentLine = self.document().findBlock(
+ self.textCursor().position()).blockNumber()
+
+ painter.setPen(self.palette().color(QtGui.QPalette.Text))
+
+ painterFont = QtGui.QFont()
+ painterFont.setFamily("Courier")
+ painterFont.setStyleHint(QtGui.QFont.Monospace)
+ painterFont.setFixedPitch(True)
+ if self.knobScripter != "":
+ painterFont.setPointSize(self.knobScripter.fontSize)
+ painter.setFont(self.knobScripter.script_editor_font)
+
+ while (block.isValid() and top <= event.rect().bottom()):
+
+ textColor = QtGui.QColor(110, 110, 110) # Numbers
+
+ if blockNumber == currentLine and self.hasFocus():
+ textColor = QtGui.QColor(255, 170, 0) # Number highlighted
+
+ painter.setPen(textColor)
+
+ number = "%s" % str(blockNumber + 1)
+ painter.drawText(-3, top, self.lineNumberArea.width(),
+ self.fontMetrics().height(), QtCore.Qt.AlignRight, number)
+
+ # Move to the next block
+ block = block.next()
+ top = bottom
+ bottom = top + int(self.blockBoundingRect(block).height())
+ blockNumber += 1
+
+ def keyPressEvent(self, event):
+ '''
+ Custom actions for specific keystrokes
+ '''
+ key = event.key()
+ ctrl = bool(event.modifiers() & Qt.ControlModifier)
+ alt = bool(event.modifiers() & Qt.AltModifier)
+ shift = bool(event.modifiers() & Qt.ShiftModifier)
+ pre_scroll = self.verticalScrollBar().value()
+ #modifiers = QtWidgets.QApplication.keyboardModifiers()
+ #ctrl = (modifiers == Qt.ControlModifier)
+ #shift = (modifiers == Qt.ShiftModifier)
+
+ up_arrow = 16777235
+ down_arrow = 16777237
+
+ # if Tab convert to Space
+ if key == 16777217:
+ self.indentation('indent')
+
+ # if Shift+Tab remove indent
+ elif key == 16777218:
+ self.indentation('unindent')
+
+ # if BackSpace try to snap to previous indent level
+ elif key == 16777219:
+ if not self.unindentBackspace():
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ else:
+ # COOL BEHAVIORS SIMILAR TO SUBLIME GO NEXT!
+ cursor = self.textCursor()
+ cpos = cursor.position()
+ apos = cursor.anchor()
+ text_before_cursor = self.toPlainText()[:min(cpos, apos)]
+ text_after_cursor = self.toPlainText()[max(cpos, apos):]
+ text_all = self.toPlainText()
+ to_line_start = text_before_cursor[::-1].find("\n")
+ if to_line_start == -1:
+ # Position of the start of the line that includes the cursor selection start
+ linestart_pos = 0
+ else:
+ linestart_pos = len(text_before_cursor) - to_line_start
+
+ to_line_end = text_after_cursor.find("\n")
+ if to_line_end == -1:
+ # Position of the end of the line that includes the cursor selection end
+ lineend_pos = len(text_all)
+ else:
+ lineend_pos = max(cpos, apos) + to_line_end
+
+ text_before_lines = text_all[:linestart_pos]
+ text_after_lines = text_all[lineend_pos:]
+ if len(text_after_lines) and text_after_lines.startswith("\n"):
+ text_after_lines = text_after_lines[1:]
+ text_lines = text_all[linestart_pos:lineend_pos]
+
+ if cursor.hasSelection():
+ selection = cursor.selection().toPlainText()
+ else:
+ selection = ""
+ if key == Qt.Key_ParenLeft and (len(selection) > 0 or re.match(r"[\s)}\];]+", text_after_cursor) or not len(text_after_cursor)): # (
+ cursor.insertText("(" + selection + ")")
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ # )
+ elif key == Qt.Key_ParenRight and text_after_cursor.startswith(")"):
+ cursor.movePosition(QtGui.QTextCursor.NextCharacter)
+ self.setTextCursor(cursor)
+ elif key == Qt.Key_BracketLeft and (len(selection) > 0 or re.match(r"[\s)}\];]+", text_after_cursor) or not len(text_after_cursor)): # [
+ cursor.insertText("[" + selection + "]")
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ # ]
+ elif key in [Qt.Key_BracketRight, 43] and text_after_cursor.startswith("]"):
+ cursor.movePosition(QtGui.QTextCursor.NextCharacter)
+ self.setTextCursor(cursor)
+ elif key == Qt.Key_BraceLeft and (len(selection) > 0 or re.match(r"[\s)}\];]+", text_after_cursor) or not len(text_after_cursor)): # {
+ cursor.insertText("{" + selection + "}")
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ # }
+ elif key in [199, Qt.Key_BraceRight] and text_after_cursor.startswith("}"):
+ cursor.movePosition(QtGui.QTextCursor.NextCharacter)
+ self.setTextCursor(cursor)
+ elif key == 34: # "
+ if len(selection) > 0:
+ cursor.insertText('"' + selection + '"')
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ # and not re.search(r"(?:[\s)\]]+|$)",text_before_cursor):
+ elif text_after_cursor.startswith('"') and '"' in text_before_cursor.split("\n")[-1]:
+ cursor.movePosition(QtGui.QTextCursor.NextCharacter)
+ # If chars after cursor, act normal
+ elif not re.match(r"(?:[\s)\]]+|$)", text_after_cursor):
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ # If chars before cursor, act normal
+ elif not re.search(r"[\s.({\[,]$", text_before_cursor) and text_before_cursor != "":
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ else:
+ cursor.insertText('"' + selection + '"')
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ elif key == 39: # '
+ if len(selection) > 0:
+ cursor.insertText("'" + selection + "'")
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ # and not re.search(r"(?:[\s)\]]+|$)",text_before_cursor):
+ elif text_after_cursor.startswith("'") and "'" in text_before_cursor.split("\n")[-1]:
+ cursor.movePosition(QtGui.QTextCursor.NextCharacter)
+ # If chars after cursor, act normal
+ elif not re.match(r"(?:[\s)\]]+|$)", text_after_cursor):
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ # If chars before cursor, act normal
+ elif not re.search(r"[\s.({\[,]$", text_before_cursor) and text_before_cursor != "":
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ else:
+ cursor.insertText("'" + selection + "'")
+ cursor.setPosition(apos + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ elif key == 35 and len(selection): # (yes, a hash)
+ # If there's a selection, insert a hash at the start of each line.. how the fuck?
+ if selection != "":
+ selection_split = selection.split("\n")
+ if all(i.startswith("#") for i in selection_split):
+ selection_commented = "\n".join(
+ [s[1:] for s in selection_split]) # Uncommented
+ else:
+ selection_commented = "#" + "\n#".join(selection_split)
+ cursor.insertText(selection_commented)
+ if apos > cpos:
+ cursor.setPosition(
+ apos + len(selection_commented) - len(selection), QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos, QtGui.QTextCursor.KeepAnchor)
+ else:
+ cursor.setPosition(apos, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(
+ cpos + len(selection_commented) - len(selection), QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+
+ elif key == 68 and ctrl and shift: # Ctrl+Shift+D, to duplicate text or line/s
+
+ if not len(selection):
+ self.setPlainText(
+ text_before_lines + text_lines + "\n" + text_lines + "\n" + text_after_lines)
+ cursor.setPosition(
+ apos + len(text_lines) + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(
+ cpos + len(text_lines) + 1, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ self.verticalScrollBar().setValue(pre_scroll)
+ self.scrollToCursor()
+ else:
+ if text_before_cursor.endswith("\n") and not selection.startswith("\n"):
+ cursor.insertText(selection + "\n" + selection)
+ cursor.setPosition(
+ apos + len(selection) + 1, QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(
+ cpos + len(selection) + 1, QtGui.QTextCursor.KeepAnchor)
+ else:
+ cursor.insertText(selection + selection)
+ cursor.setPosition(
+ apos + len(selection), QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(
+ cpos + len(selection), QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+
+ # Ctrl+Shift+Up, to move the selected line/s up
+ elif key == up_arrow and ctrl and shift and len(text_before_lines):
+ prev_line_start_distance = text_before_lines[:-1][::-1].find(
+ "\n")
+ if prev_line_start_distance == -1:
+ prev_line_start_pos = 0 # Position of the start of the previous line
+ else:
+ prev_line_start_pos = len(
+ text_before_lines) - 1 - prev_line_start_distance
+ prev_line = text_before_lines[prev_line_start_pos:]
+
+ text_before_prev_line = text_before_lines[:prev_line_start_pos]
+
+ if prev_line.endswith("\n"):
+ prev_line = prev_line[:-1]
+
+ if len(text_after_lines):
+ text_after_lines = "\n" + text_after_lines
+
+ self.setPlainText(
+ text_before_prev_line + text_lines + "\n" + prev_line + text_after_lines)
+ cursor.setPosition(apos - len(prev_line) - 1,
+ QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos - len(prev_line) - 1,
+ QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ self.verticalScrollBar().setValue(pre_scroll)
+ self.scrollToCursor()
+ return
+
+ elif key == down_arrow and ctrl and shift: # Ctrl+Shift+Up, to move the selected line/s up
+ if not len(text_after_lines):
+ text_after_lines = ""
+ next_line_end_distance = text_after_lines.find("\n")
+ if next_line_end_distance == -1:
+ next_line_end_pos = len(text_all)
+ else:
+ next_line_end_pos = next_line_end_distance
+ next_line = text_after_lines[:next_line_end_pos]
+ text_after_next_line = text_after_lines[next_line_end_pos:]
+
+ self.setPlainText(text_before_lines + next_line +
+ "\n" + text_lines + text_after_next_line)
+ cursor.setPosition(apos + len(next_line) + 1,
+ QtGui.QTextCursor.MoveAnchor)
+ cursor.setPosition(cpos + len(next_line) + 1,
+ QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+ self.verticalScrollBar().setValue(pre_scroll)
+ self.scrollToCursor()
+ return
+
+ # If up key and nothing happens, go to start
+ elif key == up_arrow and not len(text_before_lines):
+ if not shift:
+ cursor.setPosition(0, QtGui.QTextCursor.MoveAnchor)
+ self.setTextCursor(cursor)
+ else:
+ cursor.setPosition(0, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+
+ # If up key and nothing happens, go to start
+ elif key == down_arrow and not len(text_after_lines):
+ if not shift:
+ cursor.setPosition(
+ len(text_all), QtGui.QTextCursor.MoveAnchor)
+ self.setTextCursor(cursor)
+ else:
+ cursor.setPosition(
+ len(text_all), QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(cursor)
+
+ # if enter or return, match indent level
+ elif key in [16777220, 16777221]:
+ self.indentNewLine()
+ else:
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+
+ self.scrollToCursor()
+
+ def scrollToCursor(self):
+ self.cursor = self.textCursor()
+ # Does nothing, but makes the scroll go to the right place...
+ self.cursor.movePosition(QtGui.QTextCursor.NoMove)
+ self.setTextCursor(self.cursor)
+
+ def getCursorInfo(self):
+
+ self.cursor = self.textCursor()
+
+ self.firstChar = self.cursor.selectionStart()
+ self.lastChar = self.cursor.selectionEnd()
+
+ self.noSelection = False
+ if self.firstChar == self.lastChar:
+ self.noSelection = True
+
+ self.originalPosition = self.cursor.position()
+ self.cursorBlockPos = self.cursor.positionInBlock()
+
+ def unindentBackspace(self):
+ '''
+ #snap to previous indent level
+ '''
+ self.getCursorInfo()
+
+ if not self.noSelection or self.cursorBlockPos == 0:
+ return False
+
+ # check text in front of cursor
+ textInFront = self.document().findBlock(
+ self.firstChar).text()[:self.cursorBlockPos]
+
+ # check whether solely spaces
+ if textInFront != ' ' * self.cursorBlockPos:
+ return False
+
+ # snap to previous indent level
+ spaces = len(textInFront)
+ for space in range(spaces - ((spaces - 1) / self.tabSpaces) * self.tabSpaces - 1):
+ self.cursor.deletePreviousChar()
+
+ def indentNewLine(self):
+
+ # in case selection covers multiple line, make it one line first
+ self.insertPlainText('')
+
+ self.getCursorInfo()
+
+ # check how many spaces after cursor
+ text = self.document().findBlock(self.firstChar).text()
+
+ textInFront = text[:self.cursorBlockPos]
+
+ if len(textInFront) == 0:
+ self.insertPlainText('\n')
+ return
+
+ indentLevel = 0
+ for i in textInFront:
+ if i == ' ':
+ indentLevel += 1
+ else:
+ break
+
+ indentLevel /= self.tabSpaces
+
+ # find out whether textInFront's last character was a ':'
+ # if that's the case add another indent.
+ # ignore any spaces at the end, however also
+ # make sure textInFront is not just an indent
+ if textInFront.count(' ') != len(textInFront):
+ while textInFront[-1] == ' ':
+ textInFront = textInFront[:-1]
+
+ if textInFront[-1] == ':':
+ indentLevel += 1
+
+ # new line
+ self.insertPlainText('\n')
+ # match indent
+ self.insertPlainText(' ' * (self.tabSpaces * indentLevel))
+
+ def indentation(self, mode):
+
+ pre_scroll = self.verticalScrollBar().value()
+ self.getCursorInfo()
+
+ # if nothing is selected and mode is set to indent, simply insert as many
+ # space as needed to reach the next indentation level.
+ if self.noSelection and mode == 'indent':
+
+ remainingSpaces = self.tabSpaces - \
+ (self.cursorBlockPos % self.tabSpaces)
+ self.insertPlainText(' ' * remainingSpaces)
+ return
+
+ selectedBlocks = self.findBlocks(self.firstChar, self.lastChar)
+ beforeBlocks = self.findBlocks(
+ last=self.firstChar - 1, exclude=selectedBlocks)
+ afterBlocks = self.findBlocks(
+ first=self.lastChar + 1, exclude=selectedBlocks)
+
+ beforeBlocksText = self.blocks2list(beforeBlocks)
+ selectedBlocksText = self.blocks2list(selectedBlocks, mode)
+ afterBlocksText = self.blocks2list(afterBlocks)
+
+ combinedText = '\n'.join(
+ beforeBlocksText + selectedBlocksText + afterBlocksText)
+
+ # make sure the line count stays the same
+ originalBlockCount = len(self.toPlainText().split('\n'))
+ combinedText = '\n'.join(combinedText.split('\n')[:originalBlockCount])
+
+ self.clear()
+ self.setPlainText(combinedText)
+
+ if self.noSelection:
+ self.cursor.setPosition(self.lastChar)
+
+ # check whether the the orignal selection was from top to bottom or vice versa
+ else:
+ if self.originalPosition == self.firstChar:
+ first = self.lastChar
+ last = self.firstChar
+ firstBlockSnap = QtGui.QTextCursor.EndOfBlock
+ lastBlockSnap = QtGui.QTextCursor.StartOfBlock
+ else:
+ first = self.firstChar
+ last = self.lastChar
+ firstBlockSnap = QtGui.QTextCursor.StartOfBlock
+ lastBlockSnap = QtGui.QTextCursor.EndOfBlock
+
+ self.cursor.setPosition(first)
+ self.cursor.movePosition(
+ firstBlockSnap, QtGui.QTextCursor.MoveAnchor)
+ self.cursor.setPosition(last, QtGui.QTextCursor.KeepAnchor)
+ self.cursor.movePosition(
+ lastBlockSnap, QtGui.QTextCursor.KeepAnchor)
+
+ self.setTextCursor(self.cursor)
+ self.verticalScrollBar().setValue(pre_scroll)
+
+ def findBlocks(self, first=0, last=None, exclude=[]):
+ blocks = []
+ if last == None:
+ last = self.document().characterCount()
+ for pos in range(first, last + 1):
+ block = self.document().findBlock(pos)
+ if block not in blocks and block not in exclude:
+ blocks.append(block)
+ return blocks
+
+ def blocks2list(self, blocks, mode=None):
+ text = []
+ for block in blocks:
+ blockText = block.text()
+ if mode == 'unindent':
+ if blockText.startswith(' ' * self.tabSpaces):
+ blockText = blockText[self.tabSpaces:]
+ self.lastChar -= self.tabSpaces
+ elif blockText.startswith('\t'):
+ blockText = blockText[1:]
+ self.lastChar -= 1
+
+ elif mode == 'indent':
+ blockText = ' ' * self.tabSpaces + blockText
+ self.lastChar += self.tabSpaces
+
+ text.append(blockText)
+
+ return text
+
+ def highlightCurrentLine(self):
+ '''
+ Highlight currently selected line
+ '''
+ extraSelections = []
+
+ selection = QtWidgets.QTextEdit.ExtraSelection()
+
+ lineColor = QtGui.QColor(62, 62, 62, 255)
+
+ selection.format.setBackground(lineColor)
+ selection.format.setProperty(
+ QtGui.QTextFormat.FullWidthSelection, True)
+ selection.cursor = self.textCursor()
+ selection.cursor.clearSelection()
+
+ extraSelections.append(selection)
+
+ self.setExtraSelections(extraSelections)
+ self.scrollToCursor()
+
+ def format(self, rgb, style=''):
+ '''
+ Return a QtWidgets.QTextCharFormat with the given attributes.
+ '''
+ color = QtGui.QColor(*rgb)
+ textFormat = QtGui.QTextCharFormat()
+ textFormat.setForeground(color)
+
+ if 'bold' in style:
+ textFormat.setFontWeight(QtGui.QFont.Bold)
+ if 'italic' in style:
+ textFormat.setFontItalic(True)
+ if 'underline' in style:
+ textFormat.setUnderlineStyle(QtGui.QTextCharFormat.SingleUnderline)
+
+ return textFormat
+
+
+class KSLineNumberArea(QtWidgets.QWidget):
+ def __init__(self, scriptEditor):
+ super(KSLineNumberArea, self).__init__(scriptEditor)
+
+ self.scriptEditor = scriptEditor
+ self.setStyleSheet("text-align: center;")
+
+ def paintEvent(self, event):
+ self.scriptEditor.lineNumberAreaPaintEvent(event)
+ return
+
+
+class KSScriptEditorHighlighter(QtGui.QSyntaxHighlighter):
+ '''
+ This is also adapted from an original version by Wouter Gilsing. His comments:
+
+ Modified, simplified version of some code found I found when researching:
+ wiki.python.org/moin/PyQt/Python%20syntax%20highlighting
+ They did an awesome job, so credits to them. I only needed to make some
+ modifications to make it fit my needs.
+ '''
+
+ def __init__(self, document, parent=None):
+
+ super(KSScriptEditorHighlighter, self).__init__(document)
+ self.knobScripter = parent
+ self.script_editor = self.knobScripter.script_editor
+ self.selected_text = ""
+ self.selected_text_prev = ""
+ self.rules_sublime = ""
+
+ self.styles = {
+ 'keyword': self.format([238, 117, 181], 'bold'),
+ 'string': self.format([242, 136, 135]),
+ 'comment': self.format([143, 221, 144]),
+ 'numbers': self.format([174, 129, 255]),
+ 'custom': self.format([255, 170, 0], 'italic'),
+ 'selected': self.format([255, 255, 255], 'bold underline'),
+ 'underline': self.format([240, 240, 240], 'underline'),
+ }
+
+ self.keywords = [
+ 'and', 'assert', 'break', 'class', 'continue', 'def',
+ 'del', 'elif', 'else', 'except', 'exec', 'finally',
+ 'for', 'from', 'global', 'if', 'import', 'in',
+ 'is', 'lambda', 'not', 'or', 'pass', 'print',
+ 'raise', 'return', 'try', 'while', 'yield', 'with', 'as'
+ ]
+
+ self.operatorKeywords = [
+ '=', '==', '!=', '<', '<=', '>', '>=',
+ '\+', '-', '\*', '/', '//', '\%', '\*\*',
+ '\+=', '-=', '\*=', '/=', '\%=',
+ '\^', '\|', '\&', '\~', '>>', '<<'
+ ]
+
+ self.variableKeywords = ['int', 'str',
+ 'float', 'bool', 'list', 'dict', 'set']
+
+ self.numbers = ['True', 'False', 'None']
+ self.loadAltStyles()
+
+ self.tri_single = (QtCore.QRegExp("'''"), 1, self.styles['comment'])
+ self.tri_double = (QtCore.QRegExp('"""'), 2, self.styles['comment'])
+
+ # rules
+ rules = []
+
+ rules += [(r'\b%s\b' % i, 0, self.styles['keyword'])
+ for i in self.keywords]
+ rules += [(i, 0, self.styles['keyword'])
+ for i in self.operatorKeywords]
+ rules += [(r'\b%s\b' % i, 0, self.styles['numbers'])
+ for i in self.numbers]
+
+ rules += [
+
+ # integers
+ (r'\b[0-9]+\b', 0, self.styles['numbers']),
+ # Double-quoted string, possibly containing escape sequences
+ (r'"[^"\\]*(\\.[^"\\]*)*"', 0, self.styles['string']),
+ # Single-quoted string, possibly containing escape sequences
+ (r"'[^'\\]*(\\.[^'\\]*)*'", 0, self.styles['string']),
+ # From '#' until a newline
+ (r'#[^\n]*', 0, self.styles['comment']),
+ ]
+
+ # Build a QRegExp for each pattern
+ self.rules_nuke = [(QtCore.QRegExp(pat), index, fmt)
+ for (pat, index, fmt) in rules]
+ self.rules = self.rules_nuke
+
+ def loadAltStyles(self):
+ ''' Loads other color styles apart from Nuke's default. '''
+ self.styles_sublime = {
+ 'base': self.format([255, 255, 255]),
+ 'keyword': self.format([237, 36, 110]),
+ 'string': self.format([237, 229, 122]),
+ 'comment': self.format([125, 125, 125]),
+ 'numbers': self.format([165, 120, 255]),
+ 'functions': self.format([184, 237, 54]),
+ 'blue': self.format([130, 226, 255], 'italic'),
+ 'arguments': self.format([255, 170, 10], 'italic'),
+ 'custom': self.format([200, 200, 200], 'italic'),
+ 'underline': self.format([240, 240, 240], 'underline'),
+ 'selected': self.format([255, 255, 255], 'bold underline'),
+ }
+
+ self.keywords_sublime = [
+ 'and', 'assert', 'break', 'continue',
+ 'del', 'elif', 'else', 'except', 'exec', 'finally',
+ 'for', 'from', 'global', 'if', 'import', 'in',
+ 'is', 'lambda', 'not', 'or', 'pass', 'print',
+ 'raise', 'return', 'try', 'while', 'yield', 'with', 'as'
+ ]
+ self.operatorKeywords_sublime = [
+ '=', '==', '!=', '<', '<=', '>', '>=',
+ '\+', '-', '\*', '/', '//', '\%', '\*\*',
+ '\+=', '-=', '\*=', '/=', '\%=',
+ '\^', '\|', '\&', '\~', '>>', '<<'
+ ]
+
+ self.baseKeywords_sublime = [
+ ',',
+ ]
+
+ self.customKeywords_sublime = [
+ 'nuke',
+ ]
+
+ self.blueKeywords_sublime = [
+ 'def', 'class', 'int', 'str', 'float', 'bool', 'list', 'dict', 'set'
+ ]
+
+ self.argKeywords_sublime = [
+ 'self',
+ ]
+
+ self.tri_single_sublime = (QtCore.QRegExp(
+ "'''"), 1, self.styles_sublime['comment'])
+ self.tri_double_sublime = (QtCore.QRegExp(
+ '"""'), 2, self.styles_sublime['comment'])
+ self.numbers_sublime = ['True', 'False', 'None']
+
+ # rules
+
+ rules = []
+ # First turn everything inside parentheses orange
+ rules += [(r"def [\w]+[\s]*\((.*)\)", 1,
+ self.styles_sublime['arguments'])]
+ # Now restore unwanted stuff...
+ rules += [(i, 0, self.styles_sublime['base'])
+ for i in self.baseKeywords_sublime]
+ rules += [(r"[^\(\w),.][\s]*[\w]+", 0, self.styles_sublime['base'])]
+
+ # Everything else
+ rules += [(r'\b%s\b' % i, 0, self.styles_sublime['keyword'])
+ for i in self.keywords_sublime]
+ rules += [(i, 0, self.styles_sublime['keyword'])
+ for i in self.operatorKeywords_sublime]
+ rules += [(i, 0, self.styles_sublime['custom'])
+ for i in self.customKeywords_sublime]
+ rules += [(r'\b%s\b' % i, 0, self.styles_sublime['blue'])
+ for i in self.blueKeywords_sublime]
+ rules += [(i, 0, self.styles_sublime['arguments'])
+ for i in self.argKeywords_sublime]
+ rules += [(r'\b%s\b' % i, 0, self.styles_sublime['numbers'])
+ for i in self.numbers_sublime]
+
+ rules += [
+
+ # integers
+ (r'\b[0-9]+\b', 0, self.styles_sublime['numbers']),
+ # Double-quoted string, possibly containing escape sequences
+ (r'"[^"\\]*(\\.[^"\\]*)*"', 0, self.styles_sublime['string']),
+ # Single-quoted string, possibly containing escape sequences
+ (r"'[^'\\]*(\\.[^'\\]*)*'", 0, self.styles_sublime['string']),
+ # From '#' until a newline
+ (r'#[^\n]*', 0, self.styles_sublime['comment']),
+ # Function definitions
+ (r"def[\s]+([\w\.]+)", 1, self.styles_sublime['functions']),
+ # Class definitions
+ (r"class[\s]+([\w\.]+)", 1, self.styles_sublime['functions']),
+ # Class argument (which is also a class so must be green)
+ (r"class[\s]+[\w\.]+[\s]*\((.*)\)",
+ 1, self.styles_sublime['functions']),
+ # Function arguments also pick their style...
+ (r"def[\s]+[\w]+[\s]*\(([\w]+)", 1,
+ self.styles_sublime['arguments']),
+ ]
+
+ # Build a QRegExp for each pattern
+ self.rules_sublime = [(QtCore.QRegExp(pat), index, fmt)
+ for (pat, index, fmt) in rules]
+
+ def format(self, rgb, style=''):
+ '''
+ Return a QtWidgets.QTextCharFormat with the given attributes.
+ '''
+
+ color = QtGui.QColor(*rgb)
+ textFormat = QtGui.QTextCharFormat()
+ textFormat.setForeground(color)
+
+ if 'bold' in style:
+ textFormat.setFontWeight(QtGui.QFont.Bold)
+ if 'italic' in style:
+ textFormat.setFontItalic(True)
+ if 'underline' in style:
+ textFormat.setUnderlineStyle(QtGui.QTextCharFormat.SingleUnderline)
+
+ return textFormat
+
+ def highlightBlock(self, text):
+ '''
+ Apply syntax highlighting to the given block of text.
+ '''
+ # Do other syntax formatting
+
+ if self.knobScripter.color_scheme:
+ self.color_scheme = self.knobScripter.color_scheme
+ else:
+ self.color_scheme = "nuke"
+
+ if self.color_scheme == "nuke":
+ self.rules = self.rules_nuke
+ elif self.color_scheme == "sublime":
+ self.rules = self.rules_sublime
+
+ for expression, nth, format in self.rules:
+ index = expression.indexIn(text, 0)
+
+ while index >= 0:
+ # We actually want the index of the nth match
+ index = expression.pos(nth)
+ length = len(expression.cap(nth))
+ self.setFormat(index, length, format)
+ index = expression.indexIn(text, index + length)
+
+ self.setCurrentBlockState(0)
+
+ # Multi-line strings etc. based on selected scheme
+ if self.color_scheme == "nuke":
+ in_multiline = self.match_multiline(text, *self.tri_single)
+ if not in_multiline:
+ in_multiline = self.match_multiline(text, *self.tri_double)
+ elif self.color_scheme == "sublime":
+ in_multiline = self.match_multiline(text, *self.tri_single_sublime)
+ if not in_multiline:
+ in_multiline = self.match_multiline(
+ text, *self.tri_double_sublime)
+
+ # TODO if there's a selection, highlight same occurrences in the full document. If no selection but something highlighted, unhighlight full document. (do it thru regex or sth)
+
+ def match_multiline(self, text, delimiter, in_state, style):
+ '''
+ Check whether highlighting requires multiple lines.
+ '''
+ # If inside triple-single quotes, start at 0
+ if self.previousBlockState() == in_state:
+ start = 0
+ add = 0
+ # Otherwise, look for the delimiter on this line
+ else:
+ start = delimiter.indexIn(text)
+ # Move past this match
+ add = delimiter.matchedLength()
+
+ # As long as there's a delimiter match on this line...
+ while start >= 0:
+ # Look for the ending delimiter
+ end = delimiter.indexIn(text, start + add)
+ # Ending delimiter on this line?
+ if end >= add:
+ length = end - start + add + delimiter.matchedLength()
+ self.setCurrentBlockState(0)
+ # No; multi-line string
+ else:
+ self.setCurrentBlockState(in_state)
+ length = len(text) - start + add
+ # Apply formatting
+ self.setFormat(start, length, style)
+ # Look for the next match
+ start = delimiter.indexIn(text, start + length)
+
+ # Return True if still inside a multi-line string, False otherwise
+ if self.currentBlockState() == in_state:
+ return True
+ else:
+ return False
+
+# --------------------------------------------------------------------------------------
+# Script Output Widget
+# The output logger works the same way as Nuke's python script editor output window
+# --------------------------------------------------------------------------------------
+
+
+class ScriptOutputWidget(QtWidgets.QTextEdit):
+ def __init__(self, parent=None):
+ super(ScriptOutputWidget, self).__init__(parent)
+ self.knobScripter = parent
+ self.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding)
+ self.setMinimumHeight(20)
+
+ def keyPressEvent(self, event):
+ ctrl = ((event.modifiers() and (Qt.ControlModifier)) != 0)
+ alt = ((event.modifiers() and (Qt.AltModifier)) != 0)
+ shift = ((event.modifiers() and (Qt.ShiftModifier)) != 0)
+ key = event.key()
+ if type(event) == QtGui.QKeyEvent:
+ # print event.key()
+ if key in [32]: # Space
+ return KnobScripter.keyPressEvent(self.knobScripter, event)
+ elif key in [Qt.Key_Backspace, Qt.Key_Delete]:
+ self.knobScripter.clearConsole()
+ return QtWidgets.QTextEdit.keyPressEvent(self, event)
+
+ # def mousePressEvent(self, QMouseEvent):
+ # if QMouseEvent.button() == Qt.RightButton:
+ # self.knobScripter.clearConsole()
+ # QtWidgets.QTextEdit.mousePressEvent(self, QMouseEvent)
+
+# ---------------------------------------------------------------------
+# Modified KnobScripterTextEdit to include snippets etc.
+# ---------------------------------------------------------------------
+
+
+class KnobScripterTextEditMain(KnobScripterTextEdit):
+ def __init__(self, knobScripter, output=None, parent=None):
+ super(KnobScripterTextEditMain, self).__init__(knobScripter)
+ self.knobScripter = knobScripter
+ self.script_output = output
+ self.nukeCompleter = None
+ self.currentNukeCompletion = None
+
+ ########
+ # FROM NUKE's SCRIPT EDITOR START
+ ########
+ self.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding)
+
+ # Setup completer
+ self.nukeCompleter = QtWidgets.QCompleter(self)
+ self.nukeCompleter.setWidget(self)
+ self.nukeCompleter.setCompletionMode(
+ QtWidgets.QCompleter.UnfilteredPopupCompletion)
+ self.nukeCompleter.setCaseSensitivity(Qt.CaseSensitive)
+ try:
+ self.nukeCompleter.setModel(QtGui.QStringListModel())
+ except:
+ self.nukeCompleter.setModel(QtCore.QStringListModel())
+
+ self.nukeCompleter.activated.connect(self.insertNukeCompletion)
+ self.nukeCompleter.highlighted.connect(self.completerHighlightChanged)
+ ########
+ # FROM NUKE's SCRIPT EDITOR END
+ ########
+
+ def findLongestEndingMatch(self, text, dic):
+ '''
+ If the text ends with a key in the dictionary, it returns the key and value.
+ If there are several matches, returns the longest one.
+ False if no matches.
+ '''
+ longest = 0 # len of longest match
+ match_key = None
+ match_snippet = ""
+ for key, val in dic.items():
+ #match = re.search(r"[\s\.({\[,;=+-]"+key+r"(?:[\s)\]\"]+|$)",text)
+ match = re.search(r"[\s\.({\[,;=+-]" + key + r"$", text)
+ if match or text == key:
+ if len(key) > longest:
+ longest = len(key)
+ match_key = key
+ match_snippet = val
+ if match_key is None:
+ return False
+ return match_key, match_snippet
+
+ def placeholderToEnd(self, text, placeholder):
+ '''Returns distance (int) from the first ocurrence of the placeholder, to the end of the string with placeholders removed'''
+ search = re.search(placeholder, text)
+ if not search:
+ return -1
+ from_start = search.start()
+ total = len(re.sub(placeholder, "", text))
+ to_end = total - from_start
+ return to_end
+
+ def addSnippetText(self, snippet_text):
+ ''' Adds the selected text as a snippet (taking care of $$, $name$ etc) to the script editor '''
+ cursor_placeholder_find = r"(? 1:
+ cursor_len = positions[1] - positions[0] - 2
+
+ text = re.sub(cursor_placeholder_find, "", text)
+ self.cursor.insertText(text)
+ if placeholder_to_end >= 0:
+ for i in range(placeholder_to_end):
+ self.cursor.movePosition(QtGui.QTextCursor.PreviousCharacter)
+ for i in range(cursor_len):
+ self.cursor.movePosition(
+ QtGui.QTextCursor.NextCharacter, QtGui.QTextCursor.KeepAnchor)
+ self.setTextCursor(self.cursor)
+
+ def keyPressEvent(self, event):
+
+ ctrl = bool(event.modifiers() & Qt.ControlModifier)
+ alt = bool(event.modifiers() & Qt.AltModifier)
+ shift = bool(event.modifiers() & Qt.ShiftModifier)
+ key = event.key()
+
+ # ADAPTED FROM NUKE's SCRIPT EDITOR:
+ # Get completer state
+ self.nukeCompleterShowing = self.nukeCompleter.popup().isVisible()
+
+ # BEFORE ANYTHING ELSE, IF SPECIAL MODIFIERS SIMPLY IGNORE THE REST
+ if not self.nukeCompleterShowing and (ctrl or shift or alt):
+ # Bypassed!
+ if key not in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
+ KnobScripterTextEdit.keyPressEvent(self, event)
+ return
+
+ # If the completer is showing
+ if self.nukeCompleterShowing:
+ tc = self.textCursor()
+ # If we're hitting enter, do completion
+ if key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
+ if not self.currentNukeCompletion:
+ self.nukeCompleter.setCurrentRow(0)
+ self.currentNukeCompletion = self.nukeCompleter.currentCompletion()
+ # print str(self.nukeCompleter.completionModel[0])
+ self.insertNukeCompletion(self.currentNukeCompletion)
+ self.nukeCompleter.popup().hide()
+ self.nukeCompleterShowing = False
+ # If you're hitting right or escape, hide the popup
+ elif key == Qt.Key_Right or key == Qt.Key_Escape:
+ self.nukeCompleter.popup().hide()
+ self.nukeCompleterShowing = False
+ # If you hit tab, escape or ctrl-space, hide the completer
+ elif key == Qt.Key_Tab or key == Qt.Key_Escape or (ctrl and key == Qt.Key_Space):
+ self.currentNukeCompletion = ""
+ self.nukeCompleter.popup().hide()
+ self.nukeCompleterShowing = False
+ # If none of the above, update the completion model
+ else:
+ QtWidgets.QPlainTextEdit.keyPressEvent(self, event)
+ # Edit completion model
+ colNum = tc.columnNumber()
+ posNum = tc.position()
+ inputText = self.toPlainText()
+ inputTextSplit = inputText.splitlines()
+ runningLength = 0
+ currentLine = None
+ for line in inputTextSplit:
+ length = len(line)
+ runningLength += length
+ if runningLength >= posNum:
+ currentLine = line
+ break
+ runningLength += 1
+ if currentLine:
+ completionPart = currentLine.split(" ")[-1]
+ if "(" in completionPart:
+ completionPart = completionPart.split("(")[-1]
+ self.completeNukePartUnderCursor(completionPart)
+ return
+
+ if type(event) == QtGui.QKeyEvent:
+ if key == Qt.Key_Escape: # Close the knobscripter...
+ self.knobScripter.close()
+ elif not ctrl and not alt and not shift and event.key() == Qt.Key_Tab:
+ self.placeholder = "$$"
+ # 1. Set the cursor
+ self.cursor = self.textCursor()
+
+ # 2. Save text before and after
+ cpos = self.cursor.position()
+ text_before_cursor = self.toPlainText()[:cpos]
+ line_before_cursor = text_before_cursor.split('\n')[-1]
+ text_after_cursor = self.toPlainText()[cpos:]
+
+ # 3. Check coincidences in snippets dicts
+ try: # Meaning snippet found
+ match_key, match_snippet = self.findLongestEndingMatch(
+ line_before_cursor, self.knobScripter.snippets)
+ for i in range(len(match_key)):
+ self.cursor.deletePreviousChar()
+ # This function takes care of adding the appropriate snippet and moving the cursor...
+ self.addSnippetText(match_snippet)
+ except: # Meaning snippet not found...
+ # ADAPTED FROM NUKE's SCRIPT EDITOR:
+ tc = self.textCursor()
+ allCode = self.toPlainText()
+ colNum = tc.columnNumber()
+ posNum = tc.position()
+
+ # ...and if there's text in the editor
+ if len(allCode.split()) > 0:
+ # There is text in the editor
+ currentLine = tc.block().text()
+
+ # If you're not at the end of the line just add a tab
+ if colNum < len(currentLine):
+ # If there isn't a ')' directly to the right of the cursor add a tab
+ if currentLine[colNum:colNum + 1] != ')':
+ KnobScripterTextEdit.keyPressEvent(self, event)
+ return
+ # Else show the completer
+ else:
+ completionPart = currentLine[:colNum].split(
+ " ")[-1]
+ if "(" in completionPart:
+ completionPart = completionPart.split(
+ "(")[-1]
+
+ self.completeNukePartUnderCursor(
+ completionPart)
+
+ return
+
+ # If you are at the end of the line,
+ else:
+ # If there's nothing to the right of you add a tab
+ if currentLine[colNum - 1:] == "" or currentLine.endswith(" "):
+ KnobScripterTextEdit.keyPressEvent(self, event)
+ return
+ # Else update completionPart and show the completer
+ completionPart = currentLine.split(" ")[-1]
+ if "(" in completionPart:
+ completionPart = completionPart.split("(")[-1]
+
+ self.completeNukePartUnderCursor(completionPart)
+ return
+
+ KnobScripterTextEdit.keyPressEvent(self, event)
+ elif event.key() in [Qt.Key_Enter, Qt.Key_Return]:
+ modifiers = QtWidgets.QApplication.keyboardModifiers()
+ if modifiers == QtCore.Qt.ControlModifier:
+ self.runScript()
+ else:
+ KnobScripterTextEdit.keyPressEvent(self, event)
+ else:
+ KnobScripterTextEdit.keyPressEvent(self, event)
+
+ def getPyObjects(self, text):
+ ''' Returns a list containing all the functions, classes and variables found within the selected python text (code) '''
+ matches = []
+ # 1: Remove text inside triple quotes (leaving the quotes)
+ text_clean = '""'.join(text.split('"""')[::2])
+ text_clean = '""'.join(text_clean.split("'''")[::2])
+
+ # 2: Remove text inside of quotes (leaving the quotes) except if \"
+ lines = text_clean.split("\n")
+ text_clean = ""
+ for line in lines:
+ line_clean = '""'.join(line.split('"')[::2])
+ line_clean = '""'.join(line_clean.split("'")[::2])
+ line_clean = line_clean.split("#")[0]
+ text_clean += line_clean + "\n"
+
+ # 3. Split into segments (lines plus ";")
+ segments = re.findall(r"[^\n;]+", text_clean)
+
+ # 4. Go case by case.
+ for s in segments:
+ # Declared vars
+ matches += re.findall(r"([\w\.]+)(?=[,\s\w]*=[^=]+$)", s)
+ # Def functions and arguments
+ function = re.findall(r"[\s]*def[\s]+([\w\.]+)[\s]*\([\s]*", s)
+ if len(function):
+ matches += function
+ args = re.split(r"[\s]*def[\s]+([\w\.]+)[\s]*\([\s]*", s)
+ if len(args) > 1:
+ args = args[-1]
+ matches += re.findall(
+ r"(?adrianpueyo.com, 2016-2019')
+ kspSignature.setOpenExternalLinks(True)
+ kspSignature.setStyleSheet('''color:#555;font-size:9px;''')
+ kspSignature.setAlignment(QtCore.Qt.AlignRight)
+
+ fontLabel = QtWidgets.QLabel("Font:")
+ self.fontBox = QtWidgets.QFontComboBox()
+ self.fontBox.setCurrentFont(QtGui.QFont(self.font))
+ self.fontBox.currentFontChanged.connect(self.fontChanged)
+
+ fontSizeLabel = QtWidgets.QLabel("Font size:")
+ self.fontSizeBox = QtWidgets.QSpinBox()
+ self.fontSizeBox.setValue(self.oldFontSize)
+ self.fontSizeBox.setMinimum(6)
+ self.fontSizeBox.setMaximum(100)
+ self.fontSizeBox.valueChanged.connect(self.fontSizeChanged)
+
+ windowWLabel = QtWidgets.QLabel("Width (px):")
+ windowWLabel.setToolTip("Default window width in pixels")
+ self.windowWBox = QtWidgets.QSpinBox()
+ self.windowWBox.setValue(self.knobScripter.windowDefaultSize[0])
+ self.windowWBox.setMinimum(200)
+ self.windowWBox.setMaximum(4000)
+ self.windowWBox.setToolTip("Default window width in pixels")
+
+ windowHLabel = QtWidgets.QLabel("Height (px):")
+ windowHLabel.setToolTip("Default window height in pixels")
+ self.windowHBox = QtWidgets.QSpinBox()
+ self.windowHBox.setValue(self.knobScripter.windowDefaultSize[1])
+ self.windowHBox.setMinimum(100)
+ self.windowHBox.setMaximum(2000)
+ self.windowHBox.setToolTip("Default window height in pixels")
+
+ # TODO: "Grab current dimensions" button
+
+ tabSpaceLabel = QtWidgets.QLabel("Tab spaces:")
+ tabSpaceLabel.setToolTip("Number of spaces to add with the tab key.")
+ self.tabSpace2 = QtWidgets.QRadioButton("2")
+ self.tabSpace4 = QtWidgets.QRadioButton("4")
+ tabSpaceButtonGroup = QtWidgets.QButtonGroup(self)
+ tabSpaceButtonGroup.addButton(self.tabSpace2)
+ tabSpaceButtonGroup.addButton(self.tabSpace4)
+ self.tabSpace2.setChecked(self.knobScripter.tabSpaces == 2)
+ self.tabSpace4.setChecked(self.knobScripter.tabSpaces == 4)
+
+ pinDefaultLabel = QtWidgets.QLabel("Always on top:")
+ pinDefaultLabel.setToolTip("Default mode of the PIN toggle.")
+ self.pinDefaultOn = QtWidgets.QRadioButton("On")
+ self.pinDefaultOff = QtWidgets.QRadioButton("Off")
+ pinDefaultButtonGroup = QtWidgets.QButtonGroup(self)
+ pinDefaultButtonGroup.addButton(self.pinDefaultOn)
+ pinDefaultButtonGroup.addButton(self.pinDefaultOff)
+ self.pinDefaultOn.setChecked(self.knobScripter.pinned == True)
+ self.pinDefaultOff.setChecked(self.knobScripter.pinned == False)
+ self.pinDefaultOn.clicked.connect(lambda: self.knobScripter.pin(True))
+ self.pinDefaultOff.clicked.connect(
+ lambda: self.knobScripter.pin(False))
+
+ colorSchemeLabel = QtWidgets.QLabel("Color scheme:")
+ colorSchemeLabel.setToolTip("Syntax highlighting text style.")
+ self.colorSchemeSublime = QtWidgets.QRadioButton("subl")
+ self.colorSchemeNuke = QtWidgets.QRadioButton("nuke")
+ colorSchemeButtonGroup = QtWidgets.QButtonGroup(self)
+ colorSchemeButtonGroup.addButton(self.colorSchemeSublime)
+ colorSchemeButtonGroup.addButton(self.colorSchemeNuke)
+ colorSchemeButtonGroup.buttonClicked.connect(self.colorSchemeChanged)
+ self.colorSchemeSublime.setChecked(
+ self.knobScripter.color_scheme == "sublime")
+ self.colorSchemeNuke.setChecked(
+ self.knobScripter.color_scheme == "nuke")
+
+ showLabelsLabel = QtWidgets.QLabel("Show labels:")
+ showLabelsLabel.setToolTip(
+ "Display knob labels on the knob dropdown\nOtherwise, shows the internal name only.")
+ self.showLabelsOn = QtWidgets.QRadioButton("On")
+ self.showLabelsOff = QtWidgets.QRadioButton("Off")
+ showLabelsButtonGroup = QtWidgets.QButtonGroup(self)
+ showLabelsButtonGroup.addButton(self.showLabelsOn)
+ showLabelsButtonGroup.addButton(self.showLabelsOff)
+ self.showLabelsOn.setChecked(self.knobScripter.pinned == True)
+ self.showLabelsOff.setChecked(self.knobScripter.pinned == False)
+ self.showLabelsOn.clicked.connect(lambda: self.knobScripter.pin(True))
+ self.showLabelsOff.clicked.connect(
+ lambda: self.knobScripter.pin(False))
+
+ self.buttonBox = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
+ self.buttonBox.accepted.connect(self.savePrefs)
+ self.buttonBox.rejected.connect(self.cancelPrefs)
+
+ # Loaded custom values
+ self.ksPrefs = self.knobScripter.loadPrefs()
+ if self.ksPrefs != []:
+ try:
+ self.fontSizeBox.setValue(self.ksPrefs['font_size'])
+ self.windowWBox.setValue(self.ksPrefs['window_default_w'])
+ self.windowHBox.setValue(self.ksPrefs['window_default_h'])
+ self.tabSpace2.setChecked(self.ksPrefs['tab_spaces'] == 2)
+ self.tabSpace4.setChecked(self.ksPrefs['tab_spaces'] == 4)
+ self.pinDefaultOn.setChecked(self.ksPrefs['pin_default'] == 1)
+ self.pinDefaultOff.setChecked(self.ksPrefs['pin_default'] == 0)
+ self.showLabelsOn.setChecked(self.ksPrefs['show_labels'] == 1)
+ self.showLabelsOff.setChecked(self.ksPrefs['show_labels'] == 0)
+ self.colorSchemeSublime.setChecked(
+ self.ksPrefs['color_scheme'] == "sublime")
+ self.colorSchemeNuke.setChecked(
+ self.ksPrefs['color_scheme'] == "nuke")
+ except:
+ pass
+
+ # Layouts
+ font_layout = QtWidgets.QHBoxLayout()
+ font_layout.addWidget(fontLabel)
+ font_layout.addWidget(self.fontBox)
+
+ fontSize_layout = QtWidgets.QHBoxLayout()
+ fontSize_layout.addWidget(fontSizeLabel)
+ fontSize_layout.addWidget(self.fontSizeBox)
+
+ windowW_layout = QtWidgets.QHBoxLayout()
+ windowW_layout.addWidget(windowWLabel)
+ windowW_layout.addWidget(self.windowWBox)
+
+ windowH_layout = QtWidgets.QHBoxLayout()
+ windowH_layout.addWidget(windowHLabel)
+ windowH_layout.addWidget(self.windowHBox)
+
+ tabSpacesButtons_layout = QtWidgets.QHBoxLayout()
+ tabSpacesButtons_layout.addWidget(self.tabSpace2)
+ tabSpacesButtons_layout.addWidget(self.tabSpace4)
+ tabSpaces_layout = QtWidgets.QHBoxLayout()
+ tabSpaces_layout.addWidget(tabSpaceLabel)
+ tabSpaces_layout.addLayout(tabSpacesButtons_layout)
+
+ pinDefaultButtons_layout = QtWidgets.QHBoxLayout()
+ pinDefaultButtons_layout.addWidget(self.pinDefaultOn)
+ pinDefaultButtons_layout.addWidget(self.pinDefaultOff)
+ pinDefault_layout = QtWidgets.QHBoxLayout()
+ pinDefault_layout.addWidget(pinDefaultLabel)
+ pinDefault_layout.addLayout(pinDefaultButtons_layout)
+
+ showLabelsButtons_layout = QtWidgets.QHBoxLayout()
+ showLabelsButtons_layout.addWidget(self.showLabelsOn)
+ showLabelsButtons_layout.addWidget(self.showLabelsOff)
+ showLabels_layout = QtWidgets.QHBoxLayout()
+ showLabels_layout.addWidget(showLabelsLabel)
+ showLabels_layout.addLayout(showLabelsButtons_layout)
+
+ colorSchemeButtons_layout = QtWidgets.QHBoxLayout()
+ colorSchemeButtons_layout.addWidget(self.colorSchemeSublime)
+ colorSchemeButtons_layout.addWidget(self.colorSchemeNuke)
+ colorScheme_layout = QtWidgets.QHBoxLayout()
+ colorScheme_layout.addWidget(colorSchemeLabel)
+ colorScheme_layout.addLayout(colorSchemeButtons_layout)
+
+ self.master_layout = QtWidgets.QVBoxLayout()
+ self.master_layout.addWidget(kspTitle)
+ self.master_layout.addWidget(kspSignature)
+ self.master_layout.addWidget(kspLine)
+ self.master_layout.addLayout(font_layout)
+ self.master_layout.addLayout(fontSize_layout)
+ self.master_layout.addLayout(windowW_layout)
+ self.master_layout.addLayout(windowH_layout)
+ self.master_layout.addLayout(tabSpaces_layout)
+ self.master_layout.addLayout(pinDefault_layout)
+ self.master_layout.addLayout(showLabels_layout)
+ self.master_layout.addLayout(colorScheme_layout)
+ self.master_layout.addWidget(self.buttonBox)
+ self.setLayout(self.master_layout)
+ self.setFixedSize(self.minimumSize())
+
+ def savePrefs(self):
+ self.font = self.fontBox.currentFont().family()
+ ks_prefs = {
+ 'font_size': self.fontSizeBox.value(),
+ 'window_default_w': self.windowWBox.value(),
+ 'window_default_h': self.windowHBox.value(),
+ 'tab_spaces': self.tabSpaceValue(),
+ 'pin_default': self.pinDefaultValue(),
+ 'show_labels': self.showLabelsValue(),
+ 'font': self.font,
+ 'color_scheme': self.colorSchemeValue(),
+ }
+ self.knobScripter.script_editor_font.setFamily(self.font)
+ self.knobScripter.script_editor.setFont(
+ self.knobScripter.script_editor_font)
+ self.knobScripter.font = self.font
+ self.knobScripter.color_scheme = self.colorSchemeValue()
+ self.knobScripter.tabSpaces = self.tabSpaceValue()
+ self.knobScripter.script_editor.tabSpaces = self.tabSpaceValue()
+ with open(self.prefs_txt, "w") as f:
+ prefs = json.dump(ks_prefs, f, sort_keys=True, indent=4)
+ self.accept()
+ self.knobScripter.highlighter.rehighlight()
+ self.knobScripter.show_labels = self.showLabelsValue()
+ if self.knobScripter.nodeMode:
+ self.knobScripter.refreshClicked()
+ return prefs
+
+ def cancelPrefs(self):
+ self.knobScripter.script_editor_font.setPointSize(self.oldFontSize)
+ self.knobScripter.script_editor.setFont(
+ self.knobScripter.script_editor_font)
+ self.knobScripter.color_scheme = self.oldScheme
+ self.knobScripter.highlighter.rehighlight()
+ self.reject()
+
+ def fontSizeChanged(self):
+ self.knobScripter.script_editor_font.setPointSize(
+ self.fontSizeBox.value())
+ self.knobScripter.script_editor.setFont(
+ self.knobScripter.script_editor_font)
+ return
+
+ def fontChanged(self):
+ self.font = self.fontBox.currentFont().family()
+ self.knobScripter.script_editor_font.setFamily(self.font)
+ self.knobScripter.script_editor.setFont(
+ self.knobScripter.script_editor_font)
+ return
+
+ def colorSchemeChanged(self):
+ self.knobScripter.color_scheme = self.colorSchemeValue()
+ self.knobScripter.highlighter.rehighlight()
+ return
+
+ def tabSpaceValue(self):
+ return 2 if self.tabSpace2.isChecked() else 4
+
+ def pinDefaultValue(self):
+ return 1 if self.pinDefaultOn.isChecked() else 0
+
+ def showLabelsValue(self):
+ return 1 if self.showLabelsOn.isChecked() else 0
+
+ def colorSchemeValue(self):
+ return "nuke" if self.colorSchemeNuke.isChecked() else "sublime"
+
+ def closeEvent(self, event):
+ self.cancelPrefs()
+ self.close()
+
+
+def updateContext():
+ '''
+ Get the current selection of nodes with their appropiate context
+ Doing this outside the KnobScripter -> forces context update inside groups when needed
+ '''
+ global knobScripterSelectedNodes
+ knobScripterSelectedNodes = nuke.selectedNodes()
+ return
+
+# --------------------------------
+# FindReplace
+# --------------------------------
+
+
+class FindReplaceWidget(QtWidgets.QWidget):
+ ''' SearchReplace Widget for the knobscripter. FindReplaceWidget(editor = QPlainTextEdit) '''
+
+ def __init__(self, parent):
+ super(FindReplaceWidget, self).__init__(parent)
+
+ self.editor = parent.script_editor
+
+ self.initUI()
+
+ def initUI(self):
+
+ # --------------
+ # Find Row
+ # --------------
+
+ # Widgets
+ self.find_label = QtWidgets.QLabel("Find:")
+ # self.find_label.setSizePolicy(QtWidgets.QSizePolicy.Fixed,QtWidgets.QSizePolicy.Fixed)
+ self.find_label.setFixedWidth(50)
+ self.find_label.setAlignment(
+ QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ self.find_lineEdit = QtWidgets.QLineEdit()
+ self.find_next_button = QtWidgets.QPushButton("Next")
+ self.find_next_button.clicked.connect(self.find)
+ self.find_prev_button = QtWidgets.QPushButton("Previous")
+ self.find_prev_button.clicked.connect(self.findBack)
+ self.find_lineEdit.returnPressed.connect(self.find_next_button.click)
+
+ # Layout
+ self.find_layout = QtWidgets.QHBoxLayout()
+ self.find_layout.addWidget(self.find_label)
+ self.find_layout.addWidget(self.find_lineEdit, stretch=1)
+ self.find_layout.addWidget(self.find_next_button)
+ self.find_layout.addWidget(self.find_prev_button)
+
+ # --------------
+ # Replace Row
+ # --------------
+
+ # Widgets
+ self.replace_label = QtWidgets.QLabel("Replace:")
+ # self.replace_label.setSizePolicy(QtWidgets.QSizePolicy.Fixed,QtWidgets.QSizePolicy.Fixed)
+ self.replace_label.setFixedWidth(50)
+ self.replace_label.setAlignment(
+ QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ self.replace_lineEdit = QtWidgets.QLineEdit()
+ self.replace_button = QtWidgets.QPushButton("Replace")
+ self.replace_button.clicked.connect(self.replace)
+ self.replace_all_button = QtWidgets.QPushButton("Replace All")
+ self.replace_all_button.clicked.connect(
+ lambda: self.replace(rep_all=True))
+ self.replace_lineEdit.returnPressed.connect(self.replace_button.click)
+
+ # Layout
+ self.replace_layout = QtWidgets.QHBoxLayout()
+ self.replace_layout.addWidget(self.replace_label)
+ self.replace_layout.addWidget(self.replace_lineEdit, stretch=1)
+ self.replace_layout.addWidget(self.replace_button)
+ self.replace_layout.addWidget(self.replace_all_button)
+
+ # Info text
+ self.info_text = QtWidgets.QLabel("")
+ self.info_text.setVisible(False)
+ self.info_text.mousePressEvent = lambda x: self.info_text.setVisible(
+ False)
+ #f = self.info_text.font()
+ # f.setItalic(True)
+ # self.info_text.setFont(f)
+ # self.info_text.clicked.connect(lambda:self.info_text.setVisible(False))
+
+ # Divider line
+ line = QtWidgets.QFrame()
+ line.setFrameShape(QtWidgets.QFrame.HLine)
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ line.setLineWidth(0)
+ line.setMidLineWidth(1)
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
+
+ # --------------
+ # Main Layout
+ # --------------
+
+ self.layout = QtWidgets.QVBoxLayout()
+ self.layout.addSpacing(4)
+ self.layout.addWidget(self.info_text)
+ self.layout.addLayout(self.find_layout)
+ self.layout.addLayout(self.replace_layout)
+ self.layout.setSpacing(4)
+ try: # >n11
+ self.layout.setMargin(2)
+ except: # 0: # If not found but there are matches, start over
+ cursor.movePosition(QtGui.QTextCursor.Start)
+ self.editor.setTextCursor(cursor)
+ self.editor.find(find_str, flags)
+ else:
+ cursor.insertText(rep_str)
+ self.editor.find(
+ rep_str, flags | QtGui.QTextDocument.FindBackward)
+
+ cursor.endEditBlock()
+ self.replace_lineEdit.setFocus()
+ return
+
+
+# --------------------------------
+# Snippets
+# --------------------------------
+class SnippetsPanel(QtWidgets.QDialog):
+ def __init__(self, parent):
+ super(SnippetsPanel, self).__init__(parent)
+
+ self.knobScripter = parent
+
+ self.setWindowFlags(self.windowFlags() |
+ QtCore.Qt.WindowStaysOnTopHint)
+ self.setWindowTitle("Snippet editor")
+
+ self.snippets_txt_path = self.knobScripter.snippets_txt_path
+ self.snippets_dict = self.loadSnippetsDict(path=self.snippets_txt_path)
+ #self.snippets_dict = snippets_dic
+
+ # self.saveSnippets(snippets_dic)
+
+ self.initUI()
+ self.resize(500, 300)
+
+ def initUI(self):
+ self.layout = QtWidgets.QVBoxLayout()
+
+ # First Area (Titles)
+ title_layout = QtWidgets.QHBoxLayout()
+ shortcuts_label = QtWidgets.QLabel("Shortcut")
+ code_label = QtWidgets.QLabel("Code snippet")
+ title_layout.addWidget(shortcuts_label, stretch=1)
+ title_layout.addWidget(code_label, stretch=2)
+ self.layout.addLayout(title_layout)
+
+ # Main Scroll area
+ self.scroll_content = QtWidgets.QWidget()
+ self.scroll_layout = QtWidgets.QVBoxLayout()
+
+ self.buildSnippetWidgets()
+
+ self.scroll_content.setLayout(self.scroll_layout)
+
+ # Scroll Area Properties
+ self.scroll = QtWidgets.QScrollArea()
+ self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setWidget(self.scroll_content)
+
+ self.layout.addWidget(self.scroll)
+
+ # File knob test
+ #self.filePath_lineEdit = SnippetFilePath(self)
+ # self.filePath_lineEdit
+ # self.layout.addWidget(self.filePath_lineEdit)
+
+ # Lower buttons
+ self.bottom_layout = QtWidgets.QHBoxLayout()
+
+ self.add_btn = QtWidgets.QPushButton("Add snippet")
+ self.add_btn.setToolTip("Create empty fields for an extra snippet.")
+ self.add_btn.clicked.connect(self.addSnippet)
+ self.bottom_layout.addWidget(self.add_btn)
+
+ self.addPath_btn = QtWidgets.QPushButton("Add custom path")
+ self.addPath_btn.setToolTip(
+ "Add a custom path to an external snippets .txt file.")
+ self.addPath_btn.clicked.connect(self.addCustomPath)
+ self.bottom_layout.addWidget(self.addPath_btn)
+
+ self.bottom_layout.addStretch()
+
+ self.save_btn = QtWidgets.QPushButton('OK')
+ self.save_btn.setToolTip(
+ "Save the snippets into a json file and close the panel.")
+ self.save_btn.clicked.connect(self.okPressed)
+ self.bottom_layout.addWidget(self.save_btn)
+
+ self.cancel_btn = QtWidgets.QPushButton("Cancel")
+ self.cancel_btn.setToolTip("Cancel any new snippets or modifications.")
+ self.cancel_btn.clicked.connect(self.close)
+ self.bottom_layout.addWidget(self.cancel_btn)
+
+ self.apply_btn = QtWidgets.QPushButton('Apply')
+ self.apply_btn.setToolTip("Save the snippets into a json file.")
+ self.apply_btn.setShortcut('Ctrl+S')
+ self.apply_btn.clicked.connect(self.applySnippets)
+ self.bottom_layout.addWidget(self.apply_btn)
+
+ self.help_btn = QtWidgets.QPushButton('Help')
+ self.help_btn.setShortcut('F1')
+ self.help_btn.clicked.connect(self.showHelp)
+ self.bottom_layout.addWidget(self.help_btn)
+
+ self.layout.addLayout(self.bottom_layout)
+
+ self.setLayout(self.layout)
+
+ def reload(self):
+ '''
+ Clears everything without saving and redoes the widgets etc.
+ Only to be called if the panel isn't shown meaning it's closed.
+ '''
+ for i in reversed(range(self.scroll_layout.count())):
+ self.scroll_layout.itemAt(i).widget().deleteLater()
+
+ self.snippets_dict = self.loadSnippetsDict(path=self.snippets_txt_path)
+
+ self.buildSnippetWidgets()
+
+ def buildSnippetWidgets(self):
+ for i, (key, val) in enumerate(self.snippets_dict.items()):
+ if re.match(r"\[custom-path-[0-9]+\]$", key):
+ file_edit = SnippetFilePath(val)
+ self.scroll_layout.insertWidget(-1, file_edit)
+ else:
+ snippet_edit = SnippetEdit(key, val, parent=self)
+ self.scroll_layout.insertWidget(-1, snippet_edit)
+
+ def loadSnippetsDict(self, path=""):
+ ''' Load prefs. TO REMOVE '''
+ if path == "":
+ path = self.knobScripter.snippets_txt_path
+ if not os.path.isfile(self.snippets_txt_path):
+ return {}
+ else:
+ with open(self.snippets_txt_path, "r") as f:
+ self.snippets = json.load(f)
+ return self.snippets
+
+ def getSnippetsAsDict(self):
+ dic = {}
+ num_snippets = self.scroll_layout.count()
+ path_i = 1
+ for s in range(num_snippets):
+ se = self.scroll_layout.itemAt(s).widget()
+ if se.__class__.__name__ == "SnippetEdit":
+ key = se.shortcut_editor.text()
+ val = se.script_editor.toPlainText()
+ if key != "":
+ dic[key] = val
+ else:
+ path = se.filepath_lineEdit.text()
+ if path != "":
+ dic["[custom-path-{}]".format(str(path_i))] = path
+ path_i += 1
+ return dic
+
+ def saveSnippets(self, snippets=""):
+ if snippets == "":
+ snippets = self.getSnippetsAsDict()
+ with open(self.snippets_txt_path, "w") as f:
+ prefs = json.dump(snippets, f, sort_keys=True, indent=4)
+ return prefs
+
+ def applySnippets(self):
+ self.saveSnippets()
+ self.knobScripter.snippets = self.knobScripter.loadSnippets(maxDepth=5)
+ self.knobScripter.loadSnippets()
+
+ def okPressed(self):
+ self.applySnippets()
+ self.accept()
+
+ def addSnippet(self, key="", val=""):
+ se = SnippetEdit(key, val, parent=self)
+ self.scroll_layout.insertWidget(0, se)
+ self.show()
+ return se
+
+ def addCustomPath(self, path=""):
+ cpe = SnippetFilePath(path)
+ self.scroll_layout.insertWidget(0, cpe)
+ self.show()
+ cpe.browseSnippets()
+ return cpe
+
+ def showHelp(self):
+ ''' Create a new snippet, auto-completed with the help '''
+ help_key = "help"
+ help_val = """Snippets are a convenient way to have code blocks that you can call through a shortcut.\n\n1. Simply write a shortcut on the text input field on the left. You can see this one is set to "test".\n\n2. Then, write a code or whatever in this script editor. You can include $$ as the placeholder for where you'll want the mouse cursor to appear.\n\n3. Finally, click OK or Apply to save the snippets. On the main script editor, you'll be able to call any snippet by writing the shortcut (in this example: help) and pressing the Tab key.\n\nIn order to remove a snippet, simply leave the shortcut and contents blank, and save the snippets."""
+ help_se = self.addSnippet(help_key, help_val)
+ help_se.script_editor.resize(160, 160)
+
+
+class SnippetEdit(QtWidgets.QWidget):
+ ''' Simple widget containing two fields, for the snippet shortcut and content '''
+
+ def __init__(self, key="", val="", parent=None):
+ super(SnippetEdit, self).__init__(parent)
+
+ self.knobScripter = parent.knobScripter
+ self.color_scheme = self.knobScripter.color_scheme
+ self.layout = QtWidgets.QHBoxLayout()
+
+ self.shortcut_editor = QtWidgets.QLineEdit(self)
+ f = self.shortcut_editor.font()
+ f.setWeight(QtGui.QFont.Bold)
+ self.shortcut_editor.setFont(f)
+ self.shortcut_editor.setText(str(key))
+ #self.script_editor = QtWidgets.QTextEdit(self)
+ self.script_editor = KnobScripterTextEdit()
+ self.script_editor.setMinimumHeight(100)
+ self.script_editor.setStyleSheet(
+ 'background:#282828;color:#EEE;') # Main Colors
+ self.highlighter = KSScriptEditorHighlighter(
+ self.script_editor.document(), self)
+ self.script_editor_font = self.knobScripter.script_editor_font
+ self.script_editor.setFont(self.script_editor_font)
+ self.script_editor.resize(90, 90)
+ self.script_editor.setPlainText(str(val))
+ self.layout.addWidget(self.shortcut_editor,
+ stretch=1, alignment=Qt.AlignTop)
+ self.layout.addWidget(self.script_editor, stretch=2)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+
+ self.setLayout(self.layout)
+
+
+class SnippetFilePath(QtWidgets.QWidget):
+ ''' Simple widget containing a filepath lineEdit and a button to open the file browser '''
+
+ def __init__(self, path="", parent=None):
+ super(SnippetFilePath, self).__init__(parent)
+
+ self.layout = QtWidgets.QHBoxLayout()
+
+ self.custompath_label = QtWidgets.QLabel(self)
+ self.custompath_label.setText("Custom path: ")
+
+ self.filepath_lineEdit = QtWidgets.QLineEdit(self)
+ self.filepath_lineEdit.setText(str(path))
+ #self.script_editor = QtWidgets.QTextEdit(self)
+ self.filepath_lineEdit.setStyleSheet(
+ 'background:#282828;color:#EEE;') # Main Colors
+ self.script_editor_font = QtGui.QFont()
+ self.script_editor_font.setFamily("Courier")
+ self.script_editor_font.setStyleHint(QtGui.QFont.Monospace)
+ self.script_editor_font.setFixedPitch(True)
+ self.script_editor_font.setPointSize(11)
+ self.filepath_lineEdit.setFont(self.script_editor_font)
+
+ self.file_button = QtWidgets.QPushButton(self)
+ self.file_button.setText("Browse...")
+ self.file_button.clicked.connect(self.browseSnippets)
+
+ self.layout.addWidget(self.custompath_label)
+ self.layout.addWidget(self.filepath_lineEdit)
+ self.layout.addWidget(self.file_button)
+ self.layout.setContentsMargins(0, 10, 0, 10)
+
+ self.setLayout(self.layout)
+
+ def browseSnippets(self):
+ ''' Opens file panel for ...snippets.txt '''
+ browseLocation = nuke.getFilename('Select snippets file', '*.txt')
+
+ if not browseLocation:
+ return
+
+ self.filepath_lineEdit.setText(browseLocation)
+ return
+
+
+# --------------------------------
+# Implementation
+# --------------------------------
+
+def showKnobScripter(knob="knobChanged"):
+ selection = nuke.selectedNodes()
+ if not len(selection):
+ pan = KnobScripter()
+ else:
+ pan = KnobScripter(selection[0], knob)
+ pan.show()
+
+
+def addKnobScripterPanel():
+ global knobScripterPanel
+ try:
+ knobScripterPanel = panels.registerWidgetAsPanel('nuke.KnobScripterPane', 'Knob Scripter',
+ 'com.adrianpueyo.KnobScripterPane')
+ knobScripterPanel.addToPane(nuke.getPaneFor('Properties.1'))
+
+ except:
+ knobScripterPanel = panels.registerWidgetAsPanel(
+ 'nuke.KnobScripterPane', 'Knob Scripter', 'com.adrianpueyo.KnobScripterPane')
+
+
+nuke.KnobScripterPane = KnobScripterPane
+log("KS LOADED")
+ksShortcut = "alt+z"
+addKnobScripterPanel()
+nuke.menu('Nuke').addCommand(
+ 'Edit/Node/Open Floating Knob Scripter', showKnobScripter, ksShortcut)
+nuke.menu('Nuke').addCommand('Edit/Node/Update KnobScripter Context',
+ updateContext).setVisible(False)
diff --git a/setup/nuke/nuke_path/init.py b/setup/nuke/nuke_path/init.py
new file mode 100644
index 0000000000..0ea5d1ad7d
--- /dev/null
+++ b/setup/nuke/nuke_path/init.py
@@ -0,0 +1,2 @@
+# default write mov
+nuke.knobDefault('Write.mov.colorspace', 'sRGB')
diff --git a/setup/nuke/nuke_path/menu.py b/setup/nuke/nuke_path/menu.py
index fd87c98246..7f5de6013d 100644
--- a/setup/nuke/nuke_path/menu.py
+++ b/setup/nuke/nuke_path/menu.py
@@ -1,4 +1,7 @@
+import os
+import sys
import atom_server
+import KnobScripter
from pype.nuke.lib import (
writes_version_sync,
@@ -16,6 +19,6 @@ log = Logger().get_logger(__name__, "nuke")
nuke.addOnScriptSave(onScriptLoad)
nuke.addOnScriptLoad(checkInventoryVersions)
nuke.addOnScriptSave(checkInventoryVersions)
-nuke.addOnScriptSave(writes_version_sync)
+# nuke.addOnScriptSave(writes_version_sync)
log.info('Automatic syncing of write file knob to script version')