Skip to content

lib

Library functions for Silhouette.

capture_messageboxes(callback)

Capture messageboxes and call a callback with them.

This is a workaround for Silhouette not allowing the Python code to suppress messageboxes and supply default answers to them. So instead we capture the messageboxes and respond to them through a rapid QTimer.

Source code in client/ayon_silhouette/api/lib.py
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
@contextlib.contextmanager
def capture_messageboxes(callback):
    """Capture messageboxes and call a callback with them.

    This is a workaround for Silhouette not allowing the Python code to
    suppress messageboxes and supply default answers to them. So instead we
    capture the messageboxes and respond to them through a rapid QTimer.
    """
    processed = set()
    timer = QtCore.QTimer()

    def on_timeout():
        # Check for dialogs
        widgets = QtWidgets.QApplication.instance().topLevelWidgets()
        has_boxes = False
        for widget in widgets:
            if isinstance(widget, QtWidgets.QMessageBox):
                has_boxes = True
                if widget in processed:
                    continue
                processed.add(widget)
                callback(widget)

        # Stop as soon as possible with our detections. Even with the
        # QTimer repeating at interval of 0 we should have been able to
        # capture all the UI events as they happen in the main thread for
        # each dialog.
        # Note: Technically this would mean that as soon as there is no
        # active messagebox we directly stop the timer, and hence would stop
        # finding messageboxes after. However, with the export methods in
        # Silhouette this has not been a problem and all boxes were detected
        # accordingly.
        if not has_boxes:
            timer.stop()

    timer.setSingleShot(False)  # Allow to capture multiple boxes
    timer.timeout.connect(on_timeout)
    timer.start()
    try:
        yield
    finally:
        timer.stop()

collect_animation_defs(create_context, fps=False)

Get the basic animation attribute definitions for the publisher.

Parameters:

Name Type Description Default
create_context CreateContext

The context of publisher will be used to define the defaults for the attributes to use the current context's entity frame range as default values.

required
step bool

Whether to include step attribute definition.

required
fps bool

Whether to include fps attribute definition.

False

Returns:

Type Description

List[NumberDef]: List of number attribute definitions.

Source code in client/ayon_silhouette/api/lib.py
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
54
55
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
def collect_animation_defs(create_context, fps=False):
    """Get the basic animation attribute definitions for the publisher.

    Arguments:
        create_context (CreateContext): The context of publisher will be
            used to define the defaults for the attributes to use the current
            context's entity frame range as default values.
        step (bool): Whether to include `step` attribute definition.
        fps (bool): Whether to include `fps` attribute definition.

    Returns:
        List[NumberDef]: List of number attribute definitions.

    """

    # use task entity attributes to set defaults based on current context
    task_entity = create_context.get_current_task_entity()
    attrib: dict = task_entity["attrib"]
    frame_start: int = attrib["frameStart"]
    frame_end: int = attrib["frameEnd"]
    handle_start: int = attrib["handleStart"]
    handle_end: int = attrib["handleEnd"]

    # build attributes
    defs = [
        NumberDef("frameStart",
                  label="Frame Start",
                  default=frame_start,
                  decimals=0),
        NumberDef("frameEnd",
                  label="Frame End",
                  default=frame_end,
                  decimals=0),
        NumberDef("handleStart",
                  label="Handle Start",
                  tooltip="Frames added before frame start to use as handles.",
                  default=handle_start,
                  decimals=0),
        NumberDef("handleEnd",
                  label="Handle End",
                  tooltip="Frames added after frame end to use as handles.",
                  default=handle_end,
                  decimals=0),
    ]

    # if fps:
    #     doc = active_document()
    #     current_fps = doc.GetFps()
    #     fps_def = NumberDef(
    #         "fps", label="FPS", default=current_fps, decimals=5
    #     )
    #     defs.append(fps_def)

    return defs

copy_session_nodes(source_session, destination_session)

Merge all nodes from source session into destination session.

Parameters:

Name Type Description Default
source_session Session

The source session to clone nodes from.

required
destination_session Session

The destination session to merge into.

required

Returns:

Type Description
List[Node]

List[fx.Node]: The cloned nodes in the destination session.

Source code in client/ayon_silhouette/api/lib.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
def copy_session_nodes(
        source_session: fx.Session,
        destination_session: fx.Session) -> List[fx.Node]:
    """Merge all nodes from source session into destination session.

    Arguments:
        source_session (fx.Session): The source session to clone nodes from.
        destination_session (fx.Session): The destination session to merge
            into.

    Returns:
        List[fx.Node]: The cloned nodes in the destination session.
    """
    connections = {}
    for node in source_session.nodes:
        # We skip outputs because we are iterating all nodes
        # so we could automatically also collect the outputs if we
        # collect all their inputs
        connections.update(get_connections(node, outputs=False))

    # Create clones of the nodes from the source session
    source_node_to_clone_node: Dict[fx.Node, fx.Node] = {
        node: node.clone() for node in source_session.nodes
    }

    # Add all clones to the destination session
    for node in source_node_to_clone_node.values():
        destination_session.addNode(node)

    # Re-apply all their connections
    for destination, source in connections.items():
        source_node = source_node_to_clone_node[source.node]
        destination_node = source_node_to_clone_node[destination.node]
        source_port = get_output_port_by_name(source_node,
                                              source.name)
        destination_port = get_input_port_by_name(destination_node,
                                                  destination.name)
        source_port.connect(destination_port)

    return list(source_node_to_clone_node.values())

get_connections(node, inputs=True, outputs=True)

Return connections from destination ports to their source ports.

Source code in client/ayon_silhouette/api/lib.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def get_connections(
    node: fx.Node,
    inputs=True,
    outputs=True) -> Dict[fx.Port, fx.Port]:
    """Return connections from destination ports to their source ports."""
    connections: Dict[fx.Port, fx.Port] = {}
    if inputs:
        for input_destination in node.connectedInputs:
            connections[input_destination] = input_destination.source
    if outputs:
        for output_source in node.connectedOutputs:
            for target_destination in output_source.targets:
                connections[target_destination] = output_source
    return connections

get_input_port_by_name(node, port_name)

Return the input port with the given name.

Source code in client/ayon_silhouette/api/lib.py
416
417
418
419
420
421
def get_input_port_by_name(node: fx.Node, port_name: str) -> Optional[fx.Port]:
    """Return the input port with the given name."""
    return next(
        (port for port in node.inputs if port.name == port_name),
        None
    )

get_main_window()

Get the main Qt window of the application.

Source code in client/ayon_silhouette/api/lib.py
24
25
26
def get_main_window():
    """Get the main Qt window of the application."""
    return tools.window.get_main_window()

get_output_port_by_name(node, port_name)

Return the output port with the given name.

Source code in client/ayon_silhouette/api/lib.py
424
425
426
427
428
429
430
def get_output_port_by_name(
        node: fx.Node, port_name: str) -> Optional[fx.Port]:
    """Return the output port with the given name."""
    return next(
        (port for port in node.outputs if port.name == port_name),
        None
    )

import_project(path, merge_sessions=True)

Import Silhouette project into current project.

Silhouette can't 'import' projects natively, so instead we will use our own logic to load the content from a project file into the currently active project.

Parameters:

Name Type Description Default
path str

The project path to import. Since Silhouette projects are folders this should be the path to the project folder.

required
merge_sessions bool

When enabled, sessions with the same label will be 'merged' by adding all nodes of the imported session to the existing session.

True
Source code in client/ayon_silhouette/api/lib.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
@undo_chunk("Import project")
def import_project(
    path,
    merge_sessions=True):
    """Import Silhouette project into current project.

    Silhouette can't 'import' projects natively, so instead we will use our
    own logic to load the content from a project file into the currently
    active project.

    Arguments:
        path (str): The project path to import. Since Silhouette projects
            are folders this should be the path to the project folder.
        merge_sessions (bool): When enabled, sessions with the same label
            will be 'merged' by adding all nodes of the imported session to
            the existing session.

    """
    original_project = fx.activeProject()
    if not original_project:
        # Open a project
        original_project = fx.Project()
        fx.setActiveProject(original_project)

    merge_project = fx.loadProject(path)

    # Revert to original project
    fx.setActiveProject(original_project)

    # Add sources from the other project
    for source in merge_project.sources:
        original_project.addItem(source)

    # Merge sessions by label if there's a matching one
    sessions_by_label = {
        session.label: session for session in original_project.sessions
    }
    for merge_session in merge_project.sessions:
        if merge_sessions and merge_session.label in sessions_by_label:
            original_session = sessions_by_label[merge_session.label]
            copy_session_nodes(merge_session, original_session)
        else:
            # Add the session
            original_project.addItem(merge_session.clone())

            # For niceness - set it as active session if current project has
            # no active session
            if not fx.activeSession():
                fx.setActiveSession(merge_session)

imprint(node, data, key='AYON')

Write data to node as userDefined attributes

Parameters:

Name Type Description Default
node Object | Node

The selection object

required
data dict

Dictionary of key/value pairs

required
Source code in client/ayon_silhouette/api/lib.py
114
115
116
117
118
119
120
121
122
123
124
125
126
def imprint(node, data: Optional[dict], key="AYON"):
    """Write `data` to `node` as userDefined attributes

    Arguments:
        node (fx.Object | fx.Node): The selection object
        data (dict): Dictionary of key/value pairs
    """
    if isinstance(node, fx.Node):
        return imprint_state(node, data, key)
    elif isinstance(node, fx.Object):
        return imprint_property(node, data, key)
    else:
        raise TypeError(f"Unsupported node type: {node} ({type(node)})")

iter_children(node, prefix=None)

Iterate over all children of a node recursively.

This yields the node together with a label that indicates the full path from the root node passed to the function.

Source code in client/ayon_silhouette/api/lib.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def iter_children(
        node: fx.Node,
        prefix: Optional[str] = None
) -> Iterator[Tuple[fx.Node, str]]:
    """Iterate over all children of a node recursively.

    This yields the node together with a label that indicates the full path
    from the root node passed to the function.
    """
    children = node.children
    if not children:
        return
    for child in reversed(children):
        # Yield with a nested label so we can easily display it nicely
        label = child.label
        if prefix:
            label = f"{prefix} > {label}"
        yield child, label
        yield from iter_children(child, prefix=label)

maintained_selection()

Maintain selection during context.

Source code in client/ayon_silhouette/api/lib.py
85
86
87
88
89
90
91
92
93
@contextlib.contextmanager
def maintained_selection():
    """Maintain selection during context."""

    previous_selection = fx.selection()
    try:
        yield
    finally:
        fx.select(previous_selection)

read(node, key='AYON')

Return user-defined attributes from node

Parameters:

Name Type Description Default
node Object | Node

Node or object to redad from.

required
key str

The key to read from.

'AYON'

Returns:

Type Description
Optional[dict]

Optional[dict]: The data stored in the node.

Source code in client/ayon_silhouette/api/lib.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def read(node, key="AYON") -> Optional[dict]:
    """Return user-defined attributes from `node`

    Arguments:
        node (fx.Object | fx.Node): Node or object to redad from.
        key (str): The key to read from.

    Returns:
        Optional[dict]: The data stored in the node.

    """
    if isinstance(node, fx.Node):
        # Use node state instead of property
        return read_state(node, key)
    elif isinstance(node, fx.Object):
        # Project or source items do not have state
        return read_property(node, key)
    else:
        raise TypeError(f"Unsupported node type: {node} ({type(node)})")

reset_session_settings(session=None, task_entity=None)

Reset the session settings to the task context defaults.

Source code in client/ayon_silhouette/api/lib.py
245
246
247
248
249
250
251
252
253
254
255
256
def reset_session_settings(session=None, task_entity=None):
    """Reset the session settings to the task context defaults."""
    if session is None:
        session = fx.activeSession()
        assert session

    if task_entity is None:
        task_entity = get_current_task_entity()

    with undo_chunk("Reset session settings"):
        set_resolution_from_entity(session, task_entity)
        set_frame_range_from_entity(session, task_entity)

set_frame_range_from_entity(session, task_entity)

Set frame range and FPS from task entity attributes.

Parameters:

Name Type Description Default
session Session

The Silhouette session.

required
task_entity dict

Task entity.

required
Source code in client/ayon_silhouette/api/lib.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def set_frame_range_from_entity(session, task_entity):
    """Set frame range and FPS from task entity attributes.

    Args:
        session (fx.Session): The Silhouette session.
        task_entity (dict): Task entity.

    """
    frame_start = task_entity["attrib"]["frameStart"]
    frame_end = task_entity["attrib"]["frameEnd"]
    fps = task_entity["attrib"]["fps"]

    with undo_chunk("Set session frame range"):
        session.frameRate = fps
        session.startFrame = frame_start
        session.duration = (frame_end - frame_start) + 1

set_new_node_position(node)

Position the node near the active node, or the top-right of the scene

Source code in client/ayon_silhouette/api/lib.py
197
198
199
200
201
202
203
204
205
206
207
208
209
def set_new_node_position(node):
    """Position the node near the active node, or the top-right of the scene"""
    n = fx.activeNode()
    if n:
        pos = fx.trees.nextPos(n)
    else:
        bounds = fx.trees.bounds
        size = fx.trees.nodeSize(node)
        pos = fx.Point(
            bounds.right - size.x / 2,
            bounds.top + size.y / 2
        )
    node.setState("graph.pos", pos)

set_resolution_from_entity(session, task_entity)

Set resolution and pixel aspect from task entity attributes.

Parameters:

Name Type Description Default
session Session

The Silhouette session.

required
task_entity dict

Task entity.

required
Source code in client/ayon_silhouette/api/lib.py
212
213
214
215
216
217
218
219
220
221
222
223
224
def set_resolution_from_entity(session, task_entity):
    """Set resolution and pixel aspect from task entity attributes.

    Args:
        session (fx.Session): The Silhouette session.
        task_entity (dict): Task entity.

    """
    task_attrib = task_entity["attrib"]
    with undo_chunk("Set session resolution"):
        session.width = task_attrib["resolutionWidth"]
        session.height = task_attrib["resolutionHeight"]
        session.pixelAspect = task_attrib["pixelAspect"]

transfer_connections(source, destination, inputs=True, outputs=True)

Transfer connections from one node to another.

Source code in client/ayon_silhouette/api/lib.py
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
@undo_chunk("Transfer connections")
def transfer_connections(
    source: fx.Node,
    destination: fx.Node,
    inputs: bool = True,
    outputs: bool = True):
    """Transfer connections from one node to another."""
    # TODO: Match port by something else than name? (e.g. idx?)
    # Transfer connections from inputs
    if inputs:
        for _input in source.connectedInputs:
            name = _input.name
            destination_input = get_input_port_by_name(destination, name)
            if destination_input:
                destination_input.disconnect()
                _input.source.connect(destination_input)

    # Transfer connections from outputs
    if outputs:
        for output in source.connectedOutputs:
            name = output.name
            destination_output = get_output_port_by_name(destination, name)
            if destination_output:
                for target in output.targets:
                    target.disconnect()
                    destination_output.connect(target)

undo_chunk(label='')

Open undo chunk during context.

In Silhouette, it's often necessary to group operations into an undo chunk to ensure the UI updates correctly on property and value changes.

Note that contextlib.contextmanager can also be used as function decorators.

Source code in client/ayon_silhouette/api/lib.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@contextlib.contextmanager
def undo_chunk(label=""):
    """Open undo chunk during context.

    In Silhouette, it's often necessary to group operations into an undo chunk
    to ensure the UI updates correctly on property and value changes.

    Note that `contextlib.contextmanager` can also be used as function
    decorators.

    """
    try:
        fx.beginUndo(label)
        yield
    finally:
        fx.endUndo()

unzip(source, destination)

Unzip a zip file to destination.

Parameters:

Name Type Description Default
source str

Zip file to extract.

required
destination str

Destination directory to extract to.

required
Source code in client/ayon_silhouette/api/lib.py
387
388
389
390
391
392
393
394
395
396
397
def unzip(source, destination):
    """Unzip a zip file to destination.

    Args:
        source (str): Zip file to extract.
        destination (str): Destination directory to extract to.

    """
    with _ZipFile(source) as zr:
        zr.extractall(destination)
    log.debug(f"Extracted '{source}' to '{destination}'")

zip_folder(source, destination)

Zip a directory and move to destination.

This zips the contents of the source directory into the zip file. The source directory itself is not included in the zip file.

Parameters:

Name Type Description Default
source str

Directory to zip and move to destination.

required
destination str

Destination file path to zip file.

required
Source code in client/ayon_silhouette/api/lib.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
def zip_folder(source, destination):
    """Zip a directory and move to `destination`.

    This zips the contents of the source directory into the zip file. The
    source directory itself is not included in the zip file.

    Args:
        source (str): Directory to zip and move to destination.
        destination (str): Destination file path to zip file.

    """
    def _iter_zip_files_mapping(start):
        for root, dirs, files in os.walk(start):
            for folder in dirs:
                path = os.path.join(root, folder)
                yield path, os.path.relpath(path, start)
            for file in files:
                path = os.path.join(root, file)
                yield path, os.path.relpath(path, start)

    if not os.path.isdir(source):
        raise ValueError(f"Source is not a directory: {source}")

    if os.path.exists(destination):
        os.remove(destination)

    with _ZipFile(
        destination, "w", zipfile.ZIP_DEFLATED
    ) as zr:
        for path, relpath in _iter_zip_files_mapping(source):
            zr.write(path, relpath)