AI editor to build, animate & export layered short-form video projects via one tool catalog.
Maintain this server? Claim it
Call this server's tools from your browser. Nothing to install.
Bearer mp_… API key. Optional: a client that follows the MCP authorization spec signs in instead (Morpha is its own OAuth 2.1 server) and is issued one. Otherwise mint a key at https://morphareels.ai/app/settings — free with any account.
Requests go through mcptry because browsers can't call most MCP servers directly. Header values are sent to the server for that request only and are never stored or logged.
Tool list from our last check. Connect live to refresh it.
Add an audio overlay (mp3/m4a/wav/ogg) scheduled at a frame-aligned start. The asset must already be uploaded (upload_audio, or the editor for a file from the person's disk); pass the upload's returned `filename`, and its `name` as the track's label. 30 fps; convert seconds with frames = round(s * 30). Plays in the editor preview and is mixed into the MP4 export.
The stored audio filename an upload returned.
Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates.
Frame at which the overlay starts (0-indexed, 30 fps).
Linear gain 0..2. Default 1.
Optional label people see on the track: pass the upload's returned `name`.
Optional end frame; omit to play the asset's full natural length from startFrame.
Linear fade-in length in frames. Default 0.
Linear fade-out length in frames. Default 0.
Optional video layer element id ("video.<id>") to weld this overlay to. When set, the editor renders the overlay as a waveform footer on that clip and drags it with the clip instead of showing a standalone bottom row.
This tool isn't marked read-only, so it may change something on the server or in an account you connected.
Add an audio overlay (mp3/m4a/wav/ogg) scheduled at a frame-aligned start. The asset must already be uploaded (upload_audio, or the editor for a file from the person's disk); pass the upload's returned `filename`, and its `name` as the track's label. 30 fps; convert seconds with frames = round(s * 30). Plays in the editor preview and is mixed into the MP4 export.
| Argument | Type | Description |
|---|---|---|
| gain | number | Linear gain 0..2. Default 1. |
| name | string | Optional label people see on the track: pass the upload's returned `name`. |
| endFrame | number | Optional end frame; omit to play the asset's full natural length from startFrame. |
| filename* | string | The stored audio filename an upload returned. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| startFrame* | number | Frame at which the overlay starts (0-indexed, 30 fps). |
| fadeInFrames | number | Linear fade-in length in frames. Default 0. |
| fadeOutFrames | number | Linear fade-out length in frames. Default 0. |
| sourceLayerId | string | Optional video layer element id ("video.<id>") to weld this overlay to. When set, the editor renders the overlay as a waveform footer on that clip and drags it with the clip instead of showing a standalone bottom row. |
{
"type": "object",
"required": [
"filename",
"startFrame",
"projectId"
],
"properties": {
"gain": {
"type": "number",
"description": "Linear gain 0..2. Default 1."
},
"name": {
"type": "string",
"description": "Optional label people see on the track: pass the upload's returned `name`."
},
"endFrame": {
"type": "number",
"description": "Optional end frame; omit to play the asset's full natural length from startFrame."
},
"filename": {
"type": "string",
"description": "The stored audio filename an upload returned."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"startFrame": {
"type": "number",
"description": "Frame at which the overlay starts (0-indexed, 30 fps)."
},
"fadeInFrames": {
"type": "number",
"description": "Linear fade-in length in frames. Default 0."
},
"fadeOutFrames": {
"type": "number",
"description": "Linear fade-out length in frames. Default 0."
},
"sourceLayerId": {
"type": "string",
"description": "Optional video layer element id (\"video.<id>\") to weld this overlay to. When set, the editor renders the overlay as a waveform footer on that clip and drags it with the clip instead of showing a standalone bottom row."
}
}
}Build a caption track from pre-timed lines (e.g. derived from transcribe_clip's word timings). mode "line-sync" (default) creates one text layer per line, each shown only during its [startFrame, endFrame) window via hold-eased opacity keyframes — the active-line karaoke read; mode "static" makes a single layer with all lines joined. `style` picks a preset look. Lines default to a lower-third band. The caption layers are always wrapped in a "captions" group so they don't clutter the layers list. Returns the created text element ids plus `groupElementId` (the captions group).
| Argument | Type | Description |
|---|---|---|
| x | number | Caption band centre x. Default canvas centre. |
| y | number | Caption band centre y. Default lower third (~80% of height). |
| mode | string | "line-sync" (default): one timed layer per line. "static": one layer with all lines. |
| lines* | array | Caption lines in order. Each: { text, startFrame, endFrame } — frames are 0-indexed at 30fps. |
| style | string | Caption look preset. Default "classic". |
| width | number | Band width. Default ~86% of canvas width. |
| height | number | Band height. Default ~16% of canvas height. |
| projectId |
Add or overwrite a colour keyframe on a fill track. Targets a leaf (shapes.<id>, image.<id>, video.<id>, group.<id>). The canvas backdrop is the pinned is_background image_layer (the literal 'background.canvas' is accepted as a synonym for its element id). The value is a Fill — adjacent keyframes crossfade stop-by-stop. 30 fps; frame is 0-indexed.
| Argument | Type | Description |
|---|---|---|
| frame* | number | Frame number, 0-indexed. 30 fps so frame 30 = 1 second. |
| value* | string | object | Either '#rrggbb' (promoted to solid) or a Fill object: {type:"solid",color} / {type:"linear",stops:[{pos:0..1,color}],angle?} / {type:"radial",stops:[{pos:0..1,color}],cx?,cy?,radius?}. Adjacent keyframes crossfade the gradient stop-by-stop. |
| easing | string | Interpolation to the next keyframe. Default linear. |
| property* | string | Currently only 'fill' is supported. |
| elementId* | string | Element id (shapes/image/video/group prefixed). The pinned is_background image_layer is the canvas backdrop; 'background.canvas' is also accepted as a synonym. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
Draw an editable arrow / curved line — a stroked quadratic bezier with an arrowhead. Specify the two endpoints (x1,y1)→(x2,y2) in canvas pixels; `bend` pushes the midpoint perpendicular (px, 0 = straight line, positive/negative curves either way). `color` #rrggbb, `stroke_width` px, `arrow_head` none|end|both (default end). Use this for callout arrows (e.g. swooping into a link).
| Argument | Type | Description |
|---|---|---|
| x1* | number | Start x (canvas px). |
| x2* | number | End x (canvas px) — the arrowhead end. |
| y1* | number | Start y (canvas px). |
| y2* | number | End y (canvas px). |
| bend | number | Perpendicular bow of the curve in px. 0 = straight. |
| color | string | Stroke colour as #rrggbb. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| arrow_head | string | |
| stroke_width |
Add one hostname to the project's embed allowlist (the hostnames permitted to load the public <morpha-video> embed). Idempotent — re-adding an existing entry is a no-op. The origin is normalized to a bare lowercased hostname (scheme/port/path stripped). Morpha sends that website no request; any page on it may then load the project. Reference: https://morphareels.ai/docs/tools#addembedoriginorigin
| Argument | Type | Description |
|---|---|---|
| origin* | string | Hostname or URL to allow, e.g. "example.com" or "https://example.com". Normalized to a bare lowercased hostname. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"origin",
"projectId"
],
"properties": {
"origin": {
"type": "string",
"description": "Hostname or URL to allow, e.g. \"example.com\" or \"https://example.com\". Normalized to a bare lowercased hostname."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Add an item from the user's Collection into a project as a self-contained COPY. Pass the destination `projectId`, plus the `sourceProjectId` + `elementId` of an item from list_collection. Copies that layer (a whole group brings its children) plus its image/clip/font bytes into the destination at fresh ids and its original canvas position — fully detached, so it is immediately yours to edit and nothing links back to the source (deleting or changing the source never affects this copy). The element must actually be in the source project's collection and you must be able to read that project. An open editor on the destination picks the copy up within a few seconds — no refresh needed. Returns { addedElementId, count }.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | The item's element id (from list_collection) — any leaf or group.<id>. |
| projectId* | string | Destination project the item is copied INTO. |
| sourceProjectId* | string | The item's home project id (from list_collection). |
{
"type": "object",
"required": [
"projectId",
"sourceProjectId",
"elementId"
],
"properties": {
"elementId": {
"type": "string",
"description": "The item's element id (from list_collection) — any leaf or group.<id>."
},
"projectId": {
"type": "string",
"description": "Destination project the item is copied INTO."
},
"sourceProjectId": {
"type": "string",
"description": "The item's home project id (from list_collection)."
}
}
}Add an image layer. The image must already be uploaded: upload_image takes a web URL or a file attached in the chat, and a file from the person's disk is uploaded in the editor. Every upload returns { filename, name, width, height }: pass `filename` (the stored file's id) here, and `name` as the layer's label. SIZE THE BOX TO THE PICTURE: pass `width` alone (or `height` alone) and the other side is filled from the image's own aspect, so the picture is never stretched; a box with both sides that does not match the image's aspect stretches the picture to fill it. To duplicate an existing layer, reuse its filename — the editor auto-assigns a fresh id.
| Argument | Type | Description |
|---|---|---|
| x* | number | Centre x in 1080-wide base coords. |
| y* | number | Centre y in 1920-tall base coords. |
| name | string | Optional label people see in the Inspector and Timeline: pass the upload's returned `name`. Without it the layer is labelled by an older project's own filename, or by kind; never by a stored id. |
| block | object | OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`. |
| width | number | Width in px (> 0). Omit it to derive it from `height` and the image's aspect. |
Add or overwrite a keyframe on a layer's animation track. For leaves (image/video/shape), x/y/rotation keyframes are ABSOLUTE canvas-space values: x and y are the layer centre's pixel position (canvas is 1080×1920), rotation is degrees. For groups, x/y keyframes are translation offsets applied around the group's frozen pivot, and rotation is the group's absolute angle. scale orbits the layer/pivot centre (1 = no change). opacity is 0..1. 30 fps. When a keyframe track is present on a property, it OVERRIDES the layer's static base value at every frame.
| Argument | Type | Description |
|---|---|---|
| frame* | number | Frame number, 0-indexed. 30 fps so frame 30 = 1 second. |
| value* | number | Track value at this frame. |
| easing | string | Interpolation to the next keyframe. Default linear. |
| property* | string | |
| elementId* | string | Layer id (video.<id>, image.<id>, shapes.<id>, or group.<id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
Add many keyframes to ONE element's ONE property in a single call, with an optional loop mode applied in the same call. The idiomatic form when every layer in a multi-element animation gets its own track (ripple dot pulse, snowflake fall, equaliser-bar wave). Factors elementId + property out of the loop body and folds set_track_loop in. Use set_keyframes_batch instead when you need to mix elements/properties in one atomic call.
| Argument | Type | Description |
|---|---|---|
| loop | string | Optional extrapolation mode applied to this track in the same call. Default 'hold'. |
| property* | string | |
| elementId* | string | Layer id (video.<id>, image.<id>, shapes.<id>, text.<id>, or group.<id>). |
| keyframes* | array | Keyframes for this track (one or more). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"property",
"keyframes",
"projectId"
],
"properties": {
"loop": {
"enum": [
"hold",
"loop",
"ping-pong",
"cycle"
],
"type": "string",
"description": "Optional extrapolation mode applied to this track in the same call. Default 'hold'."
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string",
"description": "Layer id (video.<id>, image.<id>, shapes.<id>, text.<id>, or group.<id>)."
},
"keyframes": {
"type": "array",
"items": {
"type": "object",
"required": [
"frame",
"value"
],
"properties": {
"frame": {
"type": "number",
"description": "Frame number, 0-indexed (30 fps)."
},
"value": {
"type": "number",
"description": "Track value at this frame."
},
"easing": {
"enum": [
"linear",
"easeIn",
"easeOut",
"easeInOut",
"outQuart",
"outExpo",
"outBack",
"inBack",
"inOutBack",
"cubicBezier",
"hold"
],
"type": "string",
"description": "Interpolation to the next keyframe. Default linear."
}
}
},
"description": "Keyframes for this track (one or more)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Append a page to the project — works on any project, turning a single-page video into a multi-page one. Without duplicate_index a blank page is appended, sized to the project's canvas. With duplicate_index the page at that position is deep-copied (a fresh id is minted). There is no limit on page count. The new page becomes the active page; its index is returned. Versions or options of one video (styles to pick from, hooks, languages, a sheet's rows) are pages of ONE project (add_page, duplicate_index), never separate projects: the preview turns pages and exports one video per page. To edit the copies, select_page each one and describe_video for its element ids; to export them, use the editor's Videos option or renderVideo with page.
| Argument | Type | Description |
|---|---|---|
| name | string | Optional name for the new page. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| duplicate_index | number | Optional. 0-based index of an existing page to deep-copy instead of appending a blank one. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"name": {
"type": "string",
"description": "Optional name for the new page."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"duplicate_index": {
"type": "number",
"description": "Optional. 0-based index of an existing page to deep-copy instead of appending a blank one."
}
}
}Add a shape layer. `kind` selects the primitive: rect | ellipse | triangle | star | pentagon | hexagon | arrow | heart | rounded-rect | diamond | parallelogram | trapezoid | semicircle | ring | pill | cross | heptagon | octagon | star-4 | star-6 | sparkle | burst | arrow-left | double-arrow | chevron | block-arrow-up | curve | lightning | speech-bubble | location-pin | checkmark | x-mark | shield | cloud | crescent | teardrop | banner. All are native vector shapes; never substitute an image layer for one. Default-positioned in canvas centre if x/y/w/h omitted. A new shape stacks ON TOP of every existing layer: for a background colour or gradient use set_layer_fill on "background.canvas", not a canvas-sized shape, and to place a shape behind other layers follow with reorder_layer. A shape that covers the whole canvas returns a warning naming the layers it hides.
| Argument | Type | Description |
|---|---|---|
| x | number | |
| y | number | |
| kind* | string | |
| block | object | OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`. |
| color | string | Fill colour as #rrggbb. |
| width | number |
Add or overwrite a speed-ramp (time-remap) keyframe on a video layer, for a rate that CHANGES over the clip. `frame` is a PROJECT-timeline frame and must sit on the clip (at or after its timeline_start_frame); the curve itself is anchored to the clip, so moving the clip carries the ramp with it and never changes its duration. For a constant slower/faster clip use set_clip_speed instead. `rate` is the playback rate at `frame`: 1 = real-time, 0.5 = half-speed, 2 = double-speed. Range: rate in [0.1, 8]. The ramp multiplies the layer's constant speed, and the clip's timeline length is derived from the resulting curve.
| Argument | Type | Description |
|---|---|---|
| rate* | number | Playback rate at this frame (1 = real-time, in [0.1, 8]). |
| frame* | number | Project-timeline frame number. |
| elementId* | string | video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"frame",
"rate",
"projectId"
],
"properties": {
"rate": {
"type": "number",
"description": "Playback rate at this frame (1 = real-time, in [0.1, 8])."
},
"frame": {
"type": "number",
"description": "Project-timeline frame number."
},
"elementId": {
"type": "string",
"description": "video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Create a new text layer — a first-class leaf that animates, groups, and z-orders exactly like an image or shape. The renderer draws live typeset text (multi-line, auto-fit to the box). Defaults: x/y = canvas centre, width 900, height 320, font_family "Hanken Grotesk", text_size derived from existing text layers (or ~10% of canvas height). Also accepts full type styling: font_weight (100-900), font_style (italic), text_transform, letter_spacing, line_height, text_align, text_autofit ("hug" default = box shrink-wraps the text at the fixed text_size, honouring literal newlines, so it can't re-wrap between preview and export — bake your own "\n" line breaks / "wrap"=fixed size + word-wrap to the box / "fit"=auto-size to fill the box, grows and shrinks / "shrink"=legacy shrink-only), text_valign (top/middle/bottom), an outline (stroke_width + stroke_color), and text_shadow. Returns the new layer's id + element id (text.<id>), plus text_box (the painted box, measured from the font's glyphs), fits ({canvas, safe_area}, judged at the layer's static position) and, when the text runs past either, a warning naming the text_size that fits: add \n line breaks or lower text_size.
| Argument | Type | Description |
|---|---|---|
| x | number | Centre x in canvas px. Defaults to canvas centre. |
| y | number | Centre y in canvas px. Defaults to canvas centre. |
| text | string | The text to render. Newlines are honoured as hard line breaks. |
| block | object | OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`. |
Add a layer to the user's reusable Collection. Pass ANY element id — a leaf (text.<id>, image.<id>, …) or a whole group.<id> (a lower-third, logo sting, brand intro). It then appears in the user's Collection (list_collection), where they — and, if this project is in a workspace, every teammate — can drop a self-contained COPY of it into any other project (add_from_collection). Copies are IMMUTABLE: adding copies the whole subtree + its asset bytes, so editing or deleting this source never changes a copy already placed elsewhere. Works on solo projects too (a personal Collection). Give the layer a clear name first (rename_layer / rename_group) — that name is what shows in the Collection.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | The element id to add — any leaf (text/image/video/shapes.<id>) or a group.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"elementId": {
"type": "string",
"description": "The element id to add — any leaf (text/image/video/shapes.<id>) or a group.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Add a video layer. The clip must already be uploaded; clips are uploaded in the MorphaReels editor. An upload returns { filename, name }: pass `filename` (the stored clip's id) as `clip`, and `name` as the layer's label. To duplicate an existing layer, reuse its clip filename — the editor auto-assigns a fresh id.
| Argument | Type | Description |
|---|---|---|
| x* | number | Centre x in 1080-wide base coords. |
| y* | number | Centre y in 1920-tall base coords. |
| clip* | string | The stored clip filename an upload returned (an id such as 3f2a9c1e-….mp4, or an older project's own name). |
| name | string | Optional label people see in the Inspector and Timeline: pass the upload's returned `name`. |
| width* | number | Width in px (must be > 0). |
| height* | number | Height in px (must be > 0). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
Apply a canned animation preset to a layer.
| Argument | Type | Description |
|---|---|---|
| preset* | string | |
| elementId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| startFrame | number | Frame to anchor on. Default 0. |
{
"type": "object",
"required": [
"elementId",
"preset",
"projectId"
],
"properties": {
"preset": {
"enum": [
"fade-in",
"fade-out",
"pulse",
"slide-in-left",
"slide-in-right",
"slide-up",
"shake",
"pop"
],
"type": "string"
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"startFrame": {
"type": "number",
"description": "Frame to anchor on. Default 0."
}
}
}Apply the same preset to a LIST of layers with a per-element startFrame offset — one call instead of N apply_preset calls. For diagonal pop-in grids, sequential list reveals, ring-pulse sweeps. The startFrame for entry i is `startFrame + i * stagger`. Order the elementIds in the visual order you want the animation to cascade.
| Argument | Type | Description |
|---|---|---|
| preset* | string | |
| stagger | number | Frames between successive elements. Default 1. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| elementIds* | array | Layers to animate, ordered as the cascade should fire (first id gets startFrame; each next gets +stagger frames). |
| startFrame | number | Base frame for the first element. Default 0. |
{
"type": "object",
"required": [
"elementIds",
"preset",
"projectId"
],
"properties": {
"preset": {
"enum": [
"fade-in",
"fade-out",
"pulse",
"slide-in-left",
"slide-in-right",
"slide-up",
"shake",
"pop"
],
"type": "string"
},
"stagger": {
"type": "number",
"description": "Frames between successive elements. Default 1."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"elementIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Layers to animate, ordered as the cascade should fire (first id gets startFrame; each next gets +stagger frames)."
},
"startFrame": {
"type": "number",
"description": "Base frame for the first element. Default 0."
}
}
}Report whether a clip — or every video clip in the project, if `clip` is omitted — has been through the browser-side video-processing pipeline: proxy build, audio split, transcription, OCR (text regions). Processing never runs on the server. The preview proxy is mandatory: every processing run builds it, and the Morpha editor builds any missing proxy for every page's clip when the project is opened. The other steps run in the editor too. Returns { ok: true, data: { clips: [{ clip, processed, steps: { proxy, audio_split, transcript, text_regions } }], allProcessed } }, where each step is 'ready' | 'pending' | 'running' | 'unavailable' | 'cancelled' | 'error'. 'cancelled' is a transcript the user stopped in the editor: it stays stopped across opens, and only the editor's Re-run starts it again. audio_split stays 'pending' until the user splits the clip's audio into its own track in the editor (Inspector → Audio → Split audio); it never gates `processed`, and an unsplit clip plays its own sound. Use it to know whether transcribe_clip / detect_text_regions will return data, and to surface an 'unprocessed' state to the user.
| Argument | Type | Description |
|---|---|---|
| clip | string | Optional clip filename (video.<id>.clip). Omit to report every video clip in the project. |
| projectId* | string | Project the clip(s) belong to. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"clip": {
"type": "string",
"description": "Optional clip filename (video.<id>.clip). Omit to report every video clip in the project."
},
"projectId": {
"type": "string",
"description": "Project the clip(s) belong to."
}
}
}Create a new project and return its server-minted id. The id is an opaque v4 UUID assigned automatically, not chosen by the caller; people see the project's `name`. By default this mints a fresh BLANK project (empty canvas, no layers) — creation is independent from cloning. Pass `fromProjectId` ONLY when you want to clone: the new project then copies that project's JSON, uploaded assets, and uploaded clips, and — like duplicate_project — FOLLOWS THE SOURCE'S workspace by default (cloning a team project stays in that team; a blank project is personal). Pass `workspaceId` (from list_workspaces) to place it in a specific workspace instead — requires an edit-capable role there — or null to force it into the caller's personal space. If the clone source lives in a workspace you can only view, the call FAILS rather than silently making a personal copy — pass workspaceId:null for a personal copy. The user's editor session won't auto-refresh — they'll need to reload to see the new project in the picker. Options or versions of one video (styles to pick from, hooks, languages) are pages of ONE project (add_page, duplicate_index), not new projects: the chat preview turns pages, and separate projects cannot be flicked between.
| Argument | Type | Description |
|---|---|---|
| name | string | Recommended human-readable label, shown verbatim in the editor's project picker (the only user-facing handle for the project). Omit and the picker falls back to the opaque id. |
| workspaceId | string | null | Where to place the project. Omit to FOLLOW THE SOURCE (a clone inherits its source's workspace; a blank project is personal). A workspace id (from list_workspaces) places it there — requires an edit-capable role. null forces the caller's personal space. |
| fromProjectId | string | Optional id of an existing project to CLONE (copies its JSON + uploaded assets + clips). Omit to create a fresh blank project. |
Create a new workspace (a shared team space) owned by the caller, and return it as { id, name, role, memberCount }. Use this when the user wants a new place to group projects — then move_project_to_workspace files projects into it, and create_project can place new ones there directly. The caller becomes its owner. Requires an account with an email (workspace membership is email-based), so a keyless identity cannot create one. Refuses with a conflict if the caller already owns a workspace with the same name, which is almost always a repeated request rather than a genuine second workspace.
| Argument | Type | Description |
|---|---|---|
| name* | string | Human-readable workspace name, shown verbatim in the workspace switcher. |
{
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "Human-readable workspace name, shown verbatim in the workspace switcher."
}
}
}Ripple-delete a time window [startFrame, endFrame) — remove that span and pull all later content earlier by delta = endFrame - startFrame (the NLE 'ripple delete' / 'close gap'). Shifts every keyframe, colour keyframe, marker, audio overlay, loop region, and start_at through the cut (a speed ramp is anchored to its clip and rides along unchanged), and is SOURCE-AWARE for video layers: a clip that straddles the cut is trimmed, and one whose interior is removed is SPLIT into two layers. Audio overlays interior to the cut are truncated at the seam (overlays have no source-in to bridge the gap). REFUSES to cut across a video layer that carries speed-ramp keyframes — remove them, or cut outside that layer's span, first. The composition length shrinks accordingly (an authored length loses only the overlap with its visible region). Frames are 0-indexed project-timeline frames at 30 fps; endFrame is exclusive and clamped to the composition length.
| Argument | Type | Description |
|---|---|---|
| endFrame* | number | End frame of the window to remove (0-indexed, EXCLUSIVE). Must be > startFrame; clamped to the composition length. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| startFrame* | number | First frame of the window to remove (0-indexed, inclusive). |
{
"type": "object",
"required": [
"startFrame",
"endFrame",
"projectId"
],
"properties": {
"endFrame": {
"type": "number",
"description": "End frame of the window to remove (0-indexed, EXCLUSIVE). Must be > startFrame; clamped to the composition length."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"startFrame": {
"type": "number",
"description": "First frame of the window to remove (0-indexed, inclusive)."
}
}
}Remove the page at `index`. Fails on an out-of-range index or when only one page remains — a project must keep at least one page. The active page stays active; when the active page itself is deleted, active_index falls to the neighbouring page (the one that slid into its position, or the new last page).
| Argument | Type | Description |
|---|---|---|
| index* | number | 0-based index of the page to remove. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"index",
"projectId"
],
"properties": {
"index": {
"type": "number",
"description": "0-based index of the page to remove."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Move a project to Recently deleted. It leaves every project list at once and can be restored for 30 days, with restore_project or from the person's projects page; after that it is deleted for good, with its saved versions, uploaded images and clips. Returns { name, purgeAt, restorable: true }, where purgeAt is when it will be deleted for good (epoch ms). Deleting for good before then is the person's own step on their projects page; no tool does it. Requires edit access — the owner, a workspace editor/admin/owner, or a direct-share editor; anyone else gets the opaque not-found. Deleting the last remaining project is allowed.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Project to delete. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Project to delete."
}
}
}Permanently delete one saved version (bookmark or auto-snapshot). Accepts the version's UUID `id` or the `v<N>` shorthand. The bookmark v<N> sequence keeps gaps — numbers never re-shuffle — so any external snippet referencing the deleted `v<N>` will fail to resolve afterwards. Idempotent: deleting a non-existent version is a no-op.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Project whose version to delete. |
| versionId* | string | Version to delete. UUID `id` or `v<N>` shorthand. |
{
"type": "object",
"required": [
"projectId",
"versionId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Project whose version to delete."
},
"versionId": {
"type": "string",
"description": "Version to delete. UUID `id` or `v<N>` shorthand."
}
}
}Structural OVERVIEW of the composition (the table of contents) — canvas size, duration, platform_safe_area (the part of the canvas TikTok / Reels UI and Instagram's feed crop leave visible: keep text, captions, logos and buttons inside it; null when nothing is covered or cropped), the backdrop summary, and a z-ordered tree (top of stack first) of every layer with its elementId, type, name, type label (filename/clip/text/kind), geometry (x/y/width/height; a hug text layer's is its painted box, measured from the font, with metrics saying so), fits for every text layer ({canvas, safe_area} at its static position), and which properties are animated. Does NOT include keyframe values or styles — those are unbounded. On a multi-page project the tree describes the ACTIVE page and the data carries a `pages` block ({ page_count, active_index, pages: [{ index, name, has_video }] }) — content tools target that active page; use select_page to switch which page they target, add_page / delete_page / reorder_pages to manage the pages. Start here, then call inspect_layers([elementId, …]) for full detail on the specific layers you'll change. Don't guess keyframe/style values from this overview.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Return the OCR text regions detected in a clip's video frames OR a still image asset. Pass either `clip` (a video.<id>.clip filename) or `image` (an image layer's filename) — not both. Video clips are sampled every 0.3s at upload time; images are OCR'd once. Results are cached in R2 next to the source. Returns { status: 'ready' | 'not-ready', frames: [{ frame, time, words: [{ text, x0, y0, x1, y1, confidence }] }], videoWidth, videoHeight }. For an image there's a single frame (frame 0); videoWidth/videoHeight are the image's pixel dimensions. Each `words` entry is one detected text line — `text` may hold several words, the box is axis-aligned, and `confidence` is 0–100. Coordinates are in the SOURCE pixel space (not canvas space). Use to know where titles / subtitles / lower-thirds are baked into the video or image so the Morpha title + intro graphics can be positioned to not collide.
| Argument | Type | Description |
|---|---|---|
| clip | string | Video clip filename (the same value as `video.<id>.clip`). Pass this OR `image`. |
| image | string | Image layer filename (the same value as `image.<id>.filename`). Pass this OR `clip`. |
| projectId* | string | Project the clip/image belongs to. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"clip": {
"type": "string",
"description": "Video clip filename (the same value as `video.<id>.clip`). Pass this OR `image`."
},
"image": {
"type": "string",
"description": "Image layer filename (the same value as `image.<id>.filename`). Pass this OR `clip`."
},
"projectId": {
"type": "string",
"description": "Project the clip/image belongs to."
}
}
}Composition primitive: clone a leaf (image.<id> / video.<id> / shapes.<id> / text.<id>) `count` times, applying a cumulative per-step transform — copy i is offset by i·(dx,dy) px, rotated by i·d_rotation°, and scaled by d_scale^i. One call instead of dozens: a circle of stars (dx/dy + d_rotation), a row of chevrons (dx), a fractal (d_scale<1 + d_rotation), a staggered grid. Styles are copied; animate the result afterwards (e.g. group + a cycle-loop track for endless marching).
| Argument | Type | Description |
|---|---|---|
| dx | number | Per-step x offset in px. |
| dy | number | Per-step y offset in px. |
| count* | number | Number of copies to create. |
| d_scale | number | Per-step size multiplier (1 = no change). |
| elementId* | string | Leaf to clone (e.g. shapes.chevron-1). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| d_rotation | number | Per-step rotation in degrees. |
{
"type": "object",
"required": [
"elementId",
"count",
"projectId"
],
"properties": {
"dx": {
"type": "number",
"description": "Per-step x offset in px."
},
"dy": {
"type": "number",
"description": "Per-step y offset in px."
},
"count": {
"type": "number",
"description": "Number of copies to create."
},
"d_scale": {
"type": "number",
"description": "Per-step size multiplier (1 = no change)."
},
"elementId": {
"type": "string",
"description": "Leaf to clone (e.g. shapes.chevron-1)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"d_rotation": {
"type": "number",
"description": "Per-step rotation in degrees."
}
}
}Duplicate an existing project into a brand-new one — a full copy of its layers, animations, styles, uploaded assets, and clips. Pass the SOURCE project's `projectId`; the copy gets a fresh id automatically (ids are opaque; people see the name). Defaults the copy's name to "<source name> copy" unless you pass `name`. WORKSPACE: the copy FOLLOWS THE SOURCE by default — a project in a workspace is copied into that SAME workspace, and a personal project stays personal. Pass `workspaceId` to override: a workspace id (from list_workspaces) to place the copy in a specific workspace (you need an edit-capable role there), or null to force the copy into your personal space. If the source lives in a workspace you can only VIEW, the call FAILS rather than silently making a personal copy — get an editor role there, or pass workspaceId:null. (Duplicating a project shared with you from a workspace you're NOT a member of lands in personal, since you can't be placed in that workspace.) The returned `workspaceId` tells you where it went. The user's editor won't auto-refresh — they reload to see the copy in the picker. Versions or options of one video (styles to pick from, hooks, languages, a sheet's rows) are pages of ONE project (add_page, duplicate_index), never separate projects: the preview turns pages and exports one video per page.
| Argument | Type | Description |
|---|---|---|
| name | string | Optional name for the copy. Shown verbatim in the picker; defaults to "<source name> copy". |
| projectId* | string | Id of the existing project to duplicate (the source). |
| workspaceId | string | null | Optional override for where the copy lives. Omit to FOLLOW THE SOURCE (a workspace project stays in its workspace; a personal one stays personal). A workspace id (from list_workspaces) places the copy there — you need an edit-capable role. null forces the copy into your personal space. |
Embed another of the user's projects ("a morpha") inside this one as a version-pinned band. The source's layers are inlined into the host as a collapsible group, re-keyed to fresh ids, pinned to one immutable version of the source. Pass the source project's id as source_morpha_id (and optionally a version label); the server resolves and inlines the pinned version. Editing the band's inner layers only affects THIS video — the change is local and never propagates back to the source. To pin the band to a different saved version, re-pin it from the editor's Inspector. describe_video marks an embedded band with morpha:true + source_morpha_id so you can tell it apart from a plain group.
| Argument | Type | Description |
|---|---|---|
| version | string | Optional version label of the source to pin (e.g. "v3"). Omit to pin the source's latest saved version. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| source_morpha_id* | string | The id of the project to embed. Must not be this project. |
{
"type": "object",
"required": [
"source_morpha_id",
"projectId"
],
"properties": {
"version": {
"type": "string",
"description": "Optional version label of the source to pin (e.g. \"v3\"). Omit to pin the source's latest saved version."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"source_morpha_id": {
"type": "string",
"description": "The id of the project to embed. Must not be this project."
}
}
}Fade a layer's opacity between two frames in one call.
| Argument | Type | Description |
|---|---|---|
| toFrame* | number | |
| elementId* | string | |
| fromFrame* | number | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| toOpacity* | number | 0..1. |
| fromOpacity* | number | 0..1. |
{
"type": "object",
"required": [
"elementId",
"fromFrame",
"toFrame",
"fromOpacity",
"toOpacity",
"projectId"
],
"properties": {
"toFrame": {
"type": "number"
},
"elementId": {
"type": "string"
},
"fromFrame": {
"type": "number"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"toOpacity": {
"type": "number",
"description": "0..1."
},
"fromOpacity": {
"type": "number",
"description": "0..1."
}
}
}Clear an authored composition length and return to AUTO-FIT — the comp length tracks the furthest content (keyframe / video window / audio end) again, with a 1-second floor. The inverse of set_duration. NOTE: headless (no loaded media) this can UNDER-fit when a video layer's source_out_frame is null — its natural length is unmeasurable, so it contributes only its start frame; the length self-corrects the next time the project is opened in the editor, where the real clip durations are known.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Freeze the picture at a frame: the clip is CUT there and a still of that frame is inserted between the halves, pushing everything after it later (the NLE 'frame hold'). This is how you hold a moment — play, freeze, continue. The still is an ordinary IMAGE layer showing that frame for `holdFrames` (default 150 = 5s at 30fps), so you resize, split, move or delete it like any other layer. `image` is the filename of a PNG of that frame, already uploaded to the project's assets — rendering one needs a browser, so capture and upload it first. The reply's `frozenSourceFrame` is the SOURCE frame that was frozen, which differs from `frame` on a retimed clip. `frame` must be strictly inside the clip.
| Argument | Type | Description |
|---|---|---|
| frame* | number | Project-timeline frame to freeze at — must be strictly inside the clip. |
| image* | string | Filename of an already-uploaded PNG of the frozen frame, in the project's assets. |
| elementId* | string | video.<id> to freeze. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| holdFrames | number | How long the still holds, in frames. Defaults to 150 (5s at 30fps), the same default every added clip gets. |
Wrap sibling elements in a new group, which the Layers panel shows as one named row the person can select, move and animate as one thing (a butterfly assembled from wings + body + antennae, an icon built from primitives, a card + its title + badge, a lower-third). `name` is that row's label. The group composes its x/y/scale/rotation/opacity onto its descendants (so one track flies/spins/fades the whole thing) and pivots rotate/scale at its (pivotX, pivotY), seeded to the centroid of its children at create time. The group's x/y track values are translation offsets applied around the pivot — groups have no static body of their own. All listed elementIds must currently share the same parent (root, or one existing group).
| Argument | Type | Description |
|---|---|---|
| name | string | Optional human-readable label. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| elementIds* | array | Element ids of the elements to wrap. Must all share the same parent. |
{
"type": "object",
"required": [
"elementIds",
"projectId"
],
"properties": {
"name": {
"type": "string",
"description": "Optional human-readable label."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"elementIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Element ids of the elements to wrap. Must all share the same parent."
}
}
}Full per-element drill-in — the 'open this layer' half of the browser. Returns each named element's COMPLETE record: all of its own fields plus its animation tracks (every keyframe), colour/fill tracks, track-loop (extrapolation) modes, and style. Pass the elementIds you read from describe_video; pull detail only for the handful of layers you're about to mutate, not the whole project.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| elementIds* | array | Element ids to inspect (image.<id>, video.<id>, text.<id>, shapes.<id>, group.<id>). Read them from describe_video's tree. |
{
"type": "object",
"required": [
"elementIds",
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"elementIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Element ids to inspect (image.<id>, video.<id>, text.<id>, shapes.<id>, group.<id>). Read them from describe_video's tree."
}
}
}List the user's reusable Collection — layers they added with add_to_collection (lower-thirds, logo stings, brand intros, or any single layer). Returns { items: [{ scope, workspaceId, sourceProjectId, sourceProjectName, ownerEmail, elementId, name, kind, childCount }] }. `scope` is "personal" (from the user's own solo projects) or "team" (from a workspace they belong to). Show the user each item's `name` (and its `sourceProjectName`); `sourceProjectId` + `elementId` feed add_from_collection to drop a self-contained COPY into another project. Works on a solo account (personal items always included); takes no arguments.
No arguments.
{
"type": "object",
"required": [],
"properties": {}
}List available font families across every source the editor knows about (Google + Bunny + Fontshare + Fontsource + Velvetyne) PLUS the project's user-uploaded custom_fonts (surfaced as source: "custom"). Use this to discover families before set_layer_text / add_text_layer when you don't know what to pick. Returns { fonts: [{family, source, weights, italics}], total, returned, sources }. Filter via `q` (case-insensitive substring on family) and/or `source`; cap with `limit` (default 50, max 1000). Picking any returned family in font_family Just Works — the editor's loader dispatches to the right CSS/FontFace endpoint by source.
| Argument | Type | Description |
|---|---|---|
| q | string | Case-insensitive substring filter on family name. Omit to list everything. |
| limit | number | Max entries to return (default 50, max 1000). |
| source | string | Restrict to one source. Omit to span every source. "custom" returns only the project's uploaded faces. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"q": {
"type": "string",
"description": "Case-insensitive substring filter on family name. Omit to list everything."
},
"limit": {
"type": "number",
"description": "Max entries to return (default 50, max 1000)."
},
"source": {
"enum": [
"google",
"bunny",
"fontshare",
"fontsource",
"velvetyne",
"custom"
],
"type": "string",
"description": "Restrict to one source. Omit to span every source. \"custom\" returns only the project's uploaded faces."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}List projects as { id, name, editorUrl } entries. The id is what every other tool's `projectId` argument takes. `name` is the human-readable label shown in the editor's project picker ("Untitled" when no name has been set; ids are opaque and the editor never shows them). `editorUrl` is a link that opens that project in the editor. By default lists the user's OWN personal projects (those not in any workspace). Pass `workspaceId` (from list_workspaces) to instead list the projects that live in that workspace — including teammates' — each with the owner's `ownerEmail`. Call this when the user asks "what projects do I have?" or hasn't named a project but you need to operate on one.
| Argument | Type | Description |
|---|---|---|
| workspaceId | string | Optional workspace id (from list_workspaces). When given, lists that workspace's projects instead of the caller's personal ones; requires membership. Omit for personal projects. |
{
"type": "object",
"required": [],
"properties": {
"workspaceId": {
"type": "string",
"description": "Optional workspace id (from list_workspaces). When given, lists that workspace's projects instead of the caller's personal ones; requires membership. Omit for personal projects."
}
}
}List the projects in Recently deleted as { id, name, trashedAt, purgeAt, deletedBy, canEdit } entries. Each can be restored with restore_project until purgeAt (epoch ms), 30 days after it was deleted. `deletedBy` is { kind: "you" | "person" | "agent", label }: the caller, a teammate (label is their email), or a connected assistant (label is its name). `canEdit` says whether the caller may restore it. By default lists the caller's own personal projects; pass `workspaceId` (from list_workspaces) for that workspace's.
| Argument | Type | Description |
|---|---|---|
| workspaceId | string | Optional workspace id (from list_workspaces). When given, lists that workspace's deleted projects instead of the caller's personal ones; requires membership. |
{
"type": "object",
"required": [],
"properties": {
"workspaceId": {
"type": "string",
"description": "Optional workspace id (from list_workspaces). When given, lists that workspace's deleted projects instead of the caller's personal ones; requires membership."
}
}
}List every saved version of a project, newest-first. Returns summaries (id, name, timestamp, source, kind, version_number, cp) — not the inner project JSON. `kind` is `"bookmark"` (deliberate save, gets a v<N> label, partner-pinnable in the <morpha-video> embed) or `"auto"` (editor auto-snapshot every ~10 mutations, restore-only). `version_number` is bookmark-only and stable for the lifetime of that version — deletes leave gaps, numbers never re-shuffle. `cp` names which pages the version changed (comma-joined 8-char page-id prefixes; `"*"` = page order only; absent = unknown, treat as all pages). Use the returned `id` (or `v<version_number>` shorthand) with restore_version / rename_version / delete_version.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Project whose versions to list. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Project whose versions to list."
}
}
}List the workspaces (shared team spaces) the account belongs to, as { id, name, role, memberCount } entries. `role` is the caller's role in that workspace (owner / admin / editor / viewer) — only owner/admin/editor can add projects. The `id` is the `workspaceId` argument of create_project, move_project_to_workspace and list_projects; `name` is the label people see in the workspace switcher. Returns an empty list when the account has no email (workspace membership is email-based, so a keyless/dev identity sees none).
No arguments.
{
"type": "object",
"required": [],
"properties": {}
}Merge two or more caption lines into one. The earliest line survives with the union window and the time-ordered texts joined by spaces; the others are removed. All lines must share one flavour — every one welded to the SAME clip, or every one standalone — and no other caption line on that track may sit inside the merged span (move or include it first).
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| elementIds* | array | Two or more caption lines, each "text.<id>". |
{
"type": "object",
"required": [
"elementIds",
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"elementIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Two or more caption lines, each \"text.<id>\"."
}
}
}Set a layer's static base transform. Writes x/y/w/h/rotation directly on image.<id>, video.<id>, and shapes.<id>; for group.<id> sets pivotX/pivotY (no width/height/rotation — use add_keyframe for group rotation). Also sets `scale` and `opacity`, which every layer kind carries. Note: when a layer has a keyframe track for a property, the track OVERRIDES the static value at every frame — use add_keyframe to animate, move_layer to set the un-animated default. A scale/opacity write over an existing track is REFUSED for that reason; pass clear_animation:true to replace the animation with the static value.
| Argument | Type | Description |
|---|---|---|
| x | number | Centre x in the element's OWN frame. At root that is canvas coords (1080 wide); INSIDE A GROUP it is the group's space, so it is not where the layer sits on the canvas. inspect_layers reports both — write back the `x` it gave you, and read `canvas_x` for the on-canvas position. |
| y | number | Centre y in the element's OWN frame — canvas coords (1920 tall) at root, the group's space inside a group. See `x`. |
| scale | number | Uniform scale multiplier about the layer's pivot. 1 = natural size. Every layer kind carries it. |
| width | number | Width in px (must be > 0). |
| height | number | Height in px (must be > 0). |
| opacity | number | Layer opacity, 0..1 (values outside are allowed and clamp at paint time). Composes with any ancestor group's opacity. |
Move a project into a workspace, or back to the caller's personal space. Pass `workspaceId` (from list_workspaces) to place the project in that workspace — every member then sees it, with their workspace role deciding edit vs view. Pass `workspaceId: null` to detach it back to personal. Requires an edit-capable role (owner/admin/editor) in the TARGET workspace and write access to the project. Switching a project from one workspace to another cleanly drops the old membership.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Id of the project to move. |
| workspaceId* | string | null | Target workspace id (from list_workspaces), or null to move the project back to the caller's personal space. |
{
"type": "object",
"required": [
"projectId",
"workspaceId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Id of the project to move."
},
"workspaceId": {
"type": [
"string",
"null"
],
"description": "Target workspace id (from list_workspaces), or null to move the project back to the caller's personal space."
}
}
}Get a link that opens a project directly in the editor (the editor loads exactly this project from the URL), where the person can see the work, change it by hand and export it. Returns { name, editorUrl }; `name` is the project's label.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Id of the project to open. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Id of the project to open."
}
}
}Change a project's id LOSSLESSLY — re-keys its JSON, every saved version, and all assets + clips, then deletes the old id. Unlike create_project (clone), version history is preserved. Enforces the id policy: the new id is always a v4 UUID — pass `newId` to choose one, or omit to mint a fresh UUID. A project's `name` (the only user-facing label) is untouched, so the picker/editor are unchanged. The editor won't auto-refresh — reload to see the new id in URLs.
| Argument | Type | Description |
|---|---|---|
| newId | string | Optional new id — must be a v4 UUID. Omit to mint a fresh UUID automatically. |
| projectId* | string | Current id of the project to re-key. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"newId": {
"type": "string",
"description": "Optional new id — must be a v4 UUID. Omit to mint a fresh UUID automatically."
},
"projectId": {
"type": "string",
"description": "Current id of the project to re-key."
}
}
}Delete an audio overlay by id.
| Argument | Type | Description |
|---|---|---|
| id* | string | Overlay id (e.g. audio_1). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"id",
"projectId"
],
"properties": {
"id": {
"type": "string",
"description": "Overlay id (e.g. audio_1)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Remove the colour keyframe at an exact frame on a fill track. No-op when no track or no matching keyframe exists. Removing the last keyframe drops the track entry.
| Argument | Type | Description |
|---|---|---|
| frame* | number | |
| property* | string | |
| elementId* | string | Element id. The canvas backdrop is the pinned is_background image_layer; 'background.canvas' is accepted as a synonym. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"property",
"frame",
"projectId"
],
"properties": {
"frame": {
"type": "number"
},
"property": {
"enum": [
"fill"
],
"type": "string"
},
"elementId": {
"type": "string",
"description": "Element id. The canvas backdrop is the pinned is_background image_layer; 'background.canvas' is accepted as a synonym."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Remove one hostname from the project's embed allowlist. Idempotent — removing an entry that isn't present is a no-op. Removing the last entry turns embedding OFF (the public embed endpoint 404s the project). Morpha sends that website no request; a page on it stops loading the project. Reference: https://morphareels.ai/docs/tools#removeembedoriginorigin
| Argument | Type | Description |
|---|---|---|
| origin* | string | Hostname or URL to remove. Normalized the same way as add_embed_origin before matching. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"origin",
"projectId"
],
"properties": {
"origin": {
"type": "string",
"description": "Hostname or URL to remove. Normalized the same way as add_embed_origin before matching."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Remove a layer from the user's Collection so it's no longer offered for reuse. Pass the element id that was added with add_to_collection. Copies already placed in other projects are unaffected (they're self-contained). No-op if the id isn't in the collection.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | The element id to remove from the Collection. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"elementId": {
"type": "string",
"description": "The element id to remove from the Collection."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Remove the keyframe at frame N on a layer's track. Removing the last keyframe from a track restores the layer's static base value across the timeline.
| Argument | Type | Description |
|---|---|---|
| frame* | number | |
| property* | string | |
| elementId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"property",
"frame",
"projectId"
],
"properties": {
"frame": {
"type": "number"
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Delete a video, image, text, or shape LEAF layer. Errors on a group.<id> (dissolve it with ungroup_layers instead) and on a pinned layer such as the canvas backdrop. Deleting a video layer also takes everything welded to that clip: its welded audio overlay(s) and its welded caption lines, both of which derive their timing from the clip's trim and have no meaning without it.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | video.<id>, image.<id>, text.<id>, or shapes.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"elementId": {
"type": "string",
"description": "video.<id>, image.<id>, text.<id>, or shapes.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Remove the speed-ramp keyframe at `frame` (a PROJECT-timeline frame — the same value add_speed_keyframe and inspect_layers report) on a video layer. Removing the last keyframe clears the speed_keyframes array entirely (restoring 1× playback).
| Argument | Type | Description |
|---|---|---|
| frame* | number | Project-timeline frame number. |
| elementId* | string | video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"frame",
"projectId"
],
"properties": {
"frame": {
"type": "number",
"description": "Project-timeline frame number."
},
"elementId": {
"type": "string",
"description": "video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Rename a group. Pure cosmetic — labels appear in the Inspector and describe_video output.
| Argument | Type | Description |
|---|---|---|
| name* | string | |
| groupId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"groupId",
"name",
"projectId"
],
"properties": {
"name": {
"type": "string"
},
"groupId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set the human-readable name of a video / image / shape / text layer — the label shown in the Inspector, and the basis for the layer's auto-derived <morpha-video> embed attribute (so renaming a layer to "caption" makes the embed attribute `caption`). Pass an empty string to clear the name. For groups use rename_group.
| Argument | Type | Description |
|---|---|---|
| name* | string | New label. Empty string clears it (callers fall back to the filename stem). |
| elementId* | string | video.<id>, image.<id>, shapes.<id>, or text.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"name",
"projectId"
],
"properties": {
"name": {
"type": "string",
"description": "New label. Empty string clears it (callers fall back to the filename stem)."
},
"elementId": {
"type": "string",
"description": "video.<id>, image.<id>, shapes.<id>, or text.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Update a project's human-readable name (shown in the editor's project picker). Pass an empty string to clear the name and fall back to the id. Doesn't touch any layers, animations, or styles — only the picker label.
| Argument | Type | Description |
|---|---|---|
| name* | string | New human-readable label. Empty string clears the name and reverts to the id fallback. |
| projectId* | string | Project to rename. |
{
"type": "object",
"required": [
"projectId",
"name"
],
"properties": {
"name": {
"type": "string",
"description": "New human-readable label. Empty string clears the name and reverts to the id fallback."
},
"projectId": {
"type": "string",
"description": "Project to rename."
}
}
}Rename a saved version's human-readable label (what the user sees in the picker). The `v<N>` partner-facing identifier is unaffected — only the display name changes. Accepts the version's UUID `id` or the `v<N>` shorthand. Empty / whitespace-only names are rejected.
| Argument | Type | Description |
|---|---|---|
| name* | string | New human-readable label. Must be a non-empty string. |
| projectId* | string | Project whose version to rename. |
| versionId* | string | Version to rename. UUID `id` or `v<N>` shorthand. |
{
"type": "object",
"required": [
"projectId",
"versionId",
"name"
],
"properties": {
"name": {
"type": "string",
"description": "New human-readable label. Must be a non-empty string."
},
"projectId": {
"type": "string",
"description": "Project whose version to rename."
},
"versionId": {
"type": "string",
"description": "Version to rename. UUID `id` or `v<N>` shorthand."
}
}
}Rename an existing workspace. Requires an owner or admin role in it (the same roles that may change its membership); an editor or viewer is refused. Takes the workspace id from list_workspaces. Touches only the label — membership, projects and roles are unchanged.
| Argument | Type | Description |
|---|---|---|
| name* | string | New human-readable name. |
| workspaceId* | string | Id of the workspace to rename (from list_workspaces). |
{
"type": "object",
"required": [
"workspaceId",
"name"
],
"properties": {
"name": {
"type": "string",
"description": "New human-readable name."
},
"workspaceId": {
"type": "string",
"description": "Id of the workspace to rename (from list_workspaces)."
}
}
}Check a server render started by render_video, and get its download link. Returns { ok: true, status: 'not-ready' } while it is still rendering (wait and call again), or { ok: true, status: 'ready', data: { downloadUrl, editorUrl, expiresAt, videoSeconds, scale } } once the MP4 is ready. `downloadUrl` is a plain https link needing no Morpha account — give it to the person; it stops working when the render is deleted at `expiresAt` (7 days). A render that failed answers ok:false with a plain reason and uses no allowance. Only the account that started a render can read it.
| Argument | Type | Description |
|---|---|---|
| renderId* | string | The renderId render_video returned. |
{
"type": "object",
"required": [
"renderId"
],
"properties": {
"renderId": {
"type": "string",
"description": "The renderId render_video returned."
}
}
}Render the project to an MP4 on Morpha's servers, for an agent that has no browser to drive (a chat app, a Linux box). Returns { renderId, status: 'rendering', next } — call render_status with that renderId after about 30 seconds. It needs a subscription and draws on the account's render allowance, so a longer composition and a larger scale each use more of it. The editor renders the same MP4 with no allowance: every project result carries an editorUrl, and there the person can choose Share, then Download (Render MP4 on a phone), free and with no watermark. Renders at 2x the canvas by default, the same as the editor; pass scale: 1 for the canvas's own size, which is faster and uses less allowance. The allowance is checked before the render starts, so a render that would exceed it is refused up front rather than failing partway. Refused for a composition longer than 30 minutes, when the remaining allowance will not cover it, or while 3 renders are already running for this account. The finished file stays downloadable for 7 days, then it is deleted; the project stays and can be rendered again. The render downloads the project's fonts from their hosts, including a custom font's own URL (set_custom_font). Reference: https://morphareels.ai/docs/tools#rendervideopage-scale
| Argument | Type | Description |
|---|---|---|
| page | number | Optional 0-based page index for a multi-page project. Omit for the project's active page; render one page per call. |
| scale | number | Optional output scale: 2 (the default) renders at twice the canvas, roughly 4K for a portrait project; 1 renders at the canvas's own size for a faster file that uses less allowance. Nothing else is accepted. |
| projectId* | string | Project to render. |
Move a layer within its parent's siblings. newIndex is 0-based among siblings (root list when ungrouped, or the parent group's children when nested). 0 = bottom of that subtree; last = top.
| Argument | Type | Description |
|---|---|---|
| newIndex* | number | 0-based index among siblings of the same parent. |
| elementId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"newIndex",
"projectId"
],
"properties": {
"newIndex": {
"type": "number",
"description": "0-based index among siblings of the same parent."
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Move a page from `from_index` to `to_index`. The remaining pages shift to fill the gap; active_index is rewritten so it keeps pointing at the same page it did before the move. Fails on out-of-range indices.
| Argument | Type | Description |
|---|---|---|
| to_index* | number | 0-based destination index for the page. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| from_index* | number | 0-based index of the page to move. |
{
"type": "object",
"required": [
"from_index",
"to_index",
"projectId"
],
"properties": {
"to_index": {
"type": "number",
"description": "0-based destination index for the page."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"from_index": {
"type": "number",
"description": "0-based index of the page to move."
}
}
}Bring a project back from Recently deleted, with its versions, files, workspace and sharing as they were. Returns { projectId, name }. Scheduled social posts cancelled by the delete stay cancelled. Requires edit access, as delete_project does; take the id from list_trash.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Project to restore (an id from list_trash). |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Project to restore (an id from list_trash)."
}
}
}Load a saved version over the live project. Accepts either the version's UUID `id` or the `v<N>` shorthand (e.g. `"v6"` finds the bookmark with version_number=6); auto-snapshots are addressable by UUID only. Without `page_index` this replaces the ENTIRE project (every page) — an auto checkpoint named `before restore: …` is saved first whenever the current state has unsaved changes, so the overwritten state stays recoverable. With `page_index` it restores ONLY that page: the page is matched across versions by its stable id and replaced verbatim (canvas size and name included); other pages are untouched and the write is compare-and-swapped against concurrent edits. Errors if the version predates that page — restore the whole version instead. A restored page may reference assets deleted since the snapshot; those render as missing. An open editor reconciles the result into the live project within a few seconds — no refresh needed.
| Argument | Type | Description |
|---|---|---|
| projectId* | string | Project to restore into. |
| versionId* | string | Version to restore. UUID `id` or `v<N>` shorthand (partner-facing bookmark number). |
| page_index | number | Optional. 0-based index into the project's CURRENT pages (same indexing as select_page / delete_page). Restores only that page from the version; omit to restore the whole project. |
{
"type": "object",
"required": [
"projectId",
"versionId"
],
"properties": {
"projectId": {
"type": "string",
"description": "Project to restore into."
},
"versionId": {
"type": "string",
"description": "Version to restore. UUID `id` or `v<N>` shorthand (partner-facing bookmark number)."
},
"page_index": {
"type": "number",
"description": "Optional. 0-based index into the project's CURRENT pages (same indexing as select_page / delete_page). Restores only that page from the version; omit to restore the whole project."
}
}
}Return horizontal 'safe band' y-ranges of the canvas where a clip's burned-in video text (subtitles, lower thirds, titles) does NOT appear, plus the inverse 'text bands' (where it does). Reads the OCR cache built at clip-upload time (see detect_text_regions). Coordinates returned in BOTH source pixels and CANVAS pixels — canvas coords already account for the video layer's fit/anchor/position. Use to lay out captions, titles, callouts so they never collide with the video's own text. This is about the clip's own burned-in text: where TikTok and Instagram Reels cover the canvas with their UI is describe_video's platform_safe_area. Returns { ok: true, status: 'ready' | 'not-ready', data: { bands, safeBands, textBands } }.
| Argument | Type | Description |
|---|---|---|
| clip* | string | Clip filename (video.<id>.clip). |
| projectId* | string | |
| bandHeight | number | Vertical granularity in source pixels (default 32). |
| minConfidence | number | OCR confidence floor (0–100) for considering a text region (default 70). |
| occupancyThreshold | number | Fraction of frames that must contain text in a band to mark it occupied (default 0.10). |
{
"type": "object",
"required": [
"projectId",
"clip"
],
"properties": {
"clip": {
"type": "string",
"description": "Clip filename (video.<id>.clip)."
},
"projectId": {
"type": "string"
},
"bandHeight": {
"type": "number",
"description": "Vertical granularity in source pixels (default 32)."
},
"minConfidence": {
"type": "number",
"description": "OCR confidence floor (0–100) for considering a text region (default 70)."
},
"occupancyThreshold": {
"type": "number",
"description": "Fraction of frames that must contain text in a band to mark it occupied (default 0.10)."
}
}
}Save a version of the current project state. The person flicks between versions in the editor to compare or roll back, so one version per logical change-set (not one per tool call) is what they can revisit. `name` is shown verbatim in the version picker, so a short description reads best (e.g. "add slide-in animation").
| Argument | Type | Description |
|---|---|---|
| name | string | Short label describing what this set of changes accomplished. Defaults to a timestamp if omitted, but a description is much more useful to the user. |
| projectId* | string | Project to save a version of (filename stem). |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"name": {
"type": "string",
"description": "Short label describing what this set of changes accomplished. Defaults to a timestamp if omitted, but a description is much more useful to the user."
},
"projectId": {
"type": "string",
"description": "Project to save a version of (filename stem)."
}
}
}Switch which page is ACTIVE — the page the content tools target. Subsequent describe_video / inspect_layers / all content tools read and write this page until the active page changes again. Pages are addressed by 0-based index from describe_video's pages block. Selecting the already-active page is a harmless no-op; fails on an out-of-range index.
| Argument | Type | Description |
|---|---|---|
| index* | number | 0-based index of the page to make active. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"index",
"projectId"
],
"properties": {
"index": {
"type": "number",
"description": "0-based index of the page to make active."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Resize the ACTIVE page's canvas to width × height pixels. The composition is scaled UNIFORMLY to fit the new frame (a single factor s = min(newW/oldW, newH/oldH), so nothing distorts — a circle stays a circle) and then re-centred so the old composition centre maps to the new canvas centre. Every layer's position, size, group pivots, and x/y/width/height keyframes follow this fit+recentre; same-aspect resizes scale exactly, aspect changes letterbox the content centred. Each page owns its size, so this leaves sibling pages untouched — select_page then set_canvas_size again to resize another one. Common sizes: 1080×1920 (9:16 Reels/TikTok/Shorts), 1080×1350 (4:5 Instagram), 1080×1080 (1:1 square), 1920×1080 (16:9 YouTube).
| Argument | Type | Description |
|---|---|---|
| width* | number | Canvas width in px (positive integer). |
| height* | number | Canvas height in px (positive integer). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"width",
"height",
"projectId"
],
"properties": {
"width": {
"type": "number",
"description": "Canvas width in px (positive integer)."
},
"height": {
"type": "number",
"description": "Canvas height in px (positive integer)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Play a clip slower or faster at a CONSTANT rate — the normal way to retime a clip. 1 = source speed, 0.5 = half speed, 2 = double speed; range [0.1, 8]. The trim is unchanged, so the clip's length on the timeline changes to suit: at 0.5x it occupies twice as many frames, at 2x half as many. Audio is time-stretched with pitch preserved. Use add_speed_keyframe instead only when the rate must CHANGE over the clip (a ramp).
| Argument | Type | Description |
|---|---|---|
| speed* | number | Constant playback rate (1 = source speed, 0.5 = half, 2 = double), in [0.1, 8]. |
| elementId* | string | video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"speed",
"projectId"
],
"properties": {
"speed": {
"type": "number",
"description": "Constant playback rate (1 = source speed, 0.5 = half, 2 = double), in [0.1, 8]."
},
"elementId": {
"type": "string",
"description": "video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Register a typeface Morpha does NOT ship, so text layers can use it by family name via font_family (exactly like a built-in family). Families already in the built-in catalogs (anything list_fonts returns from google/bunny/fontshare/fontsource/velvetyne) are REJECTED — they need no registration; just set font_family to them directly. `src` is EITHER a full URL (https://…) OR a font file (.woff2/.woff/.ttf/.otf) already uploaded to the project in the editor: pass the filename the upload returned. Like add_image_layer, this does NOT verify an uploaded filename exists. Dedupes by family+weight+style, replacing a matching face. After registering, set a text layer's font_family to this family (add_text_layer / set_layer_text). NOTE: a pasted URL only loads if that host sends permissive CORS headers — uploading the font (served same-origin) is the robust path. A URL is not fetched when this tool runs: Morpha's renderer and every browser that opens the project download it later. Reference: https://morphareels.ai/docs/tools#setcustomfontfamily-src-weight-style
| Argument | Type | Description |
|---|---|---|
| src* | string | A full font URL (https://…/font.woff2) OR an uploaded asset filename in the project's bucket. |
| style | string | Optional face style. Defaults to normal. |
| family* | string | Family name text layers will reference via font_family, e.g. "Mylius Modern". |
| weight | number | Optional specific weight (1-1000) this src provides. Omit for the 400/normal baseline. |
| projectId |
Author an EXPLICIT composition length in seconds, pinning it (duration_authored=true) so the auto-fit no longer drives it. Morpha normally DERIVES the comp length from content (the furthest keyframe / video window / audio end); this overrides that with a fixed length — the stage becomes a fixed canvas you author into, and content past the end is kept but not played or exported. 1-second floor, no ceiling. Use it to shorten a comp to a target length (e.g. a 15-second cut) or to reserve a longer stage than the current content fills. Call fit_duration_to_content to release the pin.
| Argument | Type | Description |
|---|---|---|
| seconds* | number | Composition length in seconds (1-second floor, no ceiling). 30 fps; durationInFrames = ceil(seconds*30). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"seconds",
"projectId"
],
"properties": {
"seconds": {
"type": "number",
"description": "Composition length in seconds (1-second floor, no ceiling). 30 fps; durationInFrames = ceil(seconds*30)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Replace the project's embed allowlist — the hostnames permitted to load this project through the public <morpha-video> embed. Pass the full desired list; it overwrites the previous one. An empty array turns embedding OFF (the public embed endpoint 404s the project). Each entry is normalized to a bare lowercased hostname (scheme, port, and path stripped, e.g. "https://example.com/x" → "example.com"); duplicates are dropped. Morpha sends the listed websites no request: the list decides which of them may load the project, so any page on an allowed host can show it. The embed serves only saved bookmarks (save_version), never unsaved edits, and leaves out who the project is shared with. Reference: https://morphareels.ai/docs/tools#setembedoriginsorigins
| Argument | Type | Description |
|---|---|---|
| origins* | array | Full desired allowlist. Each entry may be a bare hostname or a URL; it is normalized to a bare lowercased hostname. Empty array disables embedding. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"origins",
"projectId"
],
"properties": {
"origins": {
"type": "array",
"items": {
"type": "string"
},
"description": "Full desired allowlist. Each entry may be a bare hostname or a URL; it is normalized to a bare lowercased hostname. Empty array disables embedding."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Place an embedded morpha band on the host timeline: set its TIME ORIGIN (the frame it starts). The band's whole inner reel plays relative to this frame, so its intro animations fire when the band appears instead of at 0:00 (the fix for 'the embedded intro doesn't animate'). Keeps the band's current window length; if it had none, the band spans from start to the composition end. Pass the band group's id (from describe_video — a group with morpha:true).
| Argument | Type | Description |
|---|---|---|
| start* | number | Host-timeline frame where the band begins (0-indexed, 30 fps). |
| bandId* | string | The embedded band's group id (group.<id> or the bare <id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"bandId",
"start",
"projectId"
],
"properties": {
"start": {
"type": "number",
"description": "Host-timeline frame where the band begins (0-indexed, 30 fps)."
},
"bandId": {
"type": "string",
"description": "The embedded band's group id (group.<id> or the bare <id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set a group's backdrop rect size. The rect is centred on (pivotX, pivotY) in group-local space and transforms with the group. Either dimension at 0 hides the backdrop entirely.
| Argument | Type | Description |
|---|---|---|
| box_width* | number | Backdrop width in px (non-negative). |
| elementId* | string | group.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| box_height* | number | Backdrop height in px (non-negative). |
{
"type": "object",
"required": [
"elementId",
"box_width",
"box_height",
"projectId"
],
"properties": {
"box_width": {
"type": "number",
"description": "Backdrop width in px (non-negative)."
},
"elementId": {
"type": "string",
"description": "group.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"box_height": {
"type": "number",
"description": "Backdrop height in px (non-negative)."
}
}
}Move an element into a group (or out to root). Refuses to place a group inside its own descendants.
| Argument | Type | Description |
|---|---|---|
| index | number | 0-based insert position among the new parent's children. Defaults to end. |
| elementId* | string | Full element id: video.<id>, image.<id>, shapes.<id>, or group.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| parentGroupId* | string | null | Bare group id of the new parent, or null to move to root. |
{
"type": "object",
"required": [
"elementId",
"parentGroupId",
"projectId"
],
"properties": {
"index": {
"type": "number",
"description": "0-based insert position among the new parent's children. Defaults to end."
},
"elementId": {
"type": "string",
"description": "Full element id: video.<id>, image.<id>, shapes.<id>, or group.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"parentGroupId": {
"type": [
"string",
"null"
],
"description": "Bare group id of the new parent, or null to move to root."
}
}
}TRIM a group's own visible window — the [start, start+duration) range over which the group and its subtree are drawn. The contents are NOT moved or deleted: this clips what is shown, so use it to hide the head or tail of a whole section. Writing a window overrides the group's derived contents-hull from then on. Two safety corrections apply automatically: the group's OWN keyframes are compensated for the change in start so its animation doesn't jump (reported as keyframesCompensatedBy), and the duration is grown if needed so the window can never hide one of the group's own authored keyframes (reported as grownToCoverKeyframes). To MOVE the group and its contents instead, use shift_group.
| Argument | Type | Description |
|---|---|---|
| start* | number | First frame the group is drawn on, in its parent timeline (0-indexed, 30 fps). |
| duration* | number | How many frames the group stays drawn for (≥ 1). Grown automatically if it would hide the group's own keyframes. |
| elementId* | string | The group's id (group.<id> or the bare <id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"start",
"duration",
"projectId"
],
"properties": {
"start": {
"type": "number",
"description": "First frame the group is drawn on, in its parent timeline (0-indexed, 30 fps)."
},
"duration": {
"type": "number",
"description": "How many frames the group stays drawn for (≥ 1). Grown automatically if it would hide the group's own keyframes."
},
"elementId": {
"type": "string",
"description": "The group's id (group.<id> or the bare <id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Repoint an existing image layer at a different uploaded asset — keeps the layer's id, position, size, animations, and styles; only the bitmap changes. The image must already be uploaded (upload_image for a web URL or a file attached in the chat; the editor for a file from the person's disk); pass the `filename` the upload returned, and its `name` as the label. Use this to swap a layer's image WITHOUT losing its keyframes — `remove_layer` + `add_image_layer` would mint a new id and drop the animations.
| Argument | Type | Description |
|---|---|---|
| name | string | null | Optional label people see for the layer: pass the upload's returned `name`. Omit to keep the current label; null or an empty string clears it. |
| filename* | string | The uploaded file's id: the `filename` its upload returned. |
| elementId* | string | Image layer id, image.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"filename",
"projectId"
],
"properties": {
"name": {
"type": [
"string",
"null"
],
"description": "Optional label people see for the layer: pass the upload's returned `name`. Omit to keep the current label; null or an empty string clears it."
},
"filename": {
"type": "string",
"description": "The uploaded file's id: the `filename` its upload returned."
},
"elementId": {
"type": "string",
"description": "Image layer id, image.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Add or overwrite MANY keyframes across MANY layers in ONE call — functionally equivalent to N add_keyframe calls but with one HTTP/MCP round-trip. Pass an array of entries; each entry has the same fields as add_keyframe. Validated atomically: any invalid entry rejects the whole batch. Use this whenever you'd call add_keyframe more than a couple of times (rippling grids, twinkling starfields, staggered text reveals).
| Argument | Type | Description |
|---|---|---|
| keyframes* | array | One or more keyframe entries to apply. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"keyframes",
"projectId"
],
"properties": {
"keyframes": {
"type": "array",
"items": {
"type": "object",
"required": [
"elementId",
"property",
"frame",
"value"
],
"properties": {
"frame": {
"type": "number",
"description": "Frame number, 0-indexed (30 fps)."
},
"value": {
"type": "number",
"description": "Track value at this frame."
},
"easing": {
"enum": [
"linear",
"easeIn",
"easeOut",
"easeInOut",
"outQuart",
"outExpo",
"outBack",
"inBack",
"inOutBack",
"cubicBezier",
"hold"
],
"type": "string",
"description": "Interpolation to the next keyframe. Default linear."
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string",
"description": "Layer id (video.<id>, image.<id>, shapes.<id>, text.<id>, or group.<id>)."
}
}
},
"description": "One or more keyframe entries to apply."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set (or replace) a layer's timeline BLOCK — the [start, start+duration) window it exists for. The layer is drawn ONLY inside that window, and its animation keyframes are sampled RELATIVE to the block start, so moving or trimming the block re-anchors its intro instead of leaving it behind. This is how a layer 'starts' at a point like an iMovie clip rather than being present for the whole composition. Works on any leaf or group. Frames are in the layer's parent timeline (composition frames at root; band-local inside an embedded morpha band). To place a whole embedded reel, use set_embed_start.
| Argument | Type | Description |
|---|---|---|
| start* | number | First frame the layer appears (0-indexed, 30 fps), in its parent timeline. |
| duration* | number | How many frames the layer lasts (≥ 1). Hidden outside [start, start+duration). |
| elementId* | string | Element id of the layer (image.<id>, video.<id>, text.<id>, shapes.<id>, or group.<id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"start",
"duration",
"projectId"
],
"properties": {
"start": {
"type": "number",
"description": "First frame the layer appears (0-indexed, 30 fps), in its parent timeline."
},
"duration": {
"type": "number",
"description": "How many frames the layer lasts (≥ 1). Hidden outside [start, start+duration)."
},
"elementId": {
"type": "string",
"description": "Element id of the layer (image.<id>, video.<id>, text.<id>, shapes.<id>, or group.<id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set a layer's fill. The canvas backdrop is the pinned is_background image_layer (its element id is exposed via describe_video as background.elementId; the literal 'background.canvas' is also accepted as a synonym); null is rejected on the backdrop. Shapes require a Fill (null/missing is rejected). Image / video / text / group layers accept a Fill object (or `#rrggbb` hex) to paint a backdrop, or `null` to clear it — clearing removes the layer's fill colour keyframes too, so an animated backdrop really does go away. Shapes paint their body; image/video paint behind the bitmap; groups paint a rect centred on the pivot sized by (box_width, box_height). REFUSED on a layer whose fill is ANIMATED (it has colour keyframes): the track wins at every frame, so a plain fill write would be invisible. Change it at a frame with add_color_keyframe, or pass clear_animation:true to replace the animation with this fill.
| Argument | Type | Description |
|---|---|---|
| fill* | string | object | null | Either '#rrggbb' (promoted to solid) or a Fill object: {type:"solid",color} / {type:"linear",stops:[{pos:0..1,color}],angle?} / {type:"radial",stops:[{pos:0..1,color}],cx?,cy?,radius?} / {type:"mask",layer_id,color}. A gradient is ONE fill — don't fake it with stacked shapes. null (image/video/text/group only) clears the backdrop, including any fill colour keyframes on it. |
| elementId* | string | shapes.<id> / image.<id> / video.<id> / group.<id>. The pinned is_background image_layer is the canvas backdrop; the literal 'background.canvas' is accepted as a synonym. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
Edit an existing text layer (text.<id>). Patches its text content, font, size, colour, and full type styling — pass only the fields you want to change. Does NOT create layers and does NOT touch image layers; use add_text_layer to make a new one. `font_family` is a Google Fonts family name (e.g. "Anton", "Bebas Neue"). `text_size` is the font size in px (omit to keep the current size). `text_color` is #rrggbb. Styling: font_weight (100-900, e.g. 800 for a black/heavy logo look), font_style (italic), text_transform (uppercase/lowercase), letter_spacing (px, may be negative for tight tracking), line_height (multiplier), text_align, text_autofit ("wrap" default = fixed size + word-wrap, the size you set is what renders; "fit"=auto-size to fill the box, grows and shrinks; "shrink"=legacy shrink-only), text_valign (top/middle/bottom block alignment), an outline via stroke_width + stroke_color, and a text_shadow. To make text MASK another layer (video/image-filled letterforms) use set_matte_source with this layer's id as the matte source. Only `elementId` is required. The result carries text_box, fits and a warning when the text runs past the canvas or the safe area (see add_text_layer).
| Argument | Type | Description |
|---|---|---|
| text | string | The text to render. Newlines are honoured as hard line breaks. |
| curve | number | Curve the text onto an arc, in degrees of total sweep. 0 = straight (default). POSITIVE = a SMILE (⌣, ends rise); NEGATIVE = an ARCH (⌒, rainbow). Clamped ±135. A tasteful smile is ~+60. Applies to a SINGLE line — multi-line text is joined to one line while curved (the stored text is untouched, so curve:0 restores it). |
| elementId* | string | Text layer id, text.<id>. |
Set how a layer ENTERS at the start of its on-timeline window and LEAVES at the end, instead of popping. The transition is EDGE-RELATIVE — only a length and a look are stored — so it rides the edge through every later trim, slide or clip retime. Prefer this over fade_layer / apply_preset whenever the intent is 'enters and leaves nicely': those write opacity keyframes at ABSOLUTE frames, which strand themselves the moment the edge moves, and clutter the timeline lanes. A layer created WITH a `block` is born carrying a short fade at each edge — whether you passed the block or the editor minted one — so check `inspect_layers` before adding one, rather than assuming there is none. Layers created before this default existed, and any edge cleared to "cut", carry nothing and do need setting. A layer with NO block is always-present, has no edges, and a transition on it is inert. Video clips default to a hard cut, because a hard cut between shots is the grammar of short-form video. kind "cut" clears the edge back to a hard cut. The length is a request: when the window is too short to hold both ramps they are squeezed proportionally at render time, and the stored values are left intact.
| Argument | Type | Description |
|---|---|---|
| edge* | string | Which edge to set. "in" is the start of the layer's window, "out" the end. |
| kind* | string | The look. "cut" = hard edge (clears any transition). "fade" = opacity ramp. "slide" = travels in/out from a direction while fading. "pop" = scales up from 80% with an overshoot. |
| curve | string | Optional easing override (linear, easeIn, easeOut, easeInOut, outQuart, outExpo, outBack, inBack, inOutBack). Defaults suit the kind: entries decelerate in, exits accelerate away. |
Show or hide a layer instantly by writing a single opacity keyframe (1 or 0) at frame 0.
| Argument | Type | Description |
|---|---|---|
| visible* | boolean | |
| elementId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"visible",
"projectId"
],
"properties": {
"visible": {
"type": "boolean"
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set the project's loop section: the whole composition repeats once per value, with one field of one layer varying across the repeats. Builds one pass per value, each setting `field` of `elementId` to that value — e.g. a caption text layer cycling through several strings. Pass an empty `values` array to clear the loop (the comp plays once).
| Argument | Type | Description |
|---|---|---|
| field | string | The layer field each pass overrides — e.g. "text", "text_color", "filename". Defaults to "text". |
| values* | array | One value per loop pass. Empty array clears the loop. |
| elementId* | string | Target layer — text.<id>, image.<id>, shapes.<id>, or video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"values",
"projectId"
],
"properties": {
"field": {
"type": "string",
"description": "The layer field each pass overrides — e.g. \"text\", \"text_color\", \"filename\". Defaults to \"text\"."
},
"values": {
"type": "array",
"items": {
"type": "string"
},
"description": "One value per loop pass. Empty array clears the loop."
},
"elementId": {
"type": "string",
"description": "Target layer — text.<id>, image.<id>, shapes.<id>, or video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set (or clear) a track matte — the host shows only where the matte source is opaque. The HOST can be a leaf (image.<id>, video.<id>, shapes.<id>, text.<id>) OR a group.<id> (a group is a layer of sorts): a group host clips ALL its composited children to the source shape's path — e.g. a marching chevron strip + black backing shown only inside an arrow / band shape. For a leaf host the source can be any leaf (use a text.<id> source for video-/image-filled letterforms); for a group host the source must be a shape (shapes.<id>). The source is CONSUMED: it stops painting as a layer of its own, so do NOT hide it or set its opacity to 0 (an invisible stencil empties the mask and the host vanishes). It has no time window of its own: it follows the host's, and attaching drops its block and edge transitions (`source_window_dropped` in the result). Its position, scale, rotation and keyframes shape the mask. Pass null to clear.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | Host being masked: image/video/shapes/text.<id>, or group.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| matte_inverted | boolean | Optional. Invert the mask (knock-out): the host shows everywhere EXCEPT where the source is opaque — a punch-through / spotlight. Honored on leaf hosts; ignored on group hosts. Omitted = preserve current; clearing the mask resets it. |
| matte_source_id* | string | null | Element id of the layer whose alpha drives the mask, or null to clear. |
Set the rotation / scale pivot anchor for an image, video, shape, or text leaf. Picks one of the 9 standard bbox anchors — corners, edge midpoints, or centre — so the layer rotates and scales around that point instead of its centre. The pivot is normalized to the bbox, so resizing the layer keeps the pivot anchored to the same corner / edge / centre. Static (not animated). For groups, use move_layer with x/y to set the group's absolute pivotX/pivotY instead.
| Argument | Type | Description |
|---|---|---|
| anchor* | string | Which of the 9 bbox anchors to pivot around. tl/t/tr = top row; l/c/r = middle row; bl/b/br = bottom row. c = centre (the default). |
| elementId* | string | image.<id>, video.<id>, shapes.<id>, or text.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"anchor",
"projectId"
],
"properties": {
"anchor": {
"enum": [
"tl",
"t",
"tr",
"l",
"c",
"r",
"bl",
"b",
"br"
],
"type": "string",
"description": "Which of the 9 bbox anchors to pivot around. tl/t/tr = top row; l/c/r = middle row; bl/b/br = bottom row. c = centre (the default)."
},
"elementId": {
"type": "string",
"description": "image.<id>, video.<id>, shapes.<id>, or text.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Set style fields on a layer. Only the fields you pass are changed; omit a field to leave it untouched. Covers border/radius/shadow (including borderAlign — inner|center|outer border position) plus image-only fields: fit (stretch|cover|contain), anchorX/anchorY (0..1, where the source anchors when cropping/letterboxing under cover/contain), tintColor (#rrggbb) + tintStrength (0..1) for a colour overlay painted source-atop, and alphaMask (linear gradient — see below) for a multiplicative alpha fade across the layer.
| Argument | Type | Description |
|---|---|---|
| fit | string | Fit mode (image + video layers). Default: stretch for image layers, cover for video layers. |
| anchorX | number | Object-position X (0..1). 0=left, 1=right, 0.5=centre. Only meaningful with fit=cover|contain. |
| anchorY | number | Object-position Y (0..1). 0=top, 1=bottom, 0.5=centre. Only meaningful with fit=cover|contain. |
| alphaMask | object | null | Linear alpha-mask gradient (image layers). Multiplies the layer's alpha along a gradient line — used to fade a layer out partway across (the front half of a 'sandwich' covering text below it). Object: { type: 'linear', angle: number (deg, CSS-style; 0=to top, 90=to right, 180=to bottom, 270=to left), stops: [{offset:0..1, alpha:0..1}, ...] (≥2 stops, ordered by offset) }. Pass null to clear. |
| boxShadow | string | null | CSS box-shadow string, e.g. "0 4px 12px rgba(0,0,0,0.5)". Pass an empty string "" or null to REMOVE the shadow. |
Add or update the rounded background box behind a TEXT layer (text.<id>) in one call — sets the backdrop fill plus the box's padding, corner radius, and optional stroke. Pass only the fields you want to change. New text layers are already text_autofit "hug", so padding alone shrink-wraps the box to the text — ideal for caption / sticker chips; set_layer_text(text_autofit:"hug") is only needed when adding a box to an OLDER layer still on "wrap". THIS IS HOW YOU BUILD A BUTTON: a button / CTA / chip / tag / pill / labelled badge is ONE text layer with a native background, never a rounded-rect shape with a text layer parked on top — padding is what sizes the box around the label, so the two can't drift apart when the text or the scale changes and the user drags one layer instead of two. The one exception: the box is NOT painted on CURVED text (a straight box behind a bent line reads as broken), so an arc-shaped chip genuinely needs a shape behind it. Pass fill null to remove the box. Text layers only; for shapes/images/video use set_layer_fill. Returns text_box and fits for the padded box, with a warning when it runs past the canvas or the safe area.
| Argument | Type | Description |
|---|---|---|
| fill | string | object | null | Box fill: '#rrggbb' (promoted to solid) or a Fill object: {type:"solid",color} / {type:"linear",stops:[{pos:0..1,color}],angle?} / {type:"radial",stops:[{pos:0..1,color}],cx?,cy?,radius?} / {type:"mask",layer_id,color}. null clears the box; omit to leave the current fill. |
| padding | number | Uniform inset (canvas px) between the box edge and the text. On a "hug" layer it is what sizes the box around the label; on a fixed-size layer ("wrap", "fit", "shrink") lines wrap inside the padded area, so padding narrows where they break. 0 / omitted ⇒ no explicit padding. |
| elementId* |
Set the extrapolation mode for one property's animation track. Modes: "hold" (default — holds the boundary keyframe's value past the ends), "loop" (wraps frames past the last keyframe back to the first, restarting the animation), "ping-pong" (alternates direction each cycle, bouncing back and forth), "cycle" (wraps like loop but each cycle adds the boundary delta — used for endless rotation or scrolling). Has no effect on tracks with fewer than 2 keyframes.
| Argument | Type | Description |
|---|---|---|
| mode* | string | |
| property* | string | |
| elementId* | string | Layer id (video/image/shape/group). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"property",
"mode",
"projectId"
],
"properties": {
"mode": {
"enum": [
"hold",
"loop",
"ping-pong",
"cycle"
],
"type": "string"
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string",
"description": "Layer id (video/image/shape/group)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Repoint an existing video layer at a different uploaded clip — keeps the layer's id, position, size, animations, styles, and trim window; only the source mp4 changes. The clip must already be uploaded (the editor's Add video); pass the `filename` the upload returned, and its `name` as the label. Use this to swap a video layer's source WITHOUT losing its keyframes.
| Argument | Type | Description |
|---|---|---|
| clip* | string | The uploaded clip's id: the `filename` its upload returned. |
| name | string | null | Optional label people see for the layer: pass the upload's returned `name`. Omit to keep the current label; null or an empty string clears it. |
| elementId* | string | Video layer id, video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"clip",
"projectId"
],
"properties": {
"clip": {
"type": "string",
"description": "The uploaded clip's id: the `filename` its upload returned."
},
"name": {
"type": [
"string",
"null"
],
"description": "Optional label people see for the layer: pass the upload's returned `name`. Omit to keep the current label; null or an empty string clears it."
},
"elementId": {
"type": "string",
"description": "Video layer id, video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Mute or unmute a video layer's baked audio (silenced in both preview and export). The processing pipeline's audio-split step sets this true after demuxing the clip's audio into a standalone overlay track (NLE-style linked A/V), so the source audio doesn't double with the overlay. Pass muted:false to restore the baked audio.
| Argument | Type | Description |
|---|---|---|
| muted* | boolean | true silences the layer's baked audio; false restores it. |
| elementId* | string | Video layer id, video.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"muted",
"projectId"
],
"properties": {
"muted": {
"type": "boolean",
"description": "true silences the layer's baked audio; false restores it."
},
"elementId": {
"type": "string",
"description": "Video layer id, video.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Patch a video layer's trim window: source_in_frame (frame in source to start), source_out_frame (frame in source to stop, or null for natural end), timeline_start_frame (where on the project timeline the slice begins). Only the fields you pass are changed. Use this to clip out a segment of a source mp4: duplicate the layer first (in the editor) so you have two pointing at the same clip, then set disjoint source windows.
| Argument | Type | Description |
|---|---|---|
| elementId* | string | video.<id> of the layer to trim. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| source_in_frame | number | Frame in the source mp4 where playback begins (0-indexed, 30 fps). |
| source_out_frame | number | null | Frame in the source mp4 where playback stops, or null to clear and play to the source's natural end. |
| timeline_start_frame | number | Project-timeline frame where this slice begins playing. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"elementId": {
"type": "string",
"description": "video.<id> of the layer to trim."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"source_in_frame": {
"type": "number",
"description": "Frame in the source mp4 where playback begins (0-indexed, 30 fps)."
},
"source_out_frame": {
"type": [
"number",
"null"
],
"description": "Frame in the source mp4 where playback stops, or null to clear and play to the source's natural end."
},
"timeline_start_frame": {
"type": "number",
"description": "Project-timeline frame where this slice begins playing."
}
}
}MOVE a group and everything inside it along the timeline, keeping its internal timing intact — the 'slide this whole section later' operation. A plain group is a relative CONTAINER: it has no window of its own, so on the timeline it spans the hull of its contents, and moving it slides the whole subtree as one rigid body. `start` is the ABSOLUTE frame the group's window should end up at, not a delta, so calling it twice with the same value is a no-op. Descendants keep their spacing; the move stops when the earliest thing inside reaches frame 0. Welded caption lines are deliberately left behind (they follow their clip's speech, not this group), and an embedded morpha band moves as one unit. Fails when the group is empty or holds an always-present layer — there is no bounded window to move. To CLIP what is shown of a group rather than move it, use set_group_window; for a single layer use set_layer_block.
| Argument | Type | Description |
|---|---|---|
| start* | number | ABSOLUTE frame the group's window should start at after the move (0-indexed, 30 fps) — not an offset. |
| elementId* | string | The group's id (group.<id> or the bare <id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"start",
"projectId"
],
"properties": {
"start": {
"type": "number",
"description": "ABSOLUTE frame the group's window should start at after the move (0-indexed, 30 fps) — not an offset."
},
"elementId": {
"type": "string",
"description": "The group's id (group.<id> or the bare <id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Bulk-shift every keyframe's VALUE on one property of one layer by `delta`. Mirrors the 'select all keyframes + nudge layer' gesture in After Effects / Premiere / FCP — preserves the relative spacing of the animation but slides the whole curve. Keyframe TIMES are untouched. Use for 'move all x by -30px' on a complex animation, retiming a fade by adjusting its base opacity, rotating an existing wobble by 10°, etc.
| Argument | Type | Description |
|---|---|---|
| delta* | number | Added to every keyframe's value on this track. Negative shifts the curve down/left. |
| property* | string | |
| elementId* | string | |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"property",
"delta",
"projectId"
],
"properties": {
"delta": {
"type": "number",
"description": "Added to every keyframe's value on this track. Negative shifts the curve down/left."
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Show the project in the chat as a live preview that plays and follows every edit; a multi-page project turns its pages in the preview. Call it once per project, right after creating or opening it; later edits appear in the preview without calling again. Answers { name, pages, placards, durationSeconds, editorUrl }. Its result also carries one picture per page, a Morpha placard shaped like that page (up to 12, in page order): use it wherever you show a page, and never make or fetch a picture of your own. The preview lasts 30 minutes; call again if the person comes back later. `controls` picks the player's controls: 'full' (the default: play, a scrub bar, the time, sound and fullscreen) or 'play' (the play button alone, for a preview that should look like a plain video). In a client that cannot render a preview, give the person the editorUrl instead.
| Argument | Type | Description |
|---|---|---|
| controls | string | Which controls the preview shows: 'full' (default) or 'play' for the play button alone. |
| projectId* | string | Id of the project to show. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"controls": {
"enum": [
"full",
"play"
],
"type": "string",
"description": "Which controls the preview shows: 'full' (default) or 'play' for the play button alone."
},
"projectId": {
"type": "string",
"description": "Id of the project to show."
}
}
}Split one caption line into two at a COMPOSITION frame strictly inside its window (in the editor this is the playhead). The right half is a full clone — style, band geometry, weld — and the text divides at the word gap nearest the split point (a single-word line keeps its text on the left; the right half starts empty). A welded line stays welded on both halves (the frame converts to the clip's source timeline); a standalone line splits its block. Returns { left, right, splitFrame }. Retime the halves afterwards with set_layer_block; fix the wording with set_layer_text.
| Argument | Type | Description |
|---|---|---|
| atFrame* | number | Composition frame to split at — must be strictly inside the line's on-timeline window (see describe_video / inspect_layers for windows). |
| elementId* | string | The caption line to split, "text.<id>". |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"atFrame",
"projectId"
],
"properties": {
"atFrame": {
"type": "number",
"description": "Composition frame to split at — must be strictly inside the line's on-timeline window (see describe_video / inspect_layers for windows)."
},
"elementId": {
"type": "string",
"description": "The caption line to split, \"text.<id>\"."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Return the cached transcript of a clip's audio. Generated client-side in the Morpha editor (transformers.js Whisper) and cached in R2 next to the clip; if absent, it is produced when the clip is opened in the editor. Independent of the clip's video codec — runs on the audio track only, so it works on HEVC/AV1 clips that the OCR pipeline can't decode. Returns { ok: true, status: 'ready' | 'not-ready', data: { text, word_count, words: [{ word, start, end }], vtt? } }.
| Argument | Type | Description |
|---|---|---|
| clip* | string | Clip filename (video.<id>.clip). |
| projectId* | string | Project the clip belongs to. |
{
"type": "object",
"required": [
"projectId",
"clip"
],
"properties": {
"clip": {
"type": "string",
"description": "Clip filename (video.<id>.clip)."
},
"projectId": {
"type": "string",
"description": "Project the clip belongs to."
}
}
}Dissolve a group: its children are spliced into the group's parent at the group's old position. The group's animation tracks are discarded — children survive at their last positions but inherit none of the group's keyframes.
| Argument | Type | Description |
|---|---|---|
| groupId* | string | Bare group id (no 'group.' prefix), e.g. "header" for group.header. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"groupId",
"projectId"
],
"properties": {
"groupId": {
"type": "string",
"description": "Bare group id (no 'group.' prefix), e.g. \"header\" for group.header."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}Patch an existing audio overlay. Only the fields you pass are changed. Pass endFrame:null to clear it (revert to natural-length playback).
| Argument | Type | Description |
|---|---|---|
| id* | string | |
| gain | number | Linear gain 0..2. |
| name | string | null | The track's label people see (pass the upload's returned `name` alongside a replacement `filename`), or null to remove it. |
| endFrame | number | null | End frame, or null to clear and use the asset's natural length. |
| filename | string | Replace the track's audio: the stored filename an upload returned. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| startFrame | number | |
| fadeInFrames | number | |
| fadeOutFrames | number |
Upload an audio track into a project from a public http(s) URL or a file attached in the chat, fetched server-side — the ONLY way to get an audio file's bytes into a project over MCP/HTTP (add_audio_overlay / update_audio_overlay only reference a file that is already uploaded). Pass `url` for a public http(s) link, or `file` for a file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills `file` with a temporary download link itself; do not ask the person for a URL to something they have attached). The target is the URL you pass and nothing else: Morpha's Worker fetches it with a plain HTTP GET, following redirects, and identifies itself as "Morpha/1.0 (+https://morphareels.ai; [email protected])". No third-party API is involved. The worker downloads the .mp3/.m4a/.wav/.ogg/.aac, stores it in the project's asset bucket under a new id, and returns { filename, name, sizeBytes, contentType }. Then pass the returned `filename` (the file's id, never shown to a person) to add_audio_overlay (add a second track) or update_audio_overlay { id, filename } (replace an existing track's file — find the id via describe_video's audio_overlays), with `name` as the track's name. A url must be a direct, publicly-fetchable http(s) link (not an auth-walled page). Buffered in Worker memory, so capped at 50 MB. A file on the person's own disk is uploaded in the editor. Reference: https://morphareels.ai/docs/tools#uploadaudiourl-file-name
| Argument | Type | Description |
|---|---|---|
| url | string | Direct, publicly-fetchable http(s) URL of the audio file (.mp3/.m4a/.wav/.ogg/.aac). Pass either this or `file`. |
| file | object | A file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills this itself; do not type it). Pass either this or `url`. |
| name | string |
Upload an image into a project from a public http(s) URL or a file attached in the chat, fetched server-side. Pass `url` for a public http(s) link, or `file` for a file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills `file` with a temporary download link itself; do not ask the person for a URL to something they have attached). The target is the URL you pass and nothing else: Morpha's Worker fetches it with a plain HTTP GET, following redirects, and identifies itself as "Morpha/1.0 (+https://morphareels.ai; [email protected])". No third-party API is involved. The worker downloads the .png/.jpg/.jpeg/.gif/.webp/.svg, stores it in the project's asset bucket under a new id, and returns { filename, name, sizeBytes, contentType, width, height }: pass the returned `filename` (the file's id, never shown to a person) to add_image_layer, and `name` is what people see; `width` and `height` are the picture's own pixel size (null when its header did not say), so size the layer from them or pass add_image_layer one side and let it keep the aspect. This tool does not search for images: pass a direct image file URL, such as one the person has given you, and respect the licence its source states. A url must be a direct, publicly-fetchable http(s) link (not an auth-walled page). SVGs are rejected if they carry a <script>, an inline event handler, a <foreignObject>, a javascript: URL, or an external resource reference — supply a static, self-contained SVG. Buffered in Worker memory, so capped at 16 MB. A file on the person's own disk is uploaded in the editor. Reference: https://morphareels.ai/docs/tools#uploadimageurl-file-name
| Argument | Type | Description |
|---|---|---|
| url | string | Direct, publicly-fetchable http(s) URL of the image file (.png/.jpg/.jpeg/.gif/.webp/.svg). Pass either this or `file`. |
| file | object | A file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills this itself; do not type it). Pass either this or `url`. |
| string |
| Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| clip_element_id | string | Optional "video.<id>" to WELD the caption lines to (line-sync mode). When set, each line's startFrame/endFrame are treated as its window in the clip's OWN source timeline and the on-timeline position is derived live from the clip's trim — so trimming or sliding the clip retimes/clips the captions, exactly like the clip's welded audio. Omit for fixed project-frame captions. |
{
"type": "object",
"required": [
"lines",
"projectId"
],
"properties": {
"x": {
"type": "number",
"description": "Caption band centre x. Default canvas centre."
},
"y": {
"type": "number",
"description": "Caption band centre y. Default lower third (~80% of height)."
},
"mode": {
"enum": [
"line-sync",
"static"
],
"type": "string",
"description": "\"line-sync\" (default): one timed layer per line. \"static\": one layer with all lines."
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": [
"text",
"startFrame"
],
"properties": {
"text": {
"type": "string"
},
"endFrame": {
"type": "number",
"description": "Project frame the line disappears (defaults to startFrame + 30)."
},
"startFrame": {
"type": "number",
"description": "Project frame the line appears."
},
"clip_element_id": {
"type": "string",
"description": "Optional per-line weld clip (\"video.<id>\"), overriding the top-level clip_element_id. Routes THIS line onto a specific lane clip — used to spread one montage's lines across the several clips that a single source was split into, so each line rides the clip that shows its words."
}
}
},
"description": "Caption lines in order. Each: { text, startFrame, endFrame } — frames are 0-indexed at 30fps."
},
"style": {
"enum": [
"classic",
"bold-outline",
"word-pop"
],
"type": "string",
"description": "Caption look preset. Default \"classic\"."
},
"width": {
"type": "number",
"description": "Band width. Default ~86% of canvas width."
},
"height": {
"type": "number",
"description": "Band height. Default ~16% of canvas height."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"clip_element_id": {
"type": "string",
"description": "Optional \"video.<id>\" to WELD the caption lines to (line-sync mode). When set, each line's startFrame/endFrame are treated as its window in the clip's OWN source timeline and the on-timeline position is derived live from the clip's trim — so trimming or sliding the clip retimes/clips the captions, exactly like the clip's welded audio. Omit for fixed project-frame captions."
}
}
}{
"type": "object",
"required": [
"elementId",
"property",
"frame",
"value",
"projectId"
],
"properties": {
"frame": {
"type": "number",
"description": "Frame number, 0-indexed. 30 fps so frame 30 = 1 second."
},
"value": {
"type": [
"string",
"object"
],
"description": "Either '#rrggbb' (promoted to solid) or a Fill object: {type:\"solid\",color} / {type:\"linear\",stops:[{pos:0..1,color}],angle?} / {type:\"radial\",stops:[{pos:0..1,color}],cx?,cy?,radius?}. Adjacent keyframes crossfade the gradient stop-by-stop."
},
"easing": {
"enum": [
"linear",
"easeIn",
"easeOut",
"easeInOut",
"outQuart",
"outExpo",
"outBack",
"inBack",
"inOutBack",
"cubicBezier",
"hold"
],
"type": "string",
"description": "Interpolation to the next keyframe. Default linear."
},
"property": {
"enum": [
"fill"
],
"type": "string",
"description": "Currently only 'fill' is supported."
},
"elementId": {
"type": "string",
"description": "Element id (shapes/image/video/group prefixed). The pinned is_background image_layer is the canvas backdrop; 'background.canvas' is also accepted as a synonym."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}| number |
| Line thickness in px. |
{
"type": "object",
"required": [
"x1",
"y1",
"x2",
"y2",
"projectId"
],
"properties": {
"x1": {
"type": "number",
"description": "Start x (canvas px)."
},
"x2": {
"type": "number",
"description": "End x (canvas px) — the arrowhead end."
},
"y1": {
"type": "number",
"description": "Start y (canvas px)."
},
"y2": {
"type": "number",
"description": "End y (canvas px)."
},
"bend": {
"type": "number",
"description": "Perpendicular bow of the curve in px. 0 = straight."
},
"color": {
"type": "string",
"description": "Stroke colour as #rrggbb."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"arrow_head": {
"enum": [
"none",
"end",
"both"
],
"type": "string"
},
"stroke_width": {
"type": "number",
"description": "Line thickness in px."
}
}
}| height | number | Height in px (> 0). Omit it to derive it from `width` and the image's aspect; the usual call passes `width` alone. |
| filename* | string | The stored filename an upload returned (an id such as 3f2a9c1e-….png, or an older project's own name). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"filename",
"x",
"y",
"projectId"
],
"properties": {
"x": {
"type": "number",
"description": "Centre x in 1080-wide base coords."
},
"y": {
"type": "number",
"description": "Centre y in 1920-tall base coords."
},
"name": {
"type": "string",
"description": "Optional label people see in the Inspector and Timeline: pass the upload's returned `name`. Without it the layer is labelled by an older project's own filename, or by kind; never by a stored id."
},
"block": {
"type": "object",
"required": [
"start",
"duration"
],
"properties": {
"start": {
"type": "number",
"description": "First visible composition frame (≥ 0)."
},
"duration": {
"type": "number",
"description": "Length of the window in frames (≥ 1)."
}
},
"description": "OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`."
},
"width": {
"type": "number",
"description": "Width in px (> 0). Omit it to derive it from `height` and the image's aspect."
},
"height": {
"type": "number",
"description": "Height in px (> 0). Omit it to derive it from `width` and the image's aspect; the usual call passes `width` alone."
},
"filename": {
"type": "string",
"description": "The stored filename an upload returned (an id such as 3f2a9c1e-….png, or an older project's own name)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}{
"type": "object",
"required": [
"elementId",
"property",
"frame",
"value",
"projectId"
],
"properties": {
"frame": {
"type": "number",
"description": "Frame number, 0-indexed. 30 fps so frame 30 = 1 second."
},
"value": {
"type": "number",
"description": "Track value at this frame."
},
"easing": {
"enum": [
"linear",
"easeIn",
"easeOut",
"easeInOut",
"outQuart",
"outExpo",
"outBack",
"inBack",
"inOutBack",
"cubicBezier",
"hold"
],
"type": "string",
"description": "Interpolation to the next keyframe. Default linear."
},
"property": {
"enum": [
"x",
"y",
"width",
"height",
"scale",
"rotation",
"opacity",
"curve"
],
"type": "string"
},
"elementId": {
"type": "string",
"description": "Layer id (video.<id>, image.<id>, shapes.<id>, or group.<id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}| height | number |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"kind",
"projectId"
],
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"kind": {
"enum": [
"rect",
"ellipse",
"triangle",
"star",
"pentagon",
"hexagon",
"arrow",
"heart",
"rounded-rect",
"diamond",
"parallelogram",
"trapezoid",
"semicircle",
"ring",
"pill",
"cross",
"heptagon",
"octagon",
"star-4",
"star-6",
"sparkle",
"burst",
"arrow-left",
"double-arrow",
"chevron",
"block-arrow-up",
"curve",
"lightning",
"speech-bubble",
"location-pin",
"checkmark",
"x-mark",
"shield",
"cloud",
"crescent",
"teardrop",
"banner"
],
"type": "string"
},
"block": {
"type": "object",
"required": [
"start",
"duration"
],
"properties": {
"start": {
"type": "number",
"description": "First visible composition frame (≥ 0)."
},
"duration": {
"type": "number",
"description": "Length of the window in frames (≥ 1)."
}
},
"description": "OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`."
},
"color": {
"type": "string",
"description": "Fill colour as #rrggbb."
},
"width": {
"type": "number"
},
"height": {
"type": "number"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}| curve | number | Curve the text onto an arc, in degrees of total sweep. 0 = straight (default). POSITIVE = a SMILE (⌣, ends rise); NEGATIVE = an ARCH (⌒, rainbow). Clamped ±135. A tasteful smile is ~+60. Single line only (multi-line is joined while curved). |
| width | number | Box width in px (> 0). Default 900. |
| height | number | Box height in px (> 0). Default 320. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| text_size | number | Font size in px. Omit to derive from existing text layers (median) or the canvas height. |
| font_style | string | Italic toggle. Default normal. |
| text_align | string | Horizontal alignment of each line. Default center. |
| text_color | string | Text fill colour as #rrggbb. Defaults to white. |
| decorations | object | null | Per-character underline / strikethrough. { underline?: [{start,end}], strikethrough?: [{start,end}] } — half-open character ranges [start,end) (UTF-16 offsets) into `text`. E.g. underline the first word of "Big news": underline:[{start:0,end:3}]. Ranges are normalized (sorted + merged). Not rendered on curved text. |
| font_family | string | Google Fonts family name, e.g. "Anton". Defaults to "Hanken Grotesk". |
| font_weight | number | Font weight 100..900 (400 regular, 700 bold, 800 black). Default 400. |
| line_height | number | Line height as a multiple of font size (1.2 = 120%). |
| text_shadow | object | null | Drop shadow { offsetX, offsetY, blur, color }; color is any CSS colour (rgba allowed). null clears it. |
| text_valign | string | Vertical alignment of the text block within its box. "middle" (default) centres it; "bottom" pins it to the box floor so extra wrapped lines grow upward from a fixed baseline (captions use this so a wrapped line doesn't shift the others); "top" pins the ceiling. |
| stroke_color | string | Outline colour as #rrggbb. Defaults to white when a width is set. |
| stroke_width | number | Outline width in px (0 = no outline). |
| text_autofit | string | How text fits its box. "hug" (default for new layers): hold text_size FIXED and DERIVE the box from the measured text plus padding, honouring the literal newlines you pass — the box shrink-wraps the exact content and grows/shrinks live as the text changes, so it can never re-wrap differently between the editor preview and the export (bake your own "\n" breaks; pair with set_text_background for a rounded caption box). "wrap": hold text_size FIXED in a fixed-size box and only word-wrap (hard-breaking a single over-wide word), never resize. "fit": ignore text_size and auto-size the font BOTH ways (grow and shrink) to the largest size whose wrapped block fills the box — resizing the box resizes the text. "shrink" (legacy): word-wrap then auto-shrink the font from text_size until the block fits; never grows. |
| letter_spacing | number | Tracking between glyphs in px; may be negative. Default 0. |
| text_transform | string | Case transform applied before layout. Default none. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"x": {
"type": "number",
"description": "Centre x in canvas px. Defaults to canvas centre."
},
"y": {
"type": "number",
"description": "Centre y in canvas px. Defaults to canvas centre."
},
"text": {
"type": "string",
"description": "The text to render. Newlines are honoured as hard line breaks."
},
"block": {
"type": "object",
"required": [
"start",
"duration"
],
"properties": {
"start": {
"type": "number",
"description": "First visible composition frame (≥ 0)."
},
"duration": {
"type": "number",
"description": "Length of the window in frames (≥ 1)."
}
},
"description": "OPTIONAL timeline window — {start, duration} in composition frames. OMIT IT (the default) and the layer is ALWAYS PRESENT: a persistent overlay that holds for the whole composition, which is what an agent-placed watermark / lower-third almost always wants. Pass it to place a bounded CLIP instead (what the editor's own add does: 5 s at the playhead). Keyframes on a blocked layer are sampled RELATIVE to `start`."
},
"curve": {
"type": "number",
"description": "Curve the text onto an arc, in degrees of total sweep. 0 = straight (default). POSITIVE = a SMILE (⌣, ends rise); NEGATIVE = an ARCH (⌒, rainbow). Clamped ±135. A tasteful smile is ~+60. Single line only (multi-line is joined while curved)."
},
"width": {
"type": "number",
"description": "Box width in px (> 0). Default 900."
},
"height": {
"type": "number",
"description": "Box height in px (> 0). Default 320."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"text_size": {
"type": "number",
"description": "Font size in px. Omit to derive from existing text layers (median) or the canvas height."
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string",
"description": "Italic toggle. Default normal."
},
"text_align": {
"enum": [
"left",
"center",
"right"
],
"type": "string",
"description": "Horizontal alignment of each line. Default center."
},
"text_color": {
"type": "string",
"description": "Text fill colour as #rrggbb. Defaults to white."
},
"decorations": {
"type": [
"object",
"null"
],
"properties": {
"underline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"end": {
"type": "number"
},
"start": {
"type": "number"
}
}
}
},
"strikethrough": {
"type": "array",
"items": {
"type": "object",
"properties": {
"end": {
"type": "number"
},
"start": {
"type": "number"
}
}
}
}
},
"description": "Per-character underline / strikethrough. { underline?: [{start,end}], strikethrough?: [{start,end}] } — half-open character ranges [start,end) (UTF-16 offsets) into `text`. E.g. underline the first word of \"Big news\": underline:[{start:0,end:3}]. Ranges are normalized (sorted + merged). Not rendered on curved text."
},
"font_family": {
"type": "string",
"description": "Google Fonts family name, e.g. \"Anton\". Defaults to \"Hanken Grotesk\"."
},
"font_weight": {
"type": "number",
"description": "Font weight 100..900 (400 regular, 700 bold, 800 black). Default 400."
},
"line_height": {
"type": "number",
"description": "Line height as a multiple of font size (1.2 = 120%)."
},
"text_shadow": {
"type": [
"object",
"null"
],
"properties": {
"blur": {
"type": "number"
},
"color": {
"type": "string"
},
"offsetX": {
"type": "number"
},
"offsetY": {
"type": "number"
}
},
"description": "Drop shadow { offsetX, offsetY, blur, color }; color is any CSS colour (rgba allowed). null clears it."
},
"text_valign": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string",
"description": "Vertical alignment of the text block within its box. \"middle\" (default) centres it; \"bottom\" pins it to the box floor so extra wrapped lines grow upward from a fixed baseline (captions use this so a wrapped line doesn't shift the others); \"top\" pins the ceiling."
},
"stroke_color": {
"type": "string",
"description": "Outline colour as #rrggbb. Defaults to white when a width is set."
},
"stroke_width": {
"type": "number",
"description": "Outline width in px (0 = no outline)."
},
"text_autofit": {
"enum": [
"fit",
"shrink",
"wrap",
"hug"
],
"type": "string",
"description": "How text fits its box. \"hug\" (default for new layers): hold text_size FIXED and DERIVE the box from the measured text plus padding, honouring the literal newlines you pass — the box shrink-wraps the exact content and grows/shrinks live as the text changes, so it can never re-wrap differently between the editor preview and the export (bake your own \"\\n\" breaks; pair with set_text_background for a rounded caption box). \"wrap\": hold text_size FIXED in a fixed-size box and only word-wrap (hard-breaking a single over-wide word), never resize. \"fit\": ignore text_size and auto-size the font BOTH ways (grow and shrink) to the largest size whose wrapped block fills the box — resizing the box resizes the text. \"shrink\" (legacy): word-wrap then auto-shrink the font from text_size until the block fits; never grows."
},
"letter_spacing": {
"type": "number",
"description": "Tracking between glyphs in px; may be negative. Default 0."
},
"text_transform": {
"enum": [
"none",
"uppercase",
"lowercase"
],
"type": "string",
"description": "Case transform applied before layout. Default none."
}
}
}{
"type": "object",
"required": [
"clip",
"x",
"y",
"width",
"height",
"projectId"
],
"properties": {
"x": {
"type": "number",
"description": "Centre x in 1080-wide base coords."
},
"y": {
"type": "number",
"description": "Centre y in 1920-tall base coords."
},
"clip": {
"type": "string",
"description": "The stored clip filename an upload returned (an id such as 3f2a9c1e-….mp4, or an older project's own name)."
},
"name": {
"type": "string",
"description": "Optional label people see in the Inspector and Timeline: pass the upload's returned `name`."
},
"width": {
"type": "number",
"description": "Width in px (must be > 0)."
},
"height": {
"type": "number",
"description": "Height in px (must be > 0)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Recommended human-readable label, shown verbatim in the editor's project picker (the only user-facing handle for the project). Omit and the picker falls back to the opaque id."
},
"workspaceId": {
"type": [
"string",
"null"
],
"description": "Where to place the project. Omit to FOLLOW THE SOURCE (a clone inherits its source's workspace; a blank project is personal). A workspace id (from list_workspaces) places it there — requires an edit-capable role. null forces the caller's personal space."
},
"fromProjectId": {
"type": "string",
"description": "Optional id of an existing project to CLONE (copies its JSON + uploaded assets + clips). Omit to create a fresh blank project."
}
}
}{
"type": "object",
"required": [
"projectId"
],
"properties": {
"name": {
"type": "string",
"description": "Optional name for the copy. Shown verbatim in the picker; defaults to \"<source name> copy\"."
},
"projectId": {
"type": "string",
"description": "Id of the existing project to duplicate (the source)."
},
"workspaceId": {
"type": [
"string",
"null"
],
"description": "Optional override for where the copy lives. Omit to FOLLOW THE SOURCE (a workspace project stays in its workspace; a personal one stays personal). A workspace id (from list_workspaces) places the copy there — you need an edit-capable role. null forces the copy into your personal space."
}
}
}{
"type": "object",
"required": [
"elementId",
"frame",
"image",
"projectId"
],
"properties": {
"frame": {
"type": "number",
"description": "Project-timeline frame to freeze at — must be strictly inside the clip."
},
"image": {
"type": "string",
"description": "Filename of an already-uploaded PNG of the frozen frame, in the project's assets."
},
"elementId": {
"type": "string",
"description": "video.<id> to freeze."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"holdFrames": {
"type": "number",
"description": "How long the still holds, in frames. Defaults to 150 (5s at 30fps), the same default every added clip gets."
}
}
}| rotation | number | Rotation in degrees, clockwise. |
| elementId* | string | video.<id>, image.<id>, shapes.<id>, or group.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| clear_animation | boolean | Only meaningful when setting `scale` / `opacity` on a layer where that property is animated. true = the static value REPLACES the keyframe track. Omitted / false = the call is refused rather than writing a value the animation would hide. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"x": {
"type": "number",
"description": "Centre x in the element's OWN frame. At root that is canvas coords (1080 wide); INSIDE A GROUP it is the group's space, so it is not where the layer sits on the canvas. inspect_layers reports both — write back the `x` it gave you, and read `canvas_x` for the on-canvas position."
},
"y": {
"type": "number",
"description": "Centre y in the element's OWN frame — canvas coords (1920 tall) at root, the group's space inside a group. See `x`."
},
"scale": {
"type": "number",
"description": "Uniform scale multiplier about the layer's pivot. 1 = natural size. Every layer kind carries it."
},
"width": {
"type": "number",
"description": "Width in px (must be > 0)."
},
"height": {
"type": "number",
"description": "Height in px (must be > 0)."
},
"opacity": {
"type": "number",
"description": "Layer opacity, 0..1 (values outside are allowed and clamp at paint time). Composes with any ancestor group's opacity."
},
"rotation": {
"type": "number",
"description": "Rotation in degrees, clockwise."
},
"elementId": {
"type": "string",
"description": "video.<id>, image.<id>, shapes.<id>, or group.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"clear_animation": {
"type": "boolean",
"description": "Only meaningful when setting `scale` / `opacity` on a layer where that property is animated. true = the static value REPLACES the keyframe track. Omitted / false = the call is refused rather than writing a value the animation would hide."
}
}
}{
"type": "object",
"required": [
"projectId"
],
"properties": {
"page": {
"type": "number",
"description": "Optional 0-based page index for a multi-page project. Omit for the project's active page; render one page per call."
},
"scale": {
"type": "number",
"description": "Optional output scale: 2 (the default) renders at twice the canvas, roughly 4K for a portrait project; 1 renders at the canvas's own size for a faster file that uses less allowance. Nothing else is accepted."
},
"projectId": {
"type": "string",
"description": "Project to render."
}
}
}| string |
| Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"family",
"src",
"projectId"
],
"properties": {
"src": {
"type": "string",
"description": "A full font URL (https://…/font.woff2) OR an uploaded asset filename in the project's bucket."
},
"style": {
"enum": [
"normal",
"italic"
],
"type": "string",
"description": "Optional face style. Defaults to normal."
},
"family": {
"type": "string",
"description": "Family name text layers will reference via font_family, e.g. \"Mylius Modern\"."
},
"weight": {
"type": "number",
"description": "Optional specific weight (1-1000) this src provides. Omit for the 400/normal baseline."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}| clear_animation | boolean | Only meaningful on a layer whose fill is animated. true = this fill REPLACES the colour animation (its keyframes are deleted). Omitted / false = the call is refused rather than writing a fill the animation would hide. |
{
"type": "object",
"required": [
"elementId",
"fill",
"projectId"
],
"properties": {
"fill": {
"type": [
"string",
"object",
"null"
],
"description": "Either '#rrggbb' (promoted to solid) or a Fill object: {type:\"solid\",color} / {type:\"linear\",stops:[{pos:0..1,color}],angle?} / {type:\"radial\",stops:[{pos:0..1,color}],cx?,cy?,radius?} / {type:\"mask\",layer_id,color}. A gradient is ONE fill — don't fake it with stacked shapes. null (image/video/text/group only) clears the backdrop, including any fill colour keyframes on it."
},
"elementId": {
"type": "string",
"description": "shapes.<id> / image.<id> / video.<id> / group.<id>. The pinned is_background image_layer is the canvas backdrop; the literal 'background.canvas' is accepted as a synonym."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"clear_animation": {
"type": "boolean",
"description": "Only meaningful on a layer whose fill is animated. true = this fill REPLACES the colour animation (its keyframes are deleted). Omitted / false = the call is refused rather than writing a fill the animation would hide."
}
}
}| projectId* |
| string |
| Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| text_size | number | Font size in px. |
| font_style | string | Italic toggle. Default normal. |
| text_align | string | Horizontal alignment of each line. Default center. |
| text_color | string | Text fill colour as #rrggbb. |
| decorations | object | null | Per-character underline / strikethrough. { underline?: [{start,end}], strikethrough?: [{start,end}] } — each a list of half-open character ranges [start,end) (UTF-16 offsets) into `text`. E.g. underline the first word of "Big news": underline:[{start:0,end:3}]. Ranges are normalized (sorted + merged). null clears all decorations; editing `text` in the SAME call re-indexes existing ranges against the new text. Not rendered on curved text. |
| font_family | string | Google Fonts family name, e.g. "Anton". |
| font_weight | number | Font weight 100..900 (400 regular, 700 bold, 800 black). Default 400; the canvas synthesizes weights a static font doesn't ship. |
| line_height | number | Line height as a multiple of font size (1.2 = 120%). |
| text_shadow | object | null | Drop shadow { offsetX, offsetY, blur, color }; color is any CSS colour (rgba allowed). null clears it. |
| text_valign | string | Vertical alignment of the text block within its box. "middle" (default) centres it; "bottom" pins it to the box floor so extra wrapped lines grow upward from a fixed baseline (captions use this so a wrapped line doesn't shift the others); "top" pins the ceiling. |
| stroke_color | string | Outline colour as #rrggbb. Defaults to white when a width is set. |
| stroke_width | number | Outline width in px (0 = no outline). |
| text_autofit | string | How text fits its box. "hug" (default for new layers): hold text_size FIXED and DERIVE the box from the measured text plus padding, honouring the literal newlines you pass — the box shrink-wraps the exact content and grows/shrinks live as the text changes, so it can never re-wrap differently between the editor preview and the export (bake your own "\n" breaks; pair with set_text_background for a rounded caption box). "wrap": hold text_size FIXED in a fixed-size box and only word-wrap (hard-breaking a single over-wide word), never resize. "fit": ignore text_size and auto-size the font BOTH ways (grow and shrink) to the largest size whose wrapped block fills the box — resizing the box resizes the text. "shrink" (legacy): word-wrap then auto-shrink the font from text_size until the block fits; never grows. |
| letter_spacing | number | Tracking between glyphs in px; may be negative. Default 0. |
| text_transform | string | Case transform applied before layout. Default none. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"text": {
"type": "string",
"description": "The text to render. Newlines are honoured as hard line breaks."
},
"curve": {
"type": "number",
"description": "Curve the text onto an arc, in degrees of total sweep. 0 = straight (default). POSITIVE = a SMILE (⌣, ends rise); NEGATIVE = an ARCH (⌒, rainbow). Clamped ±135. A tasteful smile is ~+60. Applies to a SINGLE line — multi-line text is joined to one line while curved (the stored text is untouched, so curve:0 restores it)."
},
"elementId": {
"type": "string",
"description": "Text layer id, text.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"text_size": {
"type": "number",
"description": "Font size in px."
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string",
"description": "Italic toggle. Default normal."
},
"text_align": {
"enum": [
"left",
"center",
"right"
],
"type": "string",
"description": "Horizontal alignment of each line. Default center."
},
"text_color": {
"type": "string",
"description": "Text fill colour as #rrggbb."
},
"decorations": {
"type": [
"object",
"null"
],
"properties": {
"underline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"end": {
"type": "number"
},
"start": {
"type": "number"
}
}
}
},
"strikethrough": {
"type": "array",
"items": {
"type": "object",
"properties": {
"end": {
"type": "number"
},
"start": {
"type": "number"
}
}
}
}
},
"description": "Per-character underline / strikethrough. { underline?: [{start,end}], strikethrough?: [{start,end}] } — each a list of half-open character ranges [start,end) (UTF-16 offsets) into `text`. E.g. underline the first word of \"Big news\": underline:[{start:0,end:3}]. Ranges are normalized (sorted + merged). null clears all decorations; editing `text` in the SAME call re-indexes existing ranges against the new text. Not rendered on curved text."
},
"font_family": {
"type": "string",
"description": "Google Fonts family name, e.g. \"Anton\"."
},
"font_weight": {
"type": "number",
"description": "Font weight 100..900 (400 regular, 700 bold, 800 black). Default 400; the canvas synthesizes weights a static font doesn't ship."
},
"line_height": {
"type": "number",
"description": "Line height as a multiple of font size (1.2 = 120%)."
},
"text_shadow": {
"type": [
"object",
"null"
],
"properties": {
"blur": {
"type": "number"
},
"color": {
"type": "string"
},
"offsetX": {
"type": "number"
},
"offsetY": {
"type": "number"
}
},
"description": "Drop shadow { offsetX, offsetY, blur, color }; color is any CSS colour (rgba allowed). null clears it."
},
"text_valign": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string",
"description": "Vertical alignment of the text block within its box. \"middle\" (default) centres it; \"bottom\" pins it to the box floor so extra wrapped lines grow upward from a fixed baseline (captions use this so a wrapped line doesn't shift the others); \"top\" pins the ceiling."
},
"stroke_color": {
"type": "string",
"description": "Outline colour as #rrggbb. Defaults to white when a width is set."
},
"stroke_width": {
"type": "number",
"description": "Outline width in px (0 = no outline)."
},
"text_autofit": {
"enum": [
"fit",
"shrink",
"wrap",
"hug"
],
"type": "string",
"description": "How text fits its box. \"hug\" (default for new layers): hold text_size FIXED and DERIVE the box from the measured text plus padding, honouring the literal newlines you pass — the box shrink-wraps the exact content and grows/shrinks live as the text changes, so it can never re-wrap differently between the editor preview and the export (bake your own \"\\n\" breaks; pair with set_text_background for a rounded caption box). \"wrap\": hold text_size FIXED in a fixed-size box and only word-wrap (hard-breaking a single over-wide word), never resize. \"fit\": ignore text_size and auto-size the font BOTH ways (grow and shrink) to the largest size whose wrapped block fills the box — resizing the box resizes the text. \"shrink\" (legacy): word-wrap then auto-shrink the font from text_size until the block fits; never grows."
},
"letter_spacing": {
"type": "number",
"description": "Tracking between glyphs in px; may be negative. Default 0."
},
"text_transform": {
"enum": [
"none",
"uppercase",
"lowercase"
],
"type": "string",
"description": "Case transform applied before layout. Default none."
}
}
}| frames | number | Ramp length in frames (30 fps). Default 6. Ignored for kind "cut". |
| direction | string | For kind "slide" only. On the IN edge this is where the layer comes FROM; on the OUT edge, where it goes TO. Default "left". |
| elementId* | string | Element id of the layer (image.<id>, video.<id>, text.<id>, shapes.<id>, or group.<id>). |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
{
"type": "object",
"required": [
"elementId",
"edge",
"kind",
"projectId"
],
"properties": {
"edge": {
"enum": [
"in",
"out",
"both"
],
"type": "string",
"description": "Which edge to set. \"in\" is the start of the layer's window, \"out\" the end."
},
"kind": {
"enum": [
"cut",
"fade",
"slide",
"pop"
],
"type": "string",
"description": "The look. \"cut\" = hard edge (clears any transition). \"fade\" = opacity ramp. \"slide\" = travels in/out from a direction while fading. \"pop\" = scales up from 80% with an overshoot."
},
"curve": {
"type": "string",
"description": "Optional easing override (linear, easeIn, easeOut, easeInOut, outQuart, outExpo, outBack, inBack, inOutBack). Defaults suit the kind: entries decelerate in, exits accelerate away."
},
"frames": {
"type": "number",
"description": "Ramp length in frames (30 fps). Default 6. Ignored for kind \"cut\"."
},
"direction": {
"enum": [
"left",
"right",
"up",
"down"
],
"type": "string",
"description": "For kind \"slide\" only. On the IN edge this is where the layer comes FROM; on the OUT edge, where it goes TO. Default \"left\"."
},
"elementId": {
"type": "string",
"description": "Element id of the layer (image.<id>, video.<id>, text.<id>, shapes.<id>, or group.<id>)."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
}
}
}{
"type": "object",
"required": [
"elementId",
"matte_source_id",
"projectId"
],
"properties": {
"elementId": {
"type": "string",
"description": "Host being masked: image/video/shapes/text.<id>, or group.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"matte_inverted": {
"type": "boolean",
"description": "Optional. Invert the mask (knock-out): the host shows everywhere EXCEPT where the source is opaque — a punch-through / spotlight. Honored on leaf hosts; ignored on group hosts. Omitted = preserve current; clearing the mask resets it."
},
"matte_source_id": {
"type": [
"string",
"null"
],
"description": "Element id of the layer whose alpha drives the mask, or null to clear."
}
}
}| elementId* | string |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| tintColor | string | #rrggbb tint colour painted source-atop over the image (image layers only). |
| blend_mode | string | Photoshop-style layer blend mode (Canvas globalCompositeOperation). 'normal' is the default. Applies to every layer kind; allowed on group.<id> too (the only set_style field that is). |
| chroma_key | object | null | Green-screen key (video / image layers). Makes pixels near `color` transparent at render time so layers below show through. Object: { color: '#rrggbb' (default '#00ff00'), similarity: 0..1 (match radius, default 0.4), smoothness: 0..1 (edge feather, default 0.1) }. Pass null to clear. |
| borderAlign | string | Where the border sits relative to the layer's edge (design-tool "border position"). "inner" (default) draws the band INSIDE the box so it eats into the content; "outer" draws it entirely OUTSIDE so it frames the content without covering it; "center" straddles the edge 50/50. Rectangular boxes (image/video/text) only — shapes always stroke centred on their silhouette and ignore it. |
| borderColor | string | #rrggbb. |
| borderWidth | number |
| borderRadius | number |
| tintStrength | number | Tint blend strength 0..1. 0=no tint, 1=image silhouette filled with tintColor. Default 0 (no tint). |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"fit": {
"enum": [
"stretch",
"cover",
"contain"
],
"type": "string",
"description": "Fit mode (image + video layers). Default: stretch for image layers, cover for video layers."
},
"anchorX": {
"type": "number",
"description": "Object-position X (0..1). 0=left, 1=right, 0.5=centre. Only meaningful with fit=cover|contain."
},
"anchorY": {
"type": "number",
"description": "Object-position Y (0..1). 0=top, 1=bottom, 0.5=centre. Only meaningful with fit=cover|contain."
},
"alphaMask": {
"type": [
"object",
"null"
],
"required": [
"stops"
],
"properties": {
"type": {
"enum": [
"linear"
],
"type": "string"
},
"angle": {
"type": "number"
},
"stops": {
"type": "array",
"items": {
"type": "object",
"required": [
"offset",
"alpha"
],
"properties": {
"alpha": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"offset": {
"type": "number",
"maximum": 1,
"minimum": 0
}
}
},
"minItems": 2
}
},
"description": "Linear alpha-mask gradient (image layers). Multiplies the layer's alpha along a gradient line — used to fade a layer out partway across (the front half of a 'sandwich' covering text below it). Object: { type: 'linear', angle: number (deg, CSS-style; 0=to top, 90=to right, 180=to bottom, 270=to left), stops: [{offset:0..1, alpha:0..1}, ...] (≥2 stops, ordered by offset) }. Pass null to clear."
},
"boxShadow": {
"type": [
"string",
"null"
],
"description": "CSS box-shadow string, e.g. \"0 4px 12px rgba(0,0,0,0.5)\". Pass an empty string \"\" or null to REMOVE the shadow."
},
"elementId": {
"type": "string"
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"tintColor": {
"type": "string",
"description": "#rrggbb tint colour painted source-atop over the image (image layers only)."
},
"blend_mode": {
"enum": [
"normal",
"multiply",
"screen",
"overlay",
"darken",
"lighten",
"color-dodge",
"color-burn",
"hard-light",
"soft-light",
"difference",
"exclusion",
"hue",
"saturation",
"color",
"luminosity"
],
"type": "string",
"description": "Photoshop-style layer blend mode (Canvas globalCompositeOperation). 'normal' is the default. Applies to every layer kind; allowed on group.<id> too (the only set_style field that is)."
},
"chroma_key": {
"type": [
"object",
"null"
],
"properties": {
"color": {
"type": "string"
},
"similarity": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"smoothness": {
"type": "number",
"maximum": 1,
"minimum": 0
}
},
"description": "Green-screen key (video / image layers). Makes pixels near `color` transparent at render time so layers below show through. Object: { color: '#rrggbb' (default '#00ff00'), similarity: 0..1 (match radius, default 0.4), smoothness: 0..1 (edge feather, default 0.1) }. Pass null to clear."
},
"borderAlign": {
"enum": [
"inner",
"center",
"outer"
],
"type": "string",
"description": "Where the border sits relative to the layer's edge (design-tool \"border position\"). \"inner\" (default) draws the band INSIDE the box so it eats into the content; \"outer\" draws it entirely OUTSIDE so it frames the content without covering it; \"center\" straddles the edge 50/50. Rectangular boxes (image/video/text) only — shapes always stroke centred on their silhouette and ignore it."
},
"borderColor": {
"type": "string",
"description": "#rrggbb."
},
"borderWidth": {
"type": "number"
},
"borderRadius": {
"type": "number"
},
"tintStrength": {
"type": "number",
"description": "Tint blend strength 0..1. 0=no tint, 1=image silhouette filled with tintColor. Default 0 (no tint)."
}
}
}| string |
| text.<id>. |
| projectId* | string | Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates. |
| strokeColor | string | Box outline colour as #rrggbb. |
| strokeWidth | number | Box outline width in px. 0 / omitted ⇒ no outline. |
| cornerRadius | number | Corner radius of the box in px. 0 ⇒ square corners. |
| clear_animation | boolean | Only meaningful when `fill` is given AND the layer's backdrop fill is animated. true = the new fill REPLACES the colour animation. Omitted / false = the call is refused rather than writing a fill the animation would hide. |
{
"type": "object",
"required": [
"elementId",
"projectId"
],
"properties": {
"fill": {
"type": [
"string",
"object",
"null"
],
"description": "Box fill: '#rrggbb' (promoted to solid) or a Fill object: {type:\"solid\",color} / {type:\"linear\",stops:[{pos:0..1,color}],angle?} / {type:\"radial\",stops:[{pos:0..1,color}],cx?,cy?,radius?} / {type:\"mask\",layer_id,color}. null clears the box; omit to leave the current fill."
},
"padding": {
"type": "number",
"description": "Uniform inset (canvas px) between the box edge and the text. On a \"hug\" layer it is what sizes the box around the label; on a fixed-size layer (\"wrap\", \"fit\", \"shrink\") lines wrap inside the padded area, so padding narrows where they break. 0 / omitted ⇒ no explicit padding."
},
"elementId": {
"type": "string",
"description": "text.<id>."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"strokeColor": {
"type": "string",
"description": "Box outline colour as #rrggbb."
},
"strokeWidth": {
"type": "number",
"description": "Box outline width in px. 0 / omitted ⇒ no outline."
},
"cornerRadius": {
"type": "number",
"description": "Corner radius of the box in px. 0 ⇒ square corners."
},
"clear_animation": {
"type": "boolean",
"description": "Only meaningful when `fill` is given AND the layer's backdrop fill is animated. true = the new fill REPLACES the colour animation. Omitted / false = the call is refused rather than writing a fill the animation would hide."
}
}
}| sourceLayerId | string | null | Weld the overlay to a video layer ("video.<id>") so it renders as a clip footer and drags with the clip, or null to detach it back into a standalone track. |
| denoiseStrength | number | null | Clean-strength wet/dry mix 0..1 for an overlay with an AI-cleaned track: 1 = fully cleaned, 0 = fully original, between = blend. null clears it (full clean). Ignored while the Original track is selected. |
{
"type": "object",
"required": [
"id",
"projectId"
],
"properties": {
"id": {
"type": "string"
},
"gain": {
"type": "number",
"description": "Linear gain 0..2."
},
"name": {
"type": [
"string",
"null"
],
"description": "The track's label people see (pass the upload's returned `name` alongside a replacement `filename`), or null to remove it."
},
"endFrame": {
"type": [
"number",
"null"
],
"description": "End frame, or null to clear and use the asset's natural length."
},
"filename": {
"type": "string",
"description": "Replace the track's audio: the stored filename an upload returned."
},
"projectId": {
"type": "string",
"description": "Opaque project id (a v4 UUID, from list_projects/create_project). Selects which existing project this call mutates."
},
"startFrame": {
"type": "number"
},
"fadeInFrames": {
"type": "number"
},
"fadeOutFrames": {
"type": "number"
},
"sourceLayerId": {
"type": [
"string",
"null"
],
"description": "Weld the overlay to a video layer (\"video.<id>\") so it renders as a clip footer and drags with the clip, or null to detach it back into a standalone track."
},
"denoiseStrength": {
"type": [
"number",
"null"
],
"description": "Clean-strength wet/dry mix 0..1 for an overlay with an AI-cleaned track: 1 = fully cleaned, 0 = fully original, between = blend. null clears it (full clean). Ignored while the Original track is selected."
}
}
}| Optional display name, what people see (e.g. Theme.mp3). Defaults to the attached file's name or the URL's file name. It never decides where the file is stored. |
| projectId* | string | Project to add the audio to. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"url": {
"type": "string",
"description": "Direct, publicly-fetchable http(s) URL of the audio file (.mp3/.m4a/.wav/.ogg/.aac). Pass either this or `file`."
},
"file": {
"type": "object",
"required": [
"download_url",
"file_id"
],
"properties": {
"file_id": {
"type": "string",
"description": "The host's id for the file."
},
"file_name": {
"type": "string",
"description": "The file's own name, when the host knows it."
},
"mime_type": {
"type": "string",
"description": "The file's media type, when the host knows it."
},
"download_url": {
"type": "string",
"description": "Temporary URL the host serves the file at."
}
},
"description": "A file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills this itself; do not type it). Pass either this or `url`."
},
"name": {
"type": "string",
"description": "Optional display name, what people see (e.g. Theme.mp3). Defaults to the attached file's name or the URL's file name. It never decides where the file is stored."
},
"projectId": {
"type": "string",
"description": "Project to add the audio to."
}
}
}| name | string | Optional display name, what people see (e.g. Logo.png). Defaults to the attached file's name or the URL's file name. It never decides where the file is stored. |
| projectId* | string | Project to add the image to. |
{
"type": "object",
"required": [
"projectId"
],
"properties": {
"url": {
"type": "string",
"description": "Direct, publicly-fetchable http(s) URL of the image file (.png/.jpg/.jpeg/.gif/.webp/.svg). Pass either this or `file`."
},
"file": {
"type": "object",
"required": [
"download_url",
"file_id"
],
"properties": {
"file_id": {
"type": "string",
"description": "The host's id for the file."
},
"file_name": {
"type": "string",
"description": "The file's own name, when the host knows it."
},
"mime_type": {
"type": "string",
"description": "The file's media type, when the host knows it."
},
"download_url": {
"type": "string",
"description": "Temporary URL the host serves the file at."
}
},
"description": "A file the person attached in the chat (a host that supports file inputs, such as ChatGPT, fills this itself; do not type it). Pass either this or `url`."
},
"name": {
"type": "string",
"description": "Optional display name, what people see (e.g. Logo.png). Defaults to the attached file's name or the URL's file name. It never decides where the file is stored."
},
"projectId": {
"type": "string",
"description": "Project to add the image to."
}
}
}