Skip to content

plugin

SilhouetteCreator

Bases: Creator

Base class for Silhouette creators.

This base creator can be applied multiple times to a single node, where each instance is stored as a separate imprint on the node, inside an AYON_instances state that is a dict[str, dict] of instance data per uuid. The instance_id will then be defined by the node's id and this uuid as {node_id}|{uuid}.

This way, a single RotoNode can have multiple instances of different product types (or even the same product type) to allow exporting e.g. track points and matte shapes from the same RotoNode.

Source code in client/ayon_silhouette/api/plugin.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class SilhouetteCreator(Creator):
    """Base class for Silhouette creators.

    This base creator can be applied multiple times to a single node, where
    each instance is stored as a separate imprint on the node, inside an
    `AYON_instances` state that is a `dict[str, dict]` of instance data per
    uuid. The `instance_id` will then be defined by the node's id and this uuid
    as `{node_id}|{uuid}`.

    This way, a single RotoNode can have multiple instances of different
    product types (or even the same product type) to allow exporting e.g.
    track points and matte shapes from the same RotoNode.

    """
    default_variants = ["Main"]
    settings_category = "silhouette"

    create_node_type = "OutputNode"

    # When `valid_node_types` is set, all these node types are allowed to get
    # imprinted by this creator
    valid_node_types = set()

    @lib.undo_chunk("Create instance")
    def create(self, product_name, instance_data, pre_create_data):

        session = fx.activeSession()
        if not session:
            return

        instance_node = None
        use_selection = pre_create_data.get("use_selection")
        selected_nodes = []
        if use_selection:
            # Allow to imprint on a currently selected node of the same type
            # as this creator would generate. If the node is already imprinted
            # by a Creator then raise an error - otherwise use it as the
            # instance node.
            valid_node_types = self.valid_node_types or {self.create_node_type}
            selected_nodes = [
                node for node in fx.selection() if isinstance(node, fx.Node)]
            for node in selected_nodes:
                if node.type in valid_node_types:
                    data = lib.read(node)
                    if data and data.get("creator_identifier"):
                        raise CreatorError(
                            "Selected node is already imprinted by a Creator."
                        )
                    instance_node = node
                    self.log.info(
                        f"Using selected node as instance node: {node.label}")
                    break

        if instance_node is None:
            # Create new node and place it in the scene
            instance_node = fx.Node(self.create_node_type)
            session.addNode(instance_node)
            lib.set_new_node_position(instance_node)

            # When generating a new instance node and use selection is enabled,
            # connect to the first selected node with a matching output type
            if use_selection and selected_nodes:
                self._connect_input_to_first_matching_candidate(
                    instance_node, selected_nodes)

            instance_node.label = session.uniqueLabel(product_name)
        fx.activate(instance_node)

        # Use the uniqueness of the node in Silhouette as part of the instance
        # id, but because we support multiple instances per node, we also add
        # an uuid within the node to make duplicates of nodes still unique in
        # the full create context.
        instance_id = f"{instance_node.id}|{uuid.uuid4()}"
        instance_data["instance_id"] = instance_id
        instance_data["label"] = self._define_label(
            instance_node, product_name)
        instance = CreatedInstance(
            product_type=self.product_type,
            product_name=product_name,
            data=instance_data,
            creator=self,
            transient_data={
                "instance_node": instance_node
            }
        )

        # Store the instance data
        data = instance.data_to_store()
        self._imprint(instance_node, data)

        self._add_instance_to_context(instance)

        return instance

    def collect_instances(self):
        shared_data = cache_instance_data(self.collection_shared_data)
        cached_instances = shared_data["silhouette_cached_instances"]
        for obj, instance_uuid, data in cached_instances.get(
                self.identifier, []):
            data["instance_id"] = f"{obj.id}|{instance_uuid}"
            data["label"] = self._define_label(obj, data["productName"])

            # Add instance
            created_instance = CreatedInstance.from_existing(
                data,
                self,
                transient_data={"instance_node": obj}
            )
            self._add_instance_to_context(created_instance)

    @lib.undo_chunk("Update instances")
    def update_instances(self, update_list):
        for created_inst, _changes in update_list:
            new_data = created_inst.data_to_store()
            node = created_inst.transient_data["instance_node"]
            self._imprint(node, new_data)

    @lib.undo_chunk("Remove instances")
    def remove_instances(self, instances):
        for instance in instances:

            # Remove node from the scene
            node = instance.transient_data["instance_node"]
            if node:
                instance_uuid = self._get_uuid_from_instance_id(instance.id)
                instances_by_uuid = lib.read(node,
                                             key=INSTANCES_DATA_KEY) or {}
                instances_by_uuid.pop(instance_uuid, None)
                if not instances_by_uuid:
                    # Remove the node, because it was the last imprinted value
                    session = node.session
                    session.removeNode(node)
                else:
                    # Update the node's imprinted value by removing the entry
                    lib.imprint(
                        node,
                        instances_by_uuid,
                        key=INSTANCES_DATA_KEY)

            # Remove the collected CreatedInstance to remove from UI directly
            self._remove_instance_from_context(instance)

    def _imprint(self, node, data):
        data.pop("label", None)  # do not store the label
        # Do not store instance id since it's the Silhouette node id
        instance_id = data.pop("instance_id")

        instance_uuid = self._get_uuid_from_instance_id(instance_id)
        instances_by_uuid = lib.read(node, key=INSTANCES_DATA_KEY) or {}
        instances_by_uuid[instance_uuid] = data
        lib.imprint(node, instances_by_uuid, key=INSTANCES_DATA_KEY)

    def _get_uuid_from_instance_id(self, instance_id: str) -> str:
        """Return uuid for instance's key on the node data from instance id."""
        return instance_id.rsplit("|", 1)[-1]

    def _define_label(self, obj: fx.Node, product_name: str) -> str:
        return f"{product_name} ({obj.label})"

    def get_pre_create_attr_defs(self):
        return [
            BoolDef("use_selection",
                    label="Use selection",
                    default=True)
        ]

    def _connect_input_to_first_matching_candidate(self, node, candidates):
        """Connect the primary input of `node` to the first candidate with
        an output that has a matching data type."""
        primary_input = node.primaryInput
        if not primary_input:
            return

        allowed_types = set(primary_input.dataTypes)
        for candidate in candidates:
            for output in candidate.outputs:
                if allowed_types.intersection(output.dataTypes):
                    output.connect(primary_input)
                    return

SilhouetteImportLoader

Bases: SilhouetteLoader

Import using the chosen Silhouette IO module.

Source code in client/ayon_silhouette/api/plugin.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
class SilhouetteImportLoader(SilhouetteLoader):
    """Import using the chosen Silhouette IO module."""
    # TODO: Should we allow importing multiple containers to one node?
    #  It may be needed for importing both track points and matte shapes to
    #  a single RotoNode

    io_module: str
    node_type = "RotoNode"

    options = [
        BoolDef(
            "use_selection",
            label="Use selection",
            default=True,
            tooltip=(
                "Use the selected node if it supports the import type. "
                "Otherwise create a new node."
            )
        )
    ]

    @lib.undo_chunk("Load")
    @lib.maintained_selection()
    def load(self, context, name=None, namespace=None, options=None):
        """Merge the Alembic into the scene."""
        if not fx.activeProject():
            raise RuntimeError("No active project found.")
        if not fx.activeSession():
            raise RuntimeError("No active session found.")
        if options is None:
            options = {}

        # Use selected node or create a new one to import to
        selection = [
            _node for _node in fx.selection() if self.can_import_to_node(_node)
        ]
        if options.get("use_selection", True) and selection:
            node = selection[0]
        else:
            # Create a new node
            node = fx.Node(self.node_type)
            fx.activeSession().addNode(node)
            lib.set_new_node_position(node)


        # Import the file
        fx.activate(node)
        filepath = self.filepath_from_context(context)

        try:
            fx.io_modules[self.io_module].importFile(filepath)
        except AssertionError as exc:
            # Provide better message than "importer not ready" when it fails
            # to import
            if str(exc) == "importer not ready":
                raise LoadError(
                    f"Failed to import as '{self.io_module}':\n{filepath}"
                    f"\n\n"
                    f"Most likely the Tracker format is unsupported."
                ) from exc
            raise
        self._process_loaded(context, node)

        # property.hidden = True  # hide the attribute
        lib.imprint(node, data={
            "name": str(name),
            "namespace": str(namespace),
            "loader": str(self.__class__.__name__),
            "representation": context["representation"]["id"],
        })

    def _process_loaded(self, context, node):
        # For overrides on inherited classes
        pass

    @lib.undo_chunk("Update Source")
    @lib.maintained_selection()
    def update(self, container, context):
        item: fx.Node = container["_item"]

        # Remove existing children
        item.objects.removeObjects(item.children)

        # Import the file
        fx.activate(item)
        filepath = self.filepath_from_context(context)
        fx.io_modules[self.io_module].importFile(filepath)

        # Update representation id
        data = lib.read(item)
        data["representation"] = context["representation"]["id"]
        lib.imprint(item, data)

    @lib.undo_chunk("Remove container")
    def remove(self, container):
        """Remove node from session"""
        node: fx.Node = container["_item"]
        session = container["_session"]
        session.removeNode(node)

    def switch(self, container, context):
        """Support switch to another representation."""
        self.update(container, context)

    def can_import_to_node(self, node) -> bool:

        if isinstance(node, fx.Node):
            return True

        # Do not allow load to node that already has import
        # TODO: Support multiple containers per node
        if parse_container(node):
            return False

        return False

load(context, name=None, namespace=None, options=None)

Merge the Alembic into the scene.

Source code in client/ayon_silhouette/api/plugin.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
@lib.undo_chunk("Load")
@lib.maintained_selection()
def load(self, context, name=None, namespace=None, options=None):
    """Merge the Alembic into the scene."""
    if not fx.activeProject():
        raise RuntimeError("No active project found.")
    if not fx.activeSession():
        raise RuntimeError("No active session found.")
    if options is None:
        options = {}

    # Use selected node or create a new one to import to
    selection = [
        _node for _node in fx.selection() if self.can_import_to_node(_node)
    ]
    if options.get("use_selection", True) and selection:
        node = selection[0]
    else:
        # Create a new node
        node = fx.Node(self.node_type)
        fx.activeSession().addNode(node)
        lib.set_new_node_position(node)


    # Import the file
    fx.activate(node)
    filepath = self.filepath_from_context(context)

    try:
        fx.io_modules[self.io_module].importFile(filepath)
    except AssertionError as exc:
        # Provide better message than "importer not ready" when it fails
        # to import
        if str(exc) == "importer not ready":
            raise LoadError(
                f"Failed to import as '{self.io_module}':\n{filepath}"
                f"\n\n"
                f"Most likely the Tracker format is unsupported."
            ) from exc
        raise
    self._process_loaded(context, node)

    # property.hidden = True  # hide the attribute
    lib.imprint(node, data={
        "name": str(name),
        "namespace": str(namespace),
        "loader": str(self.__class__.__name__),
        "representation": context["representation"]["id"],
    })

remove(container)

Remove node from session

Source code in client/ayon_silhouette/api/plugin.py
335
336
337
338
339
340
@lib.undo_chunk("Remove container")
def remove(self, container):
    """Remove node from session"""
    node: fx.Node = container["_item"]
    session = container["_session"]
    session.removeNode(node)

switch(container, context)

Support switch to another representation.

Source code in client/ayon_silhouette/api/plugin.py
342
343
344
def switch(self, container, context):
    """Support switch to another representation."""
    self.update(container, context)

cache_instance_data(shared_data)

Cache instances for Creators shared data.

Create silhouette_cached_instances key when needed in shared data and fill it with all collected instances from the scene under its respective creator identifiers.

Parameters:

Name Type Description Default
shared_data(Dict[str, Any]

Shared data.

required
Source code in client/ayon_silhouette/api/plugin.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def cache_instance_data(shared_data):
    """Cache instances for Creators shared data.

    Create `silhouette_cached_instances` key when needed in shared data and
    fill it with all collected instances from the scene under its
    respective creator identifiers.

    Args:
        shared_data(Dict[str, Any]): Shared data.

    """
    if shared_data.get('silhouette_cached_instances') is None:
        shared_data["silhouette_cached_instances"] = cache = {}

        session = fx.activeSession()
        if not session:
            return cache

        for node in session.nodes:
            instances_data_by_uuid = lib.read(node, key=INSTANCES_DATA_KEY)
            if not instances_data_by_uuid:
                continue

            for instance_uuid, data in instances_data_by_uuid.items():
                if data.get("id") != AYON_INSTANCE_ID:
                    continue

                creator_id = data.get("creator_identifier")
                if not creator_id:
                    continue

                cache.setdefault(creator_id, []).append(
                    (node, instance_uuid, data))

    return shared_data