Three kinds to start from
In Code, choose New code, then Operator…, and pick a starting point. Each makes a working program in the open composition that you then change.
- Image filter
- Changes pictures. Select a visual layer first to filter that layer, or nothing to filter the whole composition. It starts by passing the frame through unchanged.
- Shape generator
- Draws copies of a shape. Select a shape clip first. It starts as a ring of twelve squares, with a Count control.
- Transform controller
- Drives an object's transform from Python. Select an object first. It starts by returning the object's current position, rotation, scale and anchor as controls you can change.
The new operator opens in Code under Operators. Its Contract section lists the program's inputs, outputs and parameters, and Add a parameter gives it a number, a true or false value, or text with a default. Edit the source, then press Apply (Ctrl+Enter). Code checks the program as you type and says if it fails at the playhead before you apply it.
Write an image filter
A program defines evaluate(ctx) and returns a dictionary with one entry for each output. An image filter reads its input frame and returns a new one:
import numpy as np
def evaluate(ctx):
frame = ctx.frames["input_frames"].copy() # height × width × 4, RGBA
frame[..., :3] = 255 - frame[..., :3] # invert the colour, keep the alpha
return {"frames": frame}
Input frames are read-only, so copy one before you change it. Return an array of exactly the shape and type you were given. A posterise with a control for the number of levels:
import numpy as np
def evaluate(ctx):
frame = ctx.frames["input_frames"]
levels = max(2, int(ctx.params["levels"]))
step = 255 / (levels - 1)
out = frame.copy()
out[..., :3] = (np.round(frame[..., :3] / step) * step).astype(np.uint8)
return {"frames": out}
Add levels as a number parameter with a default of 4 in the Contract section, and it reaches the program as ctx.params["levels"].
Generate shapes
A shape generator returns a gp.Shapes list. The shape clip it's attached to draws one copy of its own kind and style for each entry. This is the starter:
import math
import grapple as gp
def evaluate(ctx):
count = int(ctx.params['count'])
shapes = gp.Shapes()
for key in range(count):
angle = math.tau * key / count
shapes.append(gp.Shape(width=24, height=24), id=key,
position=gp.Vec2(200 * math.cos(angle), 200 * math.sin(angle)))
return {'geometry': shapes}
shapes.append(shape, id=, ...)- Adds one copy.
idis required and unique: it keeps each copy's identity while the list changes.positionis composition pixels from the clip's position, with y down.scaleandopacityare percent,rotationis degrees, andcolor, from 0 to 1, multiplies the clip's colours. gp.Shape(...)- One copy's geometry:
width,heightandcorner_radiusin pixels,line_start_x,line_start_y,line_end_xandline_end_yin pixels from the copy's centre, andpath_trim_start,path_trim_endandpath_trim_offsetfrom 0 to 1. - A
gp.Path - Append a path instead of a shape for a copy with its own outline; its points are pixels from the copy's position.
Make the ring breathe by reading the time:
import math
import grapple as gp
def evaluate(ctx):
count = int(ctx.params["count"])
radius = 200 + 40 * math.sin(ctx.time * 2)
shapes = gp.Shapes()
for key in range(count):
angle = math.tau * key / count + ctx.time * 0.5
size = 16 + 12 * (0.5 + 0.5 * math.sin(ctx.time * 3 + key))
shapes.append(gp.Shape(width=size, height=size), id=key,
position=gp.Vec2(radius * math.cos(angle), radius * math.sin(angle)),
rotation=math.degrees(angle))
return {"geometry": shapes}
Controllers
A controller drives properties from one function. Its outputs are bound to properties, and evaluate returns one value per output, keyed by the output's name. A Transform controller from Code starts with one output for each transform property, each built from the controller's own parameters, so it begins by holding the object where it is. Change a line to compute that value instead, such as adding gp.wiggle(ctx.time, 4, 0.5, 30) to the X of the position.
Ape and connected agents make controllers with their own output names. This one, from Scripts with gp, drives a card's scale and its vertical position from one rule:
gp.controller("Pop in", drives={"scale": card.scale, "y": card.position.y},
active_range=(0, 4), source='''
def evaluate(ctx):
s = gp.ease(ctx.time, 0, 0.6, 0, 100)
return {"scale": gp.Vec3(s, s, 100), "y": 540}
''')
A whole vector property needs a gp.Vec2 or gp.Vec3, and a single axis takes a number. In the Inspector, a program shows as a named Controller, with a row for each input it reads and a link to that input's source.
What ctx holds
ctx.frames- The input frames by port name, as NumPy arrays shaped height × width × 4.
ctx.params- The program's parameters by name. A parameter with a control, such as the Shape generator's Count, shows on the program in the Inspector, where you can key it like any other value.
ctx.inputs- Values and resources connected to the program's input ports. An input with nothing connected reads
None. ctx.time- The time in composition seconds.
ctx.width,ctx.height- The size of the frame being rendered, or of the composition for a program with no frames. A preview can be smaller than the export, so size things against these.
ctx.quality"interactive"while you work,"final"for the export.ctx.seed- A repeatable seed for random choices, so a frame renders the same each time.
ctx.resources- Read-only project files the program declares, each a
gp.Resourcewith its path, content hash, media type and size. ctx.camera,ctx.lights- The composition's camera and lights at this time, when the program asks for them.
ctx.i,ctx.n,ctx.target,ctx.own- For a program that drives several objects: which one this is, how many there are, its identity, and its own value. See One program, many objects.
Asking for a name that isn't there raises an error that lists the names that are, such as "ctx.params has no 'level'; it has 'levels'."
What a program returns
Always a dictionary with exactly the declared outputs, no more and no fewer. Each value matches its output's type: a number, True or False, text, gp.Vec2, gp.Vec3, gp.Rect, gp.Path, gp.AssetRef, gp.Shapes, a gp.Table, or a NumPy frame. A wrong set of names is refused with a sentence listing the missing and unexpected ones.
Pixels and colour
Every frame is RGBA with straight alpha: the colour isn't multiplied by the alpha. Frames come in one of two formats, chosen for the whole program:
- 8-bit sRGB
- The default.
uint8values from 0 to 255 in sRGB colour, the way image files store them. Right for most looks: posterise, threshold, pixel art, channel shuffles. - 32-bit linear
float32values in the linear working colour space, where 1.0 is white and brighter light goes above it. Use it for anything that should behave like light: exposure, blur, glow, mixing.
An exposure control in linear light, with a stops parameter:
def evaluate(ctx):
frame = ctx.frames["input_frames"].copy()
frame[..., :3] *= 2.0 ** ctx.params["stops"]
return {"frames": frame}
Grapple keeps light above white inside the composition; the export clips at white.
Where a program sits
- On a clip
- Works on the clip's own pixels, before its transform, mask, opacity and blend. A generator supplies the clip's pixels.
- On a layer
- Works on the layer's frames. A generator becomes the layer's content.
- On the composition
- Works on the finished frame. A generator becomes the final frame while it's active.
- Between two frames
- Combines a foreground and a backdrop into the composition's final frame.
- On a property
- Its output sets the property: a controller.
A frame on a clip is the clip's own canvas at the composition's render size, with room around the content for effects that spread pixels beyond it. Pixel (0, 0) is the top left.
Read cameras, lights, paths and tables
A program can take the project's own objects as inputs. They're ordinary connections you and Ape can see and change, and they follow the objects: move the camera or edit the path, and the program reads the new values.
gp.Camera- The evaluated camera: its matrices as read-only 4 × 4 arrays, and
camera.project(point), which returns whether a world point is visible, itsuvin the output and its depth. It applies a solved camera's lens distortion and the footage's framing. gp.LightSet- The composition's lights, as columns of NumPy arrays.
gp.PathSet- A shape's outline after its animation, drivers, trims and transforms, as tables of members, contours and points.
paths.sample(contour, distance)returns the position and direction at a distance along a contour. gp.Table- Typed columns with one row count, such as positions and colours. A program can return one for a shader or another program to read row by row:
gp.Table({"position": ("vec2", {"meaning": "position", "unit": "composition_pixel", "space": "composition_raster"}, points)}).
Positions use composition pixels from the top left, with y down and z toward the viewer. dir() on any of these lists its fields, and reading a field that isn't there names the ones that are.
One program, many objects
A program that drives the same property on several objects runs once for each, in order. ctx.i is this object's place, from 0, ctx.n is how many there are, and ctx.target identifies it. ctx.own is the object's own value for the property, with its keys, so a program can add to the animation rather than replace it. With one output, ctx.own is that value; with several, it's a dictionary by output name.
import math
def evaluate(ctx):
# Each object bobs on top of its own animation, a little behind the one before.
phase = ctx.i / max(1, ctx.n) * math.tau
return ctx.own + 12 * math.sin(ctx.time * 4 - phase)
A program with a single output can return the value itself instead of a dictionary.
Keep it fast
- Use NumPy on whole arrays. A Python loop over every pixel of a 4K frame is millions of steps; one array expression is one.
- For a program that returns values, Grapple notes which parts of
ctxit read and reuses its result while they stay the same. A shape generator that doesn't readctx.timeisn't run again on every frame. - Keep work that doesn't change between frames in
gp.memo(key, factory), such as a lookup table built from a parameter. - Size things against
ctx.widthandctx.height, so a smaller preview frame does less work and the export still looks the same. - Pick the pixel format you need. 8-bit frames are a quarter of the size of 32-bit ones.
Programs Ape writes
Ask Ape for a look the library doesn't have, and it can write the program for you, in Python or as a GLSL shader, with its values as controls. It uses the program.author step, which takes the same source, ports, parameters and placement described here:
layer = gp.comp("Main")["Plate"]
gp.step("program.author", displayName="Posterise", language="python-3",
placement={"kind": "track", "trackNodeId": layer.id},
params=[{"name": "levels", "label": "Levels",
"description": "How many steps each colour channel has.",
"value": 4, "editor": {"family": "scalar", "min": 2, "max": 16, "step": 1}}],
source='''
import numpy as np
def evaluate(ctx):
frame = ctx.frames["input_frames"]
levels = max(2, int(ctx.params["levels"]))
step = 255 / (levels - 1)
out = frame.copy()
out[..., :3] = (np.round(frame[..., :3] / step) * step).astype(np.uint8)
return {"frames": out}
''')
With no ports declared, a program placed on frames is a filter with an input named input_frames and an output named frames. The program is a project object you can open in Code and change, like one you wrote.