Skip to content

Custom Nodes#

When the built-in node set doesn't cover a need, you can add a node to the catalog. A node is a Python class; its name is its kind on the wire, so every executing client (the iOS app) must implement a class of the same name.

Client implementation required

Defining a node in Python only adds its interface and serialization. For a procedure that uses it to run on device, you must also implement the matching Swift class and register it — see Implementing the iOS side.

The @make_procedure decorator#

Every node is a frozen, keyword-only dataclass produced by @make_procedure:

import capturegraph as cg


@cg.make_procedure
class RepeatString(cg.Procedure[cg.String]):
    """Repeat a string a fixed number of times."""

    text: cg.Input[cg.String]
    times: cg.Setting[int]

The return type is the type the node produces, written as cg.Procedure[T] where T is a CGType (cg.String, cg.Image, cg.Void, …). The class body is purely declarative — Python never executes the node, it validates and serializes it.

Field classification#

A field's role comes from its annotation marker, never from the value it happens to hold:

Marker Becomes Example
cg.Input[T] a graph input — a procedure, or a raw literal lifted to a Constant image: cg.Input[cg.Image]
a bare cg.Procedure[T] a strict graph input the framework itself reads (never a literal) source: cg.Procedure[cg.Path]
cg.Substeps substeps — a list of steps run for effect steps: cg.Substeps
cg.Setting[T] a plain JSON setting times: cg.Setting[int]
@cg.make_procedure
class ProcessImage(cg.Procedure[cg.Image]):
    input_image: cg.Input[cg.Image]   # input
    brightness: cg.Setting[float]     # setting

An unmarked field is a setting, so a field that holds procedures without the cg.Substeps or cg.Input marker (steps: list[cg.Procedure[cg.Void]]) would serialize as JSON and fail. @cg.make_procedure rejects such a class the moment it is declared.

Validation#

Add checks in __post_init__ (call super() first):

@cg.make_procedure
class RepeatString(cg.Procedure[cg.String]):
    text: cg.Input[cg.String]
    times: cg.Setting[int]

    def __post_init__(self) -> None:
        super().__post_init__()
        if self.times < 1:
            raise ValueError("times must be at least 1")

A node whose return type depends on its inputs overrides _resolve_type() instead of __post_init__. The base calls it at one fixed point during construction — after the fields are set, before validation — so no node picks an ordering:

@cg.make_procedure
class Echo(cg.Procedure[cg.CGType]):
    """Return whatever its input produces."""

    value: cg.Input[cg.CGType]

    def _resolve_type(self) -> type[cg.CGType]:
        return self.value.return_type

forward_types is the declarative version of that hook.

Pass-through types#

A node that forwards a value's type (like IfThenElse) is generic and uses forward_types, applied under @make_procedure. The first argument bounds the forwarded fields; the named fields must all produce the same type, which becomes the node's return type:

@cg.make_procedure
@cg.forward_types(cg.CGType, "input_value")
class PassThrough[T: cg.CGType](cg.Procedure[T]):
    input_value: cg.Procedure[T]

PassThrough(input_value=some_image_node) is then a Procedure[cg.Image].

Implementing the iOS side#

Every kind in the JSON must resolve to a Swift class with exactly the same name. To add a node end to end:

  1. Define the class in the right module under capturegraph-lib/capturegraph/procedures/nodes/{collection,process,destination}/.
  2. Import it into the root capturegraph/__init__.py (the single aggregation point), leaf-by-leaf as Name as Name, — the deeper procedures/__init__.py is documentation-only, and a class that never reaches the root is invisible to the node editor and the registry-sync tests.
  3. Create the matching Swift class under capturegraph-ios-client/.../Models-Procedures/ProcedureNodes/Nodes/.
  4. Add its .self entry to ProcedureNodeRegistry.swift.
  5. If it has UI, add a view under Views-Procedures/Nodes/.
  6. Run cd capturegraph-lib && python -m pytest — the registry-sync tests catch any drift between the Python exports and the Swift registry.

Note

Renaming a node is a breaking change on both sides: the Python __name__ and the Swift class name must change together, and previously exported JSON keeps the old kind.

See Also#