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
gpbound to the open project. If it definesrun(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.Errorthat 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_scripton 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),kindandid.- Time
start,endandduration, in seconds on the object's own composition clock.- Structure
parent,children,layer,comp,z_order(higher is in front),driven_by,used_byandrelations.- Where it is
presented_transform(at)gives position, scale, rotation and opacity after parents and layout.bounds(at),screen_bounds(at)(in output pixels) andin_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-leftorleft, 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.
columnssets the sheet's grid. size,crop- A number scales the composition's output size;
(width, height)sets it.cropis(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.
overlaygp.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)anddir(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, andhelp(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.pyiin the script's working directory, and published to MCP clients asgrapple://python/stubs.