예제 #1
0
class ExtractProject(api.ContextPlugin):
    """Extract an Ftrack project from context.data["ftrackProjectData"]"""

    order = inventory.get_order(__file__, "ExtractProject")
    label = "Ftrack Project"
    hosts = ["nukestudio"]

    def process(self, context):

        session = context.data["ftrackSession"]

        data = {}
        for key, value in context.data["ftrackProjectData"].iteritems():
            if not value:
                continue

            data[key] = value

        # Get project from data
        query = "Project where "
        for key, value in data.iteritems():
            query += "{0} is \"{1}\" and ".format(key, value)
        query = query[:-5]

        project = session.query(query).first()

        # Create project if it does not exist
        if not project:
            self.log.info("Creating project with data: {0}".format(data))
            project = session.create("Project", data)
            session.commit()

        context.data["ftrackProject"] = project
예제 #2
0
class ExtractTasks(api.InstancePlugin):
    """Creates ftrack shots by the name of the instance."""

    order = inventory.get_order(__file__, "ExtractTasks")
    families = ["trackItem.ftrackEntity.task"]
    label = "Ftrack Tasks"
    optional = True

    def process(self, instance):
        session = instance.context.data["ftrackSession"]

        task_type = session.query('Type where name is "{0}"'.format(
            instance.data["type"])).one()

        query = ('Task where type.name is "{0}" and name is "{1}" and '
                 'parent.id is "{2}"')
        task = session.query(
            query.format(
                instance.data["type"], instance.data["name"],
                instance.data["parent"].data["entity"]["id"])).first()

        if not task:
            task = session.create(
                "Task", {
                    "name": instance.data["name"],
                    "type": task_type,
                    "parent": instance.data["parent"].data["entity"]
                })

        instance.data["entity"] = task
예제 #3
0
class ExtractAudio(api.InstancePlugin):
    """ Extracting audio """

    families = ["audio"]
    label = "Audio"
    hosts = ["hiero"]
    order = inventory.get_order(__file__, "ExtractAudio")
    optional = True

    def process(self, instance):
        import os

        item = instance[0]
        output_file = os.path.join(
            os.path.dirname(instance.context.data["currentFile"]),
            "workspace",
            item.parent().parent().name(),
            item.parent().name(),
            item.name() + ".wav"
        )

        output_dir = os.path.dirname(output_file)
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)

        item.sequence().writeAudioToFile(output_file, item.timelineIn(),
                                         item.timelineOut())

        instance.data["audio"] = output_file
예제 #4
0
class ExtractLocal(api.InstancePlugin):
    """ Extracts nodes locally. """

    families = ["local"]
    order = inventory.get_order(__file__, "ExtractLocal")
    label = "Local"
    optional = True
    hosts = ["houdini"]

    def process(self, instance):
        import os

        node = instance[0]

        node.parm("execute").pressButton()

        # raising any errors
        if node.errors():
            raise ValueError(node.errors())

        # gather extracted files
        collection = instance.data["collection"]
        for f in collection:
            if not os.path.exists(f):
                collection.remove(f)
예제 #5
0
class ValidateOutputRange(api.InstancePlugin):
    """Validate the output range of the task.

    This compares the output range and clip associated with the task, so see
    whether there is a difference. This difference indicates that the user has
    selected to export the clip length for the task which is very uncommon to
    do.
    """

    order = inventory.get_order(__file__, "ValidateOutputRange")
    families = ["trackItem.task"]
    label = "Output Range"
    hosts = ["nukestudio"]
    optional = True

    def process(self, instance):

        task = instance.data["task"]
        item = instance.data["parent"]

        output_range = task.outputRange()
        first_frame = int(item.data["item"].source().sourceIn())
        last_frame = int(item.data["item"].source().sourceOut())
        clip_duration = last_frame - first_frame + 1

        difference = clip_duration - output_range[1]
        failure_message = (
            'Looks like you are rendering the clip length for the task '
            'rather than the cut length. If this is intended, just uncheck '
            'this validator after resetting, else adjust the export range in '
            'the "Handles" section of the export dialog.')
        assert difference, failure_message
예제 #6
0
class ValidateMayaParameters(api.InstancePlugin):
    """ Validates the existence of deadline parameters on node. """

    order = inventory.get_order(__file__, "ValidateMayaParameters")
    label = "Parameters"
    families = ["deadline"]
    hosts = ["maya"]
    actions = [RepairParametersAction]
    targets = ["process.deadline"]

    def process(self, instance):

        node = instance[0]

        msg = "Could not find Chunk Size on node \"{0}\"".format(node)
        assert "deadlineChunkSize" in instance.data, msg

        msg = "Could not find Priority on node \"{0}\"".format(node)
        assert "deadlinePriority" in instance.data, msg

        msg = "Could not find Pool on node \"{0}\"".format(node)
        assert "deadlinePool" in instance.data, msg

        msg = "Could not find Concurrent Tasks on node \"{0}\"".format(node)
        assert "deadlineConcurrentTasks" in instance.data, msg
예제 #7
0
class ValidateMantraSettings(api.InstancePlugin):
    """ Validates mantra settings """

    families = ["mantra"]
    order = inventory.get_order(__file__, "ValidateMantraSettings")
    label = "Mantra Settings"
    actions = [RepairMantraSettings]
    optional = True
    hosts = ["houdini"]

    def process(self, instance):

        node = instance[0]

        # Igonore local ifds
        if ("ifd" in instance.data["families"]
                and "local" in instance.data["families"]):
            return

        # When rendering locally we need to block, so Pyblish doesn"t execute
        # other plugins. When render on a remote, the block needs to be lifted.
        if "remote" in instance.data["families"]:
            msg = "Mantra needs to render in the background for remote "
            msg += "rendering. Disable \"Block Until Render Complete\" in "
            msg += "\"Driver\"."
            assert not node.parm("soho_foreground").eval(), msg
        else:
            msg = "Mantra needs to render in the foreground for local "
            msg += "rendering. Enable \"Block Until Render Complete\" in "
            msg += "\"Driver\"."
            assert node.parm("soho_foreground").eval(), msg
class ValidateShapeName(api.ContextPlugin):
    """ No two shapes can have the same name. """

    order = inventory.get_order(__file__, "ValidateShapeName")
    label = "Shape Name"
    actions = [RepairShapeName]
    optional = True
    targets = ["process.local"]

    def process(self, context):
        import pymel.core

        validate = False
        valid_families = set(["mayaAscii", "mayaBinary", "alembic"])
        for instance in context:
            if set(instance.data["families"]) & valid_families:
                if instance.data.get("publish", True):
                    validate = True

        if not validate:
            return

        invalid_shapes = []
        msg = "Duplicate shape names:"
        for shp in pymel.core.ls(type="mesh"):
            if "|" in shp.name():
                invalid_shapes.append(shp)
                msg += "\n\n" + shp.name()

        assert not invalid_shapes, msg
예제 #9
0
class CollectPlayblastsPublish(api.ContextPlugin):
    """Collect all local processing write instances."""

    order = inventory.get_order(__file__, "CollectPlayblastsPublish")
    label = "Playblasts Local"
    hosts = ["maya"]
    targets = ["default"]

    def process(self, context):
        import os

        for item in context.data["instances"]:
            # Skip any instances that is not valid.
            if "playblast" not in item.data.get("families", []):
                continue

            if not os.path.exists(item.data["output_path"]):
                continue

            instance = context.create_instance(item.data["name"])
            for key, value in item.data.iteritems():
                instance.data[key] = value

            instance.data["label"] = "{0} - {1}".format(
                instance.data["name"],
                os.path.basename(instance.data["output_path"]))

            instance.data["families"] += ["output"]

            for node in item:
                instance.add(node)
예제 #10
0
class ExtractGeometry(Extract):

    order = inventory.get_order(__file__, "ExtractGeometry")
    label = "Geometry"
    families = ["geometry", "local"]

    def process(self, instance):
        import os

        node = instance[0]
        node["writeCameras"].setValue(False)
        node["writePointClouds"].setValue(False)
        node["writeAxes"].setValue(False)

        file_path = node["file"].getValue()
        node["file"].setValue(instance.data["output_path"])

        self.execute(instance)

        node["writeCameras"].setValue(True)
        node["writePointClouds"].setValue(True)
        node["writeAxes"].setValue(True)

        node["file"].setValue(file_path)

        # Validate output
        msg = "\"{0}\" didn't render.".format(instance.data["output_path"])
        assert os.path.exists(instance.data["output_path"]), msg
예제 #11
0
class CollectSetsProcess(api.ContextPlugin):
    """Collect all local processing write instances."""

    order = inventory.get_order(__file__, "CollectSetsProcess")
    label = "Sets Process"
    hosts = ["maya"]
    targets = ["process.local"]

    def process(self, context):

        for item in context.data["instances"]:
            # Skip any instances that is not valid.
            valid_families = [
                "alembic", "mayaAscii", "mayaBinary", "camera", "geometry"
            ]
            if len(set(valid_families) & set(item.data["families"])) != 1:
                continue

            instance = context.create_instance(item.data["name"])
            for key, value in item.data.iteritems():
                instance.data[key] = value

            instance.data["families"] += ["local"]
            for node in item:
                instance.add(node)
class ValidateProcessing(api.ContextPlugin):
    """Validates whether there are any instances to process."""

    order = inventory.get_order(__file__, "ValidateProcessing")
    optional = True
    label = "Data to Process"
    targets = ["default", "process"]
    hosts = ["nuke", "nukeassist", "maya"]

    def process(self, context):

        instances_to_process = []
        instance_labels = ""
        for instance in context:

            # Ignore source family
            families = instance.data.get("families", [])
            families += [instance.data["family"]]
            if "source" in families:
                continue

            instance_labels += "\n\n" + instance.data.get("label", "name")

            if instance.data("publish", True):
                instances_to_process.append(instance)

        msg = (
            "No nodes were enabled for processing. Please hit reset and choose"
            " a node to process in the left-hand list.\n\nPossible nodes to "
            "process:{0}".format(instance_labels))
        assert instances_to_process, msg
예제 #13
0
class CollectVersion(api.ContextPlugin):
    """ Collects the version from the latest scene asset """

    # offset to get current version from CollectSceneVersion
    order = inventory.get_order(__file__, "CollectVersion")
    label = "Ftrack Version"

    def process(self, context):

        session = context.data["ftrackSession"]
        task = context.data["ftrackTask"]

        if not task:
            return

        query = "select versions.version from Asset where parent.id is \"{0}\""
        query += " and type.short is \"scene\" and name is \"{1}\""
        asset = session.query(query.format(task["parent"]["id"],
                                           task["name"])).first()

        # Get current version
        current_version = context.data.get("version", 1)

        if asset:
            for version in asset["versions"]:
                if current_version < version["version"]:
                    current_version = version["version"]

        context.data["version"] = current_version

        self.log.info("Current version: " + str(current_version))
예제 #14
0
class ValidateOutputPath(api.InstancePlugin):

    order = inventory.get_order(__file__, "ValidateOutputPath")
    label = "Output Path"
    families = ["img.*"]
    actions = [RepairOutputPathAction]

    def process(self, instance):

        current_path = instance.data["output"].replace("\\", "/")
        expected_path = self.get_expected_path(instance)

        msg = "Output path is not correct."
        msg += " Current: {0}".format(current_path)
        msg += " Expected: {0}".format(expected_path)
        assert current_path == expected_path, msg

    def get_expected_path(self, instance):
        import os

        current_path = instance.data["output"]

        path = instance.context.data["currentFile"]
        func = os.path.join
        basename = instance.data["name"].replace(" ", "_") + ".[####]"
        basename += os.path.splitext(current_path)[1]
        expected_path = func(os.path.dirname(path), "workspace",
                             os.path.splitext(os.path.basename(path))[0],
                             basename)

        return expected_path.replace("\\", "/")
예제 #15
0
class ValidateRenderCamera(api.InstancePlugin):
    """ Validates render camera """

    order = inventory.get_order(__file__, "ValidateRenderCamera")
    families = ["renderlayer"]
    optional = True
    label = "Render Camera"

    def process(self, instance):
        import pymel.core
        # Validate non native camera active
        render_cameras = instance.data["cameras"]

        for c in pymel.core.ls(type="camera"):
            if c.renderable.get():
                render_cameras.append(c)

        render_cameras = list(set(render_cameras))

        msg = "No renderable camera selected."
        assert render_cameras, msg

        msg = "Can't render multiple cameras. "
        msg += "Please use a render layer instead"
        assert len(render_cameras) == 1, msg
예제 #16
0
class ValidateDatatype(api.InstancePlugin):
    """Validate output datatype matches with input."""

    order = inventory.get_order(__file__, "ValidateDatatype")
    families = ["write"]
    label = "Datatype"
    optional = True
    targets = ["default", "process"]

    def process(self, instance):

        # Only validate these channels
        channels = [
            "N_Object",
            "N_World",
            "P_Object",
            "P_World",
            "Pref",
            "UV",
            "velocity",
            "cryptomatte"
        ]

        valid_channels = []
        for node_channel in instance[0].channels():
            for channel in channels:
                if node_channel.startswith(channel):
                    valid_channels.append(node_channel)

        if valid_channels:
            msg = (
                "There are 32-bit channels: {0}.\n\nConsider changing the"
                " output to 32-bit to preserve data.".format(valid_channels)
            )
            assert instance[0]["datatype"].value().startswith("32"), msg
class ExtractFtrackPath(api.InstancePlugin):
    """ Extract Ftrack path for Deadline """

    order = inventory.get_order(__file__, "ExtractFtrackPath")
    families = ["deadline"]
    label = "Ftrack Path"

    def process(self, instance):
        import ftrack

        if not instance.context.has_data("ftrackData"):
            return

        job_data = {}
        plugin_data = {}
        if "deadlineData" in instance.data:
            job_data = instance.data["deadlineData"]["job"].copy()
            plugin_data = instance.data["deadlineData"]["plugin"].copy()

        # commenting to store full ftrack path
        comment = ""
        task = ftrack.Task(instance.context.data["ftrackData"]["Task"]["id"])
        for p in reversed(task.getParents()):
            comment += p.getName() + "/"
        comment += task.getName()
        comment = "Ftrack path: \"{0}\"".format(comment)

        job_data["Comment"] = comment

        # setting data
        data = {"job": job_data, "plugin": plugin_data}
        instance.data["deadlineData"] = data
예제 #18
0
class ExtractReview(api.InstancePlugin):
    """Extract review hash value."""

    order = inventory.get_order(__file__, "ExtractReview")
    label = "Review Hash"
    optional = True
    families = ["review"]
    hosts = ["nuke", "maya"]

    def md5(self, fname):
        import hashlib

        hash_md5 = hashlib.md5()
        with open(fname, "rb") as f:
            for chunk in iter(lambda: f.read(4096), b""):
                hash_md5.update(chunk)
        return hash_md5.hexdigest()

    def process(self, instance):
        import os

        hash_value = self.md5(instance.data["output_path"])
        md5_file = instance.data["output_path"].replace(
            os.path.splitext(instance.data["output_path"])[1], ".md5")
        with open(md5_file, "w") as the_file:
            the_file.write(hash_value)
class ValidateScenePath(api.InstancePlugin):
    """ Validates the path of the hiero file """

    order = inventory.get_order(__file__, "ValidateScenePath")
    families = ["scene"]
    label = "Scene Path"
    actions = [RepairScenePathAction]

    def process(self, instance):
        import pipeline_schema

        # getting current work file
        work_path = instance.data["workPath"].lower()

        # expected path
        data = pipeline_schema.get_data()
        data["extension"] = "aep"

        version = 1
        if instance.context.has_data("version"):
            version = instance.context.data("version")
        data["version"] = version

        file_path = pipeline_schema.get_path("task_work", data)

        # validating scene work path
        msg = "Scene path is not correct:"
        msg += "\n\nCurrent: %s" % (work_path)
        msg += "\n\nExpected: %s" % (file_path)

        assert file_path == work_path, msg
예제 #20
0
class ExtractAssetDataNukeStudio(api.ContextPlugin):
    """Changes the parent of the review component."""

    order = inventory.get_order(__file__, "ExtractAssetDataNukeStudio")
    label = "Ftrack Link Review"
    optional = True
    hosts = ["nukestudio"]

    def process(self, context):

        data = {}
        for instance in context:
            families = [instance.data["family"]]
            families += instance.data.get("families", [])

            name = instance.data["name"].split("--")[-1]
            instance_data = data.get(name, {})
            if "review" in families:
                instance_data["review"] = instance
            if "trackItem.ftrackEntity.shot" in families:
                instance_data["shot"] = instance

            data[name] = instance_data

        for name, instance_data in data.iteritems():
            if not instance_data:
                continue
            asset_data = instance_data["review"].data.get("asset_data", {})
            asset_data["parent"] = instance_data["shot"].data["entity"]
            instance_data["review"].data["asset_data"] = asset_data
예제 #21
0
class ExtractHobsoftScene(api.InstancePlugin):
    """ Extract work file to Hobsoft drive
    """

    order = inventory.get_order(__file__, "ExtractHobsoftScene")
    families = ['scene']
    label = 'Hobsoft Sync'

    def process(self, instance):
        import os
        import shutil

        current_file = instance.data('workPath')
        ftrack_data = instance.context.data('ftrackData')

        if ftrack_data['Project']['name'] != 'ethel_and_ernest':
            return

        sequence_name = ftrack_data['Sequence']['name']
        shot_name = 'c' + ftrack_data['Shot']['name'].split('c')[1]
        filename = ftrack_data['Shot']['name'] + '_ee.tvpp'

        publish_file = os.path.join('B:\\', 'film', sequence_name, shot_name,
                                    'tvpaint', filename)

        # create publish directory
        if not os.path.exists(os.path.dirname(publish_file)):
            os.makedirs(os.path.dirname(publish_file))

        # copy work file to publish
        shutil.copy(current_file, publish_file)
예제 #22
0
class ValidateNames(api.InstancePlugin):
    """ Validate sequence, video track and track item names.

    When creating output directories with the name of an item, ending with a
    whitespace will fail the extraction.
    """

    order = inventory.get_order(__file__, "ValidateNames")
    families = ["trackItem"]
    label = "Names"
    hosts = ["hiero"]

    def process(self, instance):

        item = instance[0]

        msg = "Track item \"{0}\" ends with a whitespace."
        assert not item.name().endswith(" "), msg.format(item.name())

        msg = "Video track \"{0}\" ends with a whitespace."
        msg = msg.format(item.parent().name())
        assert not item.parent().name().endswith(" "), msg

        msg = "Sequence \"{0}\" ends with a whitespace."
        msg = msg.format(item.parent().parent().name())
        assert not item.parent().parent().name().endswith(" "), msg
예제 #23
0
class ExtractSuspended(api.InstancePlugin):
    """ Option to suspend Deadline job on submission """

    order = inventory.get_order(__file__, "ExtractSuspended")
    label = "Suspend Deadline Job Initally"
    families = ["deadline"]
    active = False
    optional = True
    targets = ["process.deadline"]

    def process(self, instance):

        # getting job data
        job_data = {}
        plugin_data = {}
        if instance.has_data('deadlineData'):
            job_data = instance.data('deadlineData')['job'].copy()
            plugin_data = instance.data('deadlineData')['plugin'].copy()

        job_data["InitialStatus"] = "Suspended"

        instance.data["deadlineData"] = {
            "job": job_data,
            "plugin": plugin_data
        }
예제 #24
0
class ValidateResolvedPaths(api.ContextPlugin):
    """Validate there are no overlapping resolved paths."""

    order = inventory.get_order(__file__, "ValidateResolvedPaths")
    label = "Resolved Paths"
    hosts = ["nukestudio"]

    def process(self, context):
        import os
        import collections

        paths = []
        for instance in context:
            if "trackItem.task" == instance.data["family"]:
                paths.append(
                    os.path.abspath(
                        instance.data["task"].resolvedExportPath()))

        duplicates = []
        for item, count in collections.Counter(paths).items():
            if count > 1:
                duplicates.append(item)

        msg = "Duplicate output paths found: {0}".format(duplicates)
        assert not duplicates, msg
class ValidateVraySettings(api.InstancePlugin):
    """ Validates render layer settings. """

    order = inventory.get_order(__file__, "ValidateVraySettings")
    optional = True
    families = ["vray"]
    label = "Vray Settings"
    actions = [RepairVraySettingsAction]
    hosts = ["maya"]

    def process(self, instance):
        import pymel

        settings = pymel.core.PyNode("vraySettings")

        # File name prefix
        current = settings.fileNamePrefix.get()
        expected = "<Layer>/<Scene>"

        msg = "File name prefix is incorrect. Current: \"{0}\"."
        msg += " Expected: \"{1}\""
        assert expected == current, msg.format(current, expected)

        # Enable animation
        msg = "Expecting Animation to be \"Standard\""
        assert settings.animType.get() == 1, msg

        # Frame name padding
        msg = "Expecting Frame name padding to be \"4\""
        assert settings.fileNamePadding.get() == 4, msg
예제 #26
0
class CollectScene(api.ContextPlugin):
    """Collecting the scene from the context."""

    # offset to get latest currentFile from context
    order = inventory.get_order(__file__, "CollectScene")
    label = "Source"
    targets = ["default", "process"]

    def process(self, context):
        import os

        current_file = context.data("currentFile")

        # Skip if current file is directory
        if os.path.isdir(current_file):
            return

        # create instance
        instance = context.create_instance(name=os.path.basename(current_file))

        instance.data["families"] = ["scene"]
        instance.data["family"] = "source"
        instance.data["path"] = current_file
        label = "{0} - source".format(os.path.basename(current_file))
        instance.data["label"] = label
class ValidateGroupNode(api.InstancePlugin):
    """Validates group node.

    Ensures none of the groups content is locally stored.
    """

    order = inventory.get_order(__file__, "ValidateGroupNode")
    optional = True
    families = ["gizmo", "lut"]
    label = "Group Node"
    hosts = ["nuke", "nukeassist"]

    def process(self, instance):
        import os

        for node in instance[0].nodes():

            # Skip input and output nodes
            if node.Class() in ["Input", "Output"]:
                continue

            # Get file path
            file_path = ""
            if node.Class() == "Vectorfield":
                file_path = node["vfield_file"].getValue()
            if node.Class() == "Read":
                file_path = node["file"].getValue()

            # Validate file path to not be local
            # Windows specifc
            msg = "Node \"{0}\" in group \"{1}\"".format(
                node["name"].getValue(), instance[0]["name"].getValue())
            msg += ", has a local file path: \"{0}\"".format(file_path)
            assert "c:" != os.path.splitdrive(file_path)[0].lower(), msg
예제 #28
0
class CollectScene(api.ContextPlugin):
    """ Converts the path flag value to the current file in the context. """

    order = inventory.get_order(__file__, "CollectScene")

    def process(self, context):

        context.data["currentFile"] = context.data("kwargs")["path"][0]
class ValidateTransforms(api.InstancePlugin):
    """ Freeze/Reset transforms.

    Ensure all meshes have their pivot at world zero,
    and their transforms are zero'ed out.
    """

    order = inventory.get_order(__file__, "ValidateTransforms")
    families = ["mayaAscii", "mayaBinary", "alembic"]
    label = "Transforms"
    optional = True
    targets = ["process.local"]

    def process(self, instance):
        import pymel.core as pm

        attributes = {
            "tx": 0,
            "ty": 0,
            "tz": 0,
            "rx": 0,
            "ry": 0,
            "rz": 0,
            "sx": 1,
            "sy": 1,
            "sz": 1
        }

        check = True
        for node in instance[0].members():
            v = sum(node.getRotatePivot(space="world"))
            if v > 0.09:
                msg = "\"{0}\" pivot is not at world zero."
                self.log.error(msg.format(node.name()))
                check = False

            v = sum(node.getRotation(space="world"))
            if v != 0:
                msg = "\"{0}\" pivot axis is not aligned to world."
                self.log.error(msg.format(node.name()))
                check = False

            if sum(node.scale.get()) != 3:
                msg = "\"{0}\" scale is not neutral."
                self.log.error(msg.format(node.name()))
                check = False

            for key, value in attributes.iteritems():
                attr = pm.PyNode("{0}.{1}".format(node.name(), key))
                if attr.get() != value:
                    msg = "Expected \"{0}\" value \"{1}\". Current \"{2}\""
                    self.log.error(msg.format(attr, value, attr.get()))
                    check = False

        msg = "Transforms in the scene aren't reset."
        msg += " Please reset by \"Modify\" > \"Freeze Transformations\","
        msg += " followed by \"Modify\" > \"Reset Transformations\"."
        assert check, msg
예제 #30
0
class OtherLinkSource(api.ContextPlugin):
    """Link the source version to all other versions."""

    order = inventory.get_order(__file__, "OtherLinkSource")
    label = "Ftrack Link Source"

    def process(self, context):

        # Collect source assetversion
        source_version = None
        for instance in context:
            # Filter to source instance
            families = [instance.data["family"]]
            families += instance.data.get("families", [])
            if "source" not in families:
                continue

            # Get AssetVersion from published component
            for data in instance.data.get("ftrackComponentsList", []):
                if "component" not in data.keys():
                    continue
                source_version = data["component"]["version"]

        # Skip if no source version were published
        if source_version is None:
            return

        for instance in context:
            # Filter to non source instance
            families = [instance.data["family"]] + instance.data["families"]
            if "source" in families:
                continue

            # Get AssetVersion from published component
            covered_versions = []
            for data in instance.data.get("ftrackComponentsList", []):
                if "component" not in data.keys():
                    continue

                # Prevent duplicate links being created with multiple
                # components on the same version.
                if data["component"]["version"]["id"] in covered_versions:
                    continue
                covered_versions.append(data["component"]["version"]["id"])

                existing_link = None
                for link in data["component"]["version"]["incoming_links"]:
                    if link["from_id"] == source_version["id"]:
                        existing_link = link

                if existing_link is None:
                    context.data["ftrackSession"].create(
                        "AssetVersionLink", {
                            "from": source_version,
                            "to": data["component"]["version"]
                        })

        context.data["ftrackSession"].commit()