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
uint8array 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 auint8array 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
| Attribute | Meaning |
|---|---|
ctx.time | The current time in seconds. |
ctx.width, ctx.height | The 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.seed | A 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.resources | The 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:
| Control | Python value |
|---|---|
| Slider or angle | float |
| Toggle | bool |
| Text | str |
| Point or vector | a dict with "x" and "y" |
| Colour | a 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.