Pyblish for Cinema 4D

Hi all,

I while ago I’ve started to tinker with Pyblish and how to incorporate it into Cinema 4D.
Think this would be very beneficial to our pipeline, which hopefully can translate to other teams.

Edit: Using Cinema 4D R19.053 for this.

As you probably know Cinema’s GUI environment does not allow to incorporate PyQt tools, so is my understanding that it only leaves a chance of running pyblish-qml to be used as an external GUI solution.

I will explain below about my setup to make it work and ask some questions along the way. Please correct me if I’m talking nonsense :smile:.

Cinema uses it’s own Python executable (v2.7.9) and the setup will be dedicated around its environment. This will remove any problems from reading modules outside Cinema.

I’m using Windows 7 command line but please feel free to point out any other CMD tools syntax.

With the directory selected install PyQt5 v5.7.1 from here:

C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework> python -m pip install git+git://github.com/pyqt/python-qt5.git

After installing PyQt, download pyblish-qml or pip:

C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework> python -m pip install pyblish-qml

This will install Pyblish QML, but also the Pyblish Base module.

To run in inside Cinema need to create a script to call pyblish. This is my script attempt and where i can definitely will need help to understand:

# Import modules
import os
import pyblish.api as pyblish
import pyblish_qml
from pyblish_qml import api
import c4d

PATH_FILE, FILE_NAME_EXT = os.path.split(__file__)

# Register Cinema 4D Python Exec
api.register_python_executable(os.path.join(c4d.storage.GeGetStartupPath(), "resource", "modules", "python",
                                            "Python.win64.framework", "Python.exe"))

# Register PyQt5 path
api.register_pyqt5(os.path.join(c4d.storage.GeGetStartupPath(), "resource", "modules", "python",
                                "Python.win64.framework", "Lib", "site-packages", "PyQt5"))
# Register Pyblish plugins path
pyblish.register_plugin_path(os.path.join(PATH_FILE, "plugins"))


def main():
    """
    Main function.
    """

    pyblish_qml.show()  # Load Pyblish!


if __name__ == '__main__':

    main()

As my understanding once Pyblish recognises your Python directory it will automatically find the PyQt module as well, which unfortunately didn’t work so needed to register both as above.
I’m also trying to add a custom plugins folder instead of the native path one, included with the Pyblish module.

So once you run the script inside Cinema you have the following log info.

Using Python @ 'C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework\Python.exe'
Using PyQt5 @ 'C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework\Lib\site-packages\PyQt5'
Targets: default
Starting pyblish-qml
Done, don't forget to call `show()`
Entering state: "hidden"
Entering state: "ready"
Entering state: "clean"
Entering state: "alive"
Settings:
HeartbeatInterval = 60
WindowTitle = Pyblish
WindowPosition = [100, 100]
WindowSize = [430, 600]
ContextLabel = Context
HiddenSections = [u'Collect']
Entering state: "visible"
Entering state: "initialising"
Entering state: "collecting"
Spent 288.00 ms resetting
Made 0 requests during publish.
Spent 518.00 ms resetting
Made 0 requests during reset.
Entering state: "ready"
No local comment, reading from context..

It works! For now… :grinning:
So, next steps would be to start adding your own plugins and this is where it started to fail on me on my next post.

In my post above with the register folder above we add a new plugin, as an example we have a “Hello World!” plugin, as per the learn page

import pyblish.api

class MyPlugin(pyblish.api.ContextPlugin):
  def process(self, context):
    print("hello python")

pyblish.api.register_plugin(MyPlugin)

import pyblish.util
pyblish.util.publish()

This is what it outputs:

hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
Using Python @ 'C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework\Python.exe'
Using PyQt5 @ 'C:\Program Files\MAXON\Cinema 4D R19\resource\modules\python\Python.win64.framework\Lib\site-packages\PyQt5'
Targets: default
Starting pyblish-qml
Done, don't forget to call `show()`
Entering state: "hidden"
Entering state: "ready"
Entering state: "clean"
Entering state: "alive"
Settings:
HeartbeatInterval = 60
WindowTitle = Pyblish
WindowPosition = [100, 100]
WindowSize = [430, 600]
ContextLabel = Context
HiddenSections = [u'Collect']
Entering state: "visible"
Entering state: "initialising"
No handlers could be found for logger "pyblish.util"
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
hello python
Entering state: "collecting"
Spent 399.00 ms resetting
Made 0 requests during publish.
Spent 1252.00 ms resetting
Made 0 requests during reset.
Entering state: "ready"
No local comment, reading from context..

Does the collection stage runs immediately after opening the Pyblish? Why would it continue to run the plugin until the state “ready”?

The other thing to mention is the script reload of any changes to the plugins.
We need to close Pyblish and run the script again and even then I’m not sure if it uninstalls it as per the following message Already installed, uninstalling...

It also breaks the other plugins. Is this suppose to happen?

Hopefully this can give us a discussion start to try and resolve these problems! :slightly_smiling_face:

Thank you all for your help! :+1:

Andre

Nice work!

It’s unclear where that output is coming from; from your code snippet, it should only be calling pyblish.util.publish() but from the output it looks like it’s also showing Pyblish QML? Are you sure that’s the only thing that ran to produce that output?

In case it isn’t clear, pyblish.util.publish() is a headless, non-GUI version of the publishing mechanism. It’s is equivalent to publishing from Pyblish QML; you won’t need to call both.

Pyblish QML, the GUI, does automatically trigger collection on opening the GUI; as it’s the only way for it to also display the instances it collected. The alternative would be to have two play buttons; one to collect, and preview what it is about to publish, and a second button to trigger the remaining plug-ins, which probably isn’t what you would expect.

Hi Marcus,

Thank you! :+1:

Run a couple of tests and we have good news!

Following the “Hello World!” I’ve resolved the duplication issues, as you clearly and correctly put it.
So pyblish.util.publish() was the culprit and once is remove from the test plugin the collection is made with no issues to all of the included plugins, as below.

This tells me that Cinema is speaking correctly with Pyblish and I think we have a big change of using it without a lot of fussing around. :slightly_smiling_face:

I’m going to test some more with Cinema specific examples and see how it goes. Will keep you posted!

It makes perfect sense to me!

Thank you very much!

Hope you don’t mind me posting the exercises/examples while learning Pyblish. :slightly_smiling_face:

First simple example translated to Cinema’s syntax, working well:

"""
Plugin collects polygon objects in the active document and
prints their name.
Informs the user of how many polygon objects are collected.
"""

import pyblish.api
import c4d


class CollectPolyObjects(pyblish.api.ContextPlugin):
    """
    Collects polygon objects in active document.
    """

    order = pyblish.api.CollectorOrder

    def get_objs(self, op, lst):
        """
        Returns list with polygon objects.
        """

        while op:

            if op.GetTypeName() == 'Polygon':
                lst.append(op)

            self.get_objs(op.GetDown(), lst)
            op = op.GetNext()

        return lst

    def process(self, context):
        doc = c4d.documents.GetActiveDocument()
        first_obj = doc.GetFirstObject()

        if not first_obj:
            raise Exception("No objects in the scene!")

        objs = self.get_objs(first_obj, [])

        if not objs:
            raise Exception("No polygon objects in the scene!")

        context.data["objs"] = objs


class PrintCollectedObjects(pyblish.api.ContextPlugin):
    """
    Prints the collected objects.
    """

    order = pyblish.api.CollectorOrder + 0.1

    def process(self, context):

        if "objs" not in context.data:
            raise Exception("No polygon objects collected!")

        objs = context.data["objs"]

        for obj in objs:
            print(obj.GetName())

        self.log.info("Collected {} polygon object(s).".format(len(objs)))


# Register Plugins
pyblish.api.register_plugin(CollectPolyObjects)
pyblish.api.register_plugin(PrintCollectedObjects)

1 Like

Another example…
This example interacts the Cinema 4D objects in a basic level. I’ve added a context, label, information to the user and created an instance to be validated.

"""
Collect all of the subdivision generator objects and checks if are enabled.
If False enables it and passes the validation.
"""

import pyblish.api as api
import c4d


class CollectSDSObjects(api.ContextPlugin):
    """
    Collect all of the subdivision generator objects.
    """

    order = api.CollectorOrder
    label = "Collect SDS Objects"

    def get_objs(self, op, name_type, lst):
        """
        Returns list with selected objects.
        """

        while op:

            if op.GetTypeName() == name_type:
                lst.append(op)

            self.get_objs(op.GetDown(), name_type, lst)
            op = op.GetNext()

        return lst

    def process(self, context):

        doc = c4d.documents.GetActiveDocument()
        first_obj = doc.GetFirstObject()

        if not first_obj:
            raise Exception("No objects in the scene!")

        objs = self.get_objs(first_obj, 'Subdivision Surface', [])

        if not objs:
            raise Exception("No SDS objects in the scene!")

        instance = context.create_instance("Subdivision Objects")
        instance.data["objs"] = objs
        instance.data["families"] = ["subdObjs"]

        self.log.info("Collected {} SDS object(s).".format(len(objs)))
        self.log.info([obj.GetName() for obj in objs])


class ValidateSDSObjects(api.InstancePlugin):
    """
    Checks if the SDS objects is enabled. If False enables it.
    """

    order = api.ValidatorOrder
    families = ["subdObjs"]
    label = "Enable SDS Objects"

    def process(self, instance):

        if "objs" not in instance.data:
            raise Exception("No SDS objects in the scene!")

        objs = instance.data["objs"]
        count = 0
        enabled = []

        for obj in objs:
            if not obj[c4d.ID_BASEOBJECT_GENERATOR_FLAG]:

                obj[c4d.ID_BASEOBJECT_GENERATOR_FLAG] = 1
                enabled.append(obj)
                count += 1

        if count > 0:
            self.log.warning("Enabled {} SDS object(s).".format(count))
            self.log.info([obj.GetName() for obj in enabled])

        c4d.EventAdd()


api.register_plugin(CollectSDSObjects)
api.register_plugin(ValidateSDSObjects)

Question:
I understand that targets are probably what I’m looking for if I want to publish for specific groups. E.g. animation team, modelling team, etc
Checked the example that @marcus posted and even though I can see the targets present in the terminal, i can’t see any GUI changes. Perhaps I’m thinking incorrectly about this?

Thank you! :slightly_smiling_face:

Thanks for sharing these @AndreAnjos!

The perhaps simplest way to separate between plug-ins run for various teams is to let each team have a folder of plug-ins to themselves.

  • c:\animation_plugins\
  • c:\modeling_plugins\

These could then be e.g. per-project or per-studio if you wanted, and are can be added to the Pyblish path either at application startup, or in any kind of task management system you’ve got to establish an environment for the artist (such as Allzpark!).

Targets is a way for you to do this from within each plug-in. Such that all plug-ins can reside in a single folder, but where you decide which “group” of plug-ins to use upon launching a GUI, e.g. pyblish.api.register_target("modeling")

But the perhaps most flexible method of branching plug-ins is by use of families; such that your Collector picks up “modeling” instances that modelers make, or “animation” instances that animators make. The instance is then tagged with a family, like model which is then matched with any plug-in supporting that family.

This would be the recommended approach, as it doesn’t prevent an artist from making a modeling publish from within a rendering or animation context; it would be entirely dependent on the instance they are making, whether it’s a model or something else. Then it would be up to your tools to either permit/prevent them to actually create such instances.

1 Like

Hi @marcus,

Thank you very much for your insightful reply.
I will have a play! Seems promising and I’m glad that can be flexible as you describe.

P.S. Allzpark looks fantastic!
I was considering learning Rez or your Bleeding-Rez version anytime in the future and this will make things much much easier.
Thank you very much for your (your team) awesome work! :slightly_smiling_face:

1 Like

Hi all,

Another example now regarding extraction:
This will also include an action to “fix the issue”.
Edit - As per @tokejepsen important workflow tip below I’ve updated the example.


"""
Extracts the current working document.
"""

import os
from os.path import expanduser
import pyblish
from pyblish import api
import c4d

doc = c4d.documents.GetActiveDocument()
doc_name = doc.GetDocumentName()


class CollectDocument(api.ContextPlugin):
    """
    Collect the document data.
    """

    order = api.CollectorOrder + 0.1
    label = "Collect Document Info"

    def process(self, context):

        instance = context.create_instance(doc_name, family="scene")
        instance.data["doc"] = doc
        self.log.info("{} Collected!".format(doc_name))


class SaveSceneAction(api.Action):
    """
    Action to let user save the file
    """

    label = "Save File"
    on = "failed"
    icon = "wrench"

    def process(self, context, plugin):

        file_path = c4d.storage.LoadDialog(title="Save Scene File", flags=c4d.FILESELECT_SAVE,
                                           force_suffix="c4d")

        if file_path == "":
            raise Exception("You need to save the scene before extraction!")

        c4d.documents.SaveDocument(doc, file_path,
                                   c4d.SAVEDOCUMENTFLAGS_CRASHSITUATION | c4d.SAVEDOCUMENTFLAGS_AUTOSAVE,
                                   c4d.FORMAT_C4DEXPORT)

        c4d.documents.KillDocument(doc)
        c4d.documents.LoadFile(file_path)
        c4d.EventAdd()

        self.log.info("{0} Saved to {1}".format(doc_name, file_path))


class ValidateScene(api.InstancePlugin):
    """
    Checks if scene file has all of the requirement elements before extraction.
    """

    order = api.ValidatorOrder
    families = ["scene"]
    label = "Validate Document Info"

    actions = [SaveSceneAction]

    def process(self, instance):

        def save_scene():
            """
            Function to save the scene. Returns True if the scene has been saved and it's OK to continue.
            """

            if doc.GetDocumentPath() == "" or doc.GetChanged():
                return False

            return True

        doc_saved = save_scene()

        if not doc_saved:
            raise Exception("You need to save the scene before extraction!")


class ExtractScene(api.InstancePlugin):
    """
    Extract the current working scene.
    """

    order = api.ExtractorOrder
    families = ["scene"]
    label = "Save Scene File"

    def process(self, instance):

        path = os.path.join(expanduser("~"), doc_name)
        result = c4d.documents.SaveDocument(doc, path,
                                            c4d.SAVEDOCUMENTFLAGS_CRASHSITUATION | c4d.SAVEDOCUMENTFLAGS_0,
                                            c4d.FORMAT_C4DEXPORT)

        if not result:
            raise Exception("Unexpected error! Scene file not saved!")

        self.log.info("{0} Saved to {1}".format(doc_name, path))


api.register_plugin(CollectDocument)
api.register_plugin(ValidateScene)
api.register_plugin(ExtractScene)

Hope this helps and please give me a shout if this can be improved :slight_smile:
Cheers!

Andre

Thanks for sharing!

A typical workflow is to have actions on validators. Validators should be quick checks before longer running extractors.
You might find that if you have more extractors, people will be waiting for long running extractors before they can “fix the issue”.

1 Like

Typically you dont want extractors to raise exceptions. When the publish passes validation, you would expect the publish to go through.
If extractors fail, you probably need to adjust your validators or create a new one.

1 Like

Hi @tokejepsen,

Thank you very much for pointing this out! Important stuff, that makes sense :slight_smile:
I’ve made the amends to the example above.

Andre

There is a still an exception raising in the extractor. Is that a safety net?

One thing to look out for is that your validator checks for two things, so when it fails and produces an action for the user, its only a fix for one of those checks. Might be confusing for users, but it depends on how you introduce users to the Pyblish.
Good to encourage users to inspect what the exception is, but equally good to make the validators with a single goal.

1 Like

Hi @tokejepsen,

Cheesr for that! :slight_smile:

yeah, I wasn’t too sure how to tackle this in case the extraction failed when saving the file to the server (where will eventually will be saved). The first validation save would be a local save and the extraction would basically confirm the saved file into the server. Even though I haven’t experience the file failing, there is still the possibility, i think. i.e. Server down or Cinema crashes, etc.
Would you recommend a workaround for this?

Fair enough! That makes sense to me if it takes any ambiguity from the tools.

Thank you very much for your tips! really useful :slight_smile:

Would you recommend a workaround for this?

Hmm, there are various workflow for this, but nothing standardised yet.

If you are just moving files (sorry, not entirely sure I get your plugin flow) around it been, then the integrations step might be more logical. Extractors are typically meant for getting data out of the DCCs, then integrators move the data to its final location.

Hi @tokejepsen,

OK! I will explore this a bit.

That makes sense and reading the documentation again it does mention this.

Thank you for help! :slight_smile: