Scripts with gp.

Ape does its project work in Python. For each piece of work it writes one script against gp, Grapple's API over the open project: the script finds what it needs, works out the values, makes the changes and looks at the result. The whole script lands as one row in History, so you can read it, keep it, or undo it in one step.

How a script runs

Ape runs a script with the tool python.run_script, giving it a label and a mode. The label names the History row. The mode is apply, or dry_run to run everything and keep nothing.

  • The script's top level runs with gp bound to the open project. If it defines run(ctx), that's called afterwards.
  • Every change stages as the script goes, and later lines read the staged state. Add a clip, and the next line can find it, render it or measure it.
  • When the script ends, its changes commit together as one History row with the script's label. A script that changes nothing makes no row.
  • An uncaught exception commits nothing. A gp.Error that the script catches undoes only the call that raised it, and the script carries on.
  • What the script prints comes back with the result, with any images it showed. Keep output short: a summary, not a table of every key.

Scripts read the project and can write files in the chat's scratchpad and the project's Workbench. They have no network access. Files a script writes aren't part of the undo.

Run one yourself

The gp API is bound when a script runs through python.run_script. You can reach that in two ways.

  • Ask Ape. Paste a script into the Ape tab and ask it to run it, or describe the change and let Ape write the script.
  • Connect another agent. With Let other agents use Grapple turned on in Settings, Other agents, MCP clients such as Claude Code and Codex can call python.run_script on your open project. See Let other agents use Grapple.

Scripts you run from the Code panel use a separate, smaller API through ctx.project: see Your scripts in Code.

A first script

Twelve letters, each on its own text clip named Letter 1 to Letter 12 in a composition called Main, stagger in a quarter of a second apart:

main = gp.comp("Main")
letters = main.find("Letter *", kind="clip")
for i, letter in enumerate(letters):
    t = 0.3 + i * 0.25
    letter.scale.keys([(t, (0, 0), "ease_out"), (t + 0.6, (100, 100))])
    letter.opacity.keys([(t, 0, "ease_in_out"), (t + 0.3, 100)])
print(f"keyed {len(letters)} letters")
gp.render(main, at=1.5).show("Letters at 1.5 s")

The loop is ordinary Python. Each keys call writes real keys you can see in the Graph and change by hand. The last line renders a frame from the staged project and shows it with the result, so Ape checks the picture before it reports back.

Find objects

Every object is a handle: an identity in the project. Reading a handle's facts and properties asks the project each time, so a handle never goes stale.

main = gp.comp("Main")                 # a composition by name or path; exact, then ignoring case
v1 = main["V1"]                        # a child by name
title = main["V1/Title"]               # a path of names below the composition
words = main.find("Word *", kind="clip")    # a glob on the name (* and ?), in authored order
late = [c for c in main.clips if c.start >= 3.0]   # select by any fact with a comprehension
cams = gp.find("Camera*", kind="camera")    # anywhere in the project
picked = list(gp.selection)            # what you had selected when the run started
plate = gp.project.asset("plate.mp4")  # an imported asset by name

Handles come in classes by kind, under gp.objects: Comp, Layer, Clip, Camera, Light, Effect, Marker, Collection, Member, Relation and Asset. A composition has .clips and .layers, and a layer has .clips. A name that matches nothing, or more than one object, raises gp.Error with the candidates it could mean, closest first.

Read facts

Identity
name, path (the names from the composition down), kind and id.
Time
start, end and duration, in seconds on the object's own composition clock.
Structure
parent, children, layer, comp, z_order (higher is in front), driven_by, used_by and relations.
Where it is
presented_transform(at) gives position, scale, rotation and opacity after parents and layout. bounds(at), screen_bounds(at) (in output pixels) and in_frame(at) say where it lands and whether it shows.

To read many objects at one moment, use one call: gp.read(words, ["start", "screenBounds"], at=1.5) returns one row per object, in order.

Read and set properties

A property is an attribute: title.opacity, title.position, clip.playback_rate. The name is the one Grapple registers, matched without case, spaces or underscores, so the Inspector's label works, as does the last part of the property's id. .x, .y and .z name one axis. obj.prop("builtin.transform.opacity") reaches any property by its full id.

title.opacity.value          # the authored value, before keys and drivers
title.opacity.at(2.0)        # the value at 2 s, with keys, drivers and programs
title.opacity = 50           # set the static value
title.position.x = 400       # one axis
title.opacity.animated       # whether keys, a driver or a program move it
print(title.props())         # every property: type, units, choices, settable, animated, values

Values use the property's units: pixels from the top left, degrees, percent and seconds. A vector or colour can be a tuple, such as (100, 100). Setting a property that has keys writes a key at the playhead.

To set the same properties on many objects in one step, use gp.set. A list gives one value per object, in order:

gp.set(letters, opacity=0, scale=(80, 80))
gp.set(letters, position=[(360 + i * 200, 540) for i in range(len(letters))])

Some settings aren't properties. A marker's time, an effect's on and off, and a clip's fades are edit steps: gp.step("marker.update", ...), gp.step("effect.set_bypass", ...) and gp.step("timeline.update_clip_audio_mix", ...).

Keys and easing

prop.keys writes a list of keys. Each key is (time, value) or (time, value, ease), where the ease says how the value leaves that key: hold, linear, ease_in, ease_out, ease_in_out, smooth_through or overshoot.

title.opacity.keys([(0, 0, "ease_out"), (0.6, 100)])          # replaces every key
title.opacity.keys([(2.0, 50)], mode="merge")                  # adds, or replaces a key at that time
title.opacity.key(gp.marker("drop", offset=-0.25), 100)        # one key, anchored to a marker
title.position.x.keys([(0, 760), (1, 960)])                    # one axis
title.position.shift(0.25)                                     # every key a quarter second later
title.opacity.clear()                                          # no keys

Times in a key are seconds on the object's composition clock. A key whose time is gp.marker(...) is anchored to that marker, so moving the marker moves the key.

prop.keyframes returns every stored key as a gp.Key with all its fields: time and anchor, value, interpolation, tangents, holds and the axis it belongs to. prop.keys(prop.keyframes) writes the same animation back, and key.replace(interpolation="hold") makes a changed copy:

for key in title.opacity.keyframes:
    print(key.time, key.value, key.interpolation)
title.opacity.keys([k.replace(interpolation="hold") for k in title.opacity.keyframes])

Presets

gp.presets holds six readable motions that write ordinary keys into a property: ease, overshoot, bounce, anticipate, pop and hold. Each takes the property, at (the beat it lands on, in seconds or as a marker), and optionally to (the property's authored value by default), from_ (zero by default), duration (one second) and amount.

gp.presets.pop(title.scale, at=gp.marker("drop"), amount=120)
gp.presets.ease(title.opacity, at=1.0, duration=0.5)

With a marker, every key the preset writes is anchored to it. The reference says what each amount does.

Times

A time is a number of seconds, or a typed time that the receiving operation resolves on its own clock:

gp.frames(48)
An exact frame of the receiving composition, counting from 0.
gp.marker("drop", offset=0.25)
A marker, by name, path or handle, and seconds after it. In a key it anchors the key to the marker.
gp.playhead
Where your playhead was.
gp.source(3.2)
Seconds on a piece of footage's own clock.

Typed times don't do arithmetic, because their seconds depend on who reads them. When you need a number, ask: gp.seconds(gp.marker("drop"), in_=main).

Places and regions

Places and regions are resolved by the operation that receives them, so a script never does the projection maths itself.

gp.world(x, y, z=0)
A point in world pixels: x right, y down, z toward the default camera.
gp.point(obj, "center", at=t)
A point of an object: anchor, center, top-left, top, top-right, right, bottom-right, bottom, bottom-left or left, or {"path": 0.5} for halfway along a Path.
gp.rect(x, y, w, h, in_="screen", at=t)
A rectangle in output pixels from the top left.
gp.mask_region(mask, at=t)
The region a mask covers.

Make things

from fractions import Fraction

scene = gp.project.add_composition("Scene", size=(1920, 1080), fps=Fraction(30000, 1001), duration=6)
v1 = scene.add_layer("V1", kind="visual")                 # visual, audio or adjustment
title = v1.add_text("Hello", start=0, end=4, font="Inter", size=120)
title.rename("Title")                                     # a new text clip has no name
cam = scene.add_camera("Orbit", focal_length=35)          # focal length in millimetres
card = gp.step("timeline.create_shape_clip", trackNodeId=v1.id, kind="rounded_rectangle",
               timelineRange={"start": 0, "end": 4},
               geometry={"width": 600, "height": 340, "cornerRadius": 24})
gp.step("marker.create", ownerNodeId=scene.id, time=2.0, label="drop")

Without a font and size, text is Inter at 48 pixels, white, centred in a box 80% of the frame wide and 25% high. title.created["layout"] reports whether the text overflows its box. To place footage, import it first with the workspace.import_media tool, then v1.add_media(gp.project.asset("plate.mp4"), start=0, source_range=(8.5, 10.0)) places its picture from 8.5 to 10 seconds of the source.

Everything else is made with its edit step and that step's exact arguments, such as effect.create, collection.create and program.author. Steps and tools lists them all.

Drive properties live

Keys are fixed values at fixed times. To make a property follow a rule at every frame, drive it:

card.opacity.drive("value * (0.5 + 0.5 * math.sin(time * 6.28))")   # a live expression
card.opacity.drive(None)                                             # remove it again

The expression can use time (composition seconds), value (the property's own keyed or static value), gp and math. For several properties from one function, make a controller. Each name the function returns drives one property:

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 vector, and one axis takes a number. Programs that work on pictures or geometry are made with the program.author step: see Python programs and effects.

Group, try and recover

The run is already one transaction. Two blocks help inside it.

with gp.edit("Fade in") as e:           # groups calls under a label
    title.opacity.keys([(0, 0), (1, 100)])
print(e.receipt)                        # what the block staged; it commits with the run

with gp.speculate():                    # try a change, look at it, then throw it away
    title.scale = (300, 300)
    gp.render(main, at=1.0).show("Scaled up, discarded")
print(title.scale.value)                # unchanged after the block

Every failed operation raises gp.Error. It carries code, message (what happened and what to do next), path (the argument that failed), got and expected, candidates (the objects a name could mean, as handles) and next (a tool call that can help). Catch it, and only that call is undone:

try:
    layer.add_text("Hi", start=0, end=2, font="Intre")
except gp.Error as e:
    print(e.code, e.message, e.expected)
    layer.add_text("Hi", start=0, end=2, font="Inter")

A long script can say how far it has got with gp.progress(0.5, "Keyed 120 of 240 letters").

Render and look

gp.render draws frames from the staged project, through the same renderer as the Viewer and the export, and waits for them. show(caption) returns the images with the run's result.

gp.render(main, at=1.0).show("Title at 1 s")
gp.render(main, at=1.0, size=0.5, only=[title],
          overlay=[gp.overlay(title, "bounds")]).show("Bounds")
gp.render(main, times=[0, 1, 2, 3], columns=4).show("Four moments")
gp.render(main, at=2.0, camera=cam).show("Through Orbit")
mix = gp.render(main, range=(0, 8), as_="audio")     # the mixed sound, as a WAV artifact
at, times
One frame, or a labelled contact sheet of several. columns sets the sheet's grid.
size, crop
A number scales the composition's output size; (width, height) sets it. crop is (x, y, width, height) in output pixels.
camera, only, alpha
Look through a camera, isolate some objects, or keep transparency instead of flattening over black.
overlay
gp.overlay(obj, "bounds") draws an object's bounds, and "anchors" labels them with the markers its start and keys are anchored to.
compare
{"against": ..., "as": "split"} or "difference", against a project revision, another time or an image asset.

A preview video is a job, so it's a tool call outside the script, through the render tool. An image's problems says if anything couldn't be drawn.

Measure

Measurements read rendered pixels, evaluated motion and the sound mix, so a script can check its own work with numbers. Each kind is a function with its own keywords:

gp.measure.colour(of=main, at=1.0, region=gp.rect(860, 500, 200, 80, in_="screen", at=1.0))
gp.measure.contrast(of=title, at=1.0)          # the title against the ring of pixels around it
gp.measure.bounds(of=[title], at=1.0)
gp.measure.overlap(of=[title, logo], at=1.0)   # how much of the title the logo covers
gp.measure.landing(of=title, range=(0, 4), near=gp.marker("drop"))
gp.measure.loudness(of=main, range=(0, 8))

The kinds are bounds, colour, contrast, cuts, difference, flicker, landing, loop, loudness, motion, overlap, plane_fit, reprojection and signal. The reference says what each one measures.

Steps and tools

The friendly calls above cover the common work. Everything else is one call away, with the exact contract Grapple registers:

gp.step("timeline.edit", operation="split", clipNodeIds=[clip.id], time=3.0)
gp.step("composition.update", compositionNodeId=main.id, duration=10)
rows = gp.tool("project.query", select=main, fields=["name", "kind"])

gp.step stages one edit step: creating, setting, timing, effects, collections, rigs, storyboards and the rest. gp.tool calls a tool. Both return a gp.Result, whose .id and .object give the object a call created. In gp.step, a string that starts with $ refers to an earlier step's result; write $$ for a literal dollar sign. The friendly calls always treat strings as plain text, so title.text = "$12" writes $12.

What runs inside a script

A script stages edits and reads the staged project: queries, renders of frames, sheets and audio, measurements, and the artifact reader. Work that saves on its own can't be part of a script's single transaction, so it's a separate tool call, before or after the script: importing media, analysing audio or tracking footage, solving a camera, exporting, and rendering a preview video. Calling one inside a script is refused with a message naming the tool to call instead.

The usual pattern is a tool call, then a script that works with its result. For example, analysis.audio_structure measures the beats of a song and returns an artifact. A script then reads the beats with gp.tool("analysis.get_artifact", artifactId=...) and keys the titles to them.

Find your way around gp

help(...), dir(...)
Every class, method, step and tool has a docstring. help(gp.Prop.keys), help(gp.objects.Clip) and dir(title) print to the output.
gp.step.help(name), gp.tool.help(name)
One step's or tool's arguments. help(gp.step) lists every step, and help(gp.tool) every tool with where it runs.
gp.search("glow")
Prints every class, method, step, tool and measure kind that matches, with its signature and one line.
gp.examples()
Lists the topics of short runnable examples: find, create, keys, many, steps, drive, render, measure, speculate, errors, tools and presets. gp.examples("keys") prints one.
gp.api()
Prints the type stub of the run's API. The same stub is saved as grapple.pyi in the script's working directory, and published to MCP clients as grapple://python/stubs.

Worked examples