Process frames with NumPy and return them as an effect.

A Python frame effect is a function that receives each frame as a NumPy array and returns a new one. Its parameters become controls in Properties, and it can read project files you declare. This reference covers the function, frames, context, parameters, files, examples and the request that adds an effect to a project.

The render function

A Python effect defines render(ctx, inputs, params) and returns a dictionary with one array for each declared output:

def render(ctx, inputs, params):
    rgba = inputs["input_frames"].copy()
    grey = rgba[..., :3].mean(axis=2, keepdims=True)
    mix = params["amount"]
    rgba[..., :3] = (rgba[..., :3] * (1 - mix) + grey * mix).astype("uint8")
    return {"frames": rgba}

The entry point is always called render. NumPy is available as np without an import, and import numpy as np works too. Effects run in the Python 3.12 runtime bundled with Grapple.

Frames

Inputs
A dictionary keyed by input name. Each frame is a NumPy uint8 array of shape (height, width, 4) in RGBA order.
Read-only
Input arrays can't be changed in place. Call .copy(), or build a new array, before writing pixels.
Outputs
Return exactly one entry per declared output, usually "frames", each a uint8 array with the same shape as the output frame. A missing or extra key, the wrong type or the wrong shape is reported as an error.

Frames arrive at the resolution being rendered, which is smaller in a reduced-resolution preview. Use ctx.width and ctx.height rather than fixed sizes, and scale pixel distances to match.

The context object

AttributeMeaning
ctx.timeThe current time in seconds.
ctx.width, ctx.heightThe size of the frame being rendered, in pixels.
ctx.quality"interactive" for preview and "final" for export. Use it to skip expensive work while scrubbing.
ctx.seedA whole number that changes from frame to frame, and is the same whenever a frame renders again at the same size. Use it to seed random numbers so grain and noise are repeatable.
ctx.resourcesThe project files you declared, by role. Each has a path to a read-only copy, plus media_type, bytes and content_hash.

Parameters

Parameters are declared like those of any custom effect and arrive in the params dictionary:

ControlPython value
Slider or anglefloat
Togglebool
Textstr
Point or vectora dict with "x" and "y"
Coloura dict with "x", "y" and "z" for red, green and blue, from 0 to 1

Keyed parameters arrive with their value at the current frame.

Reading project files

To use data from the project, such as a lookup table or a list of values, declare it as a resource with a role name. The file must be a project asset or an analysis result already in the project; arbitrary paths aren't accepted. In render, open ctx.resources["role"].path, which points to a read-only copy.

Module-level variables persist between frames while the source is unchanged, so load a file once and keep it.

Examples

Posterise

def render(ctx, inputs, params):
    rgba = inputs["input_frames"].copy()
    levels = max(2, int(params["levels"]))
    step = 255 / (levels - 1)
    rgba[..., :3] = (np.round(rgba[..., :3] / step) * step).astype(np.uint8)
    return {"frames": rgba}

Repeatable grain

def render(ctx, inputs, params):
    rng = np.random.default_rng(ctx.seed)
    rgba = inputs["input_frames"].astype(np.int16)
    noise = rng.normal(0, params["amount"], size=(ctx.height, ctx.width, 1))
    rgba[..., :3] = np.clip(rgba[..., :3] + noise, 0, 255)
    return {"frames": rgba.astype(np.uint8)}

Seeding from ctx.seed gives each frame its own grain and repeats it exactly whenever that frame renders at the same size. A reduced-resolution preview has a different pattern from the full-size export.

A colour curve from a file

curve = None

def render(ctx, inputs, params):
    global curve
    if curve is None:
        curve = np.load(ctx.resources["curve"].path)  # shape (256, 3), uint8
    rgba = inputs["input_frames"].copy()
    for channel in range(3):
        rgba[..., channel] = curve[rgba[..., channel], channel]
    return {"frames": rgba}

The curve is a NumPy file in the project, declared with the role curve. It loads on the first frame and is reused after that.

Add a Python effect to a project

Ape adds Python effects with the same effect.author_from_source operation used for shaders. Paste the function into the Ape panel with the parameters and where it should apply. The request differs from a shader's in its implementation fields and optional resources:

{
  "displayName": "Posterise",
  "implementationKind": "python",
  "language": "python-3",
  "entrypoint": "render",
  "source": "def render(ctx, inputs, params):\n    ...",
  "params": [{
    "name": "levels",
    "label": "Levels",
    "description": "Number of brightness steps per channel.",
    "value": 4,
    "editor": { "family": "scalar", "min": 2, "max": 16, "step": 1 }
  }],
  "inputPorts": [{ "name": "input_frames", "type": "frame_stream", "frameStream": { "space": "composition_space" } }],
  "outputPorts": [{ "name": "frames", "type": "frame_stream", "frameStream": { "space": "composition_space" } }],
  "activeRange": { "start": 0, "end": 30 },
  "placement": { "kind": "track", "trackNodeId": "<track id>", "inputPort": "input_frames", "outputPort": "frames" }
}

To read a file, add "resources": [{ "kind": "project_asset", "id": "<asset id>", "role": "curve" }]. The shader reference shows how to send a request from the command line.

Performance and safety

Python effects run on the CPU for every frame. Keep heavy work out of the interactive preview with ctx.quality, or preview a short range.

An effect's source is ordinary Python running with your user permissions. Grapple passes it only the files you declare, so only use source you have read or trust.