Tool reference

This is the complete catalog. Every tool below is callable over MCP and over HTTP (POST /api/tool/<name>). Over HTTP the projectId is a top-level body field, not a tool argument; over MCP the MCP server injects projectId into each tool's input schema. With the SDK, morpha.callTool(projectId, name, args) takes projectId as its first argument and the tool's args object as the third. The worked examples show the tool arguments — the args object.

Arguments marked ? are optional. Read Getting started first for the conventions (centre-anchored coords, 30 fps, element ids).

Getting a key

Every tool call needs a Morpha key. A person gets one by signing in: an MCP client that follows the MCP authorization spec signs them in and receives a key (the MCP page has the detail), or they mint one by hand at morphareels.ai/app/settings.

An AI agent with no person to sign in creates an anonymous Morpha account instead, with POST /api/auth/agent-register or the SDK's registerAccount(). Morpha built this so an agent can start with nothing: creating one is expected, and nobody has to set anything up first. It takes no email, password or payment details, and it costs nothing. An optional name in the JSON body labels the key. Send the returned token as Authorization: Bearer <token> on every later call, over HTTP, through the SDK, or from an MCP client configured with that header.

curl -sX POST https://morphareels.ai/api/auth/agent-register
# → {"token":"mp_…","claimUrl":"https://morphareels.ai/claim/…"}

An anonymous account holds 1 project and 250 MB, and it has no AI credit envelope, so drive the tool catalog with your own model, which is free and unmetered.

An anonymous account can become permanent. When you are done, give the person the claimUrl; every project result from this account carries it too. They open it, sign in or sign up, and the project moves into their own Morpha account, where they choose Share, then Download (Render MP4 on a phone), which is free with no watermark and nothing to install. An anonymous account nobody claims is deleted after 30 days. If the person already uses Morpha, signing in through your MCP client instead puts the project straight into their account.

An anonymous account cannot render on Morpha's servers, because render_video needs a subscription. The person renders in the editor once the project is theirs, or a script renders with the npm client's client.renderVideo(projectId).

Workspace and lifecycle

These tools discover, create, rename, save, version, and delete projects, and place them in shared workspaces. They don't transform an existing composition — list_projects, list_workspaces, and create_project don't even take an existing projectId.

list_projects(workspaceId?)

List projects as { id, name, editorUrl } entries. The id is what every other tool's projectId takes; name is the editor picker label ("Untitled" when no name has been set — ids are opaque and never shown to the user, so always refer to a project by its name); editorUrl opens that project in the editor. With no argument, lists your own personal projects (those not in a 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 you need to pick a project without being told its id.

// args
{}
// → [{ "id": "…uuid…", "name": "Spring promo", "editorUrl": "https://morphareels.ai/app?project=…uuid…" }, …]

// or, scoped to a workspace:
{ "workspaceId": "…workspace-uuid…" }
// → [{ "id": "…", "name": "Team reel", "editorUrl": "…", "ownerEmail": "jo@acme.com" }, …]

list_workspaces()

List the workspaces (shared team spaces) your account belongs to, as { id, name, role, memberCount } entries. role is your role in that workspace — only owner / admin / editor can add projects. Use the id as the workspaceId argument to create_project, move_project_to_workspace, and list_projects; refer to the workspace by its name when talking to the user. Returns an empty list for an identity with no email (workspace membership is email-based — an API key inherits its account's email). Call this to discover a workspace before placing a project into it.

// args
{}
// → [{ "id": "…workspace-uuid…", "name": "Acme", "role": "editor", "memberCount": 4 }, …]

create_workspace(name)

Create a new workspace — a shared team space — owned by you, and get back { workspaceId, name, role, memberCount }. Use it when the user wants a new place to group projects; move_project_to_workspace then files projects into it, and create_project can place new ones there directly. You become its owner. Needs an account with an email (workspace membership is email-based), so a keyless identity can't create one. Fails if you already own a workspace with the same name — almost always a repeated request rather than a genuine second workspace. Refer to it by name when talking to the user, never the id.

// args
{ "name": "Acme" }
// → { "workspaceId": "…workspace-uuid…", "name": "Acme", "role": "owner", "memberCount": 1 }

rename_workspace(workspaceId, name)

Rename an existing workspace. Needs an owner or admin role in it — the same roles that may change its membership; an editor or viewer is refused. Takes the id from list_workspaces. Touches only the label: membership, projects and roles are unchanged.

// args
{ "workspaceId": "…workspace-uuid…", "name": "Acme Studio" }
// → { "workspaceId": "…workspace-uuid…", "name": "Acme Studio" }

open_project(projectId)

Get a tappable link that opens a project directly in the editor — returns { name, editorUrl }. Use it whenever the user wants to see their work ("show me", "open it") and after a meaningful change: give them the editorUrl and invite them to tap it. Refer to the project by its name; the link is the tap target.

{ "projectId": "spring-promo" }
// → { "name": "Spring promo", "editorUrl": "https://morphareels.ai/app?project=spring-promo" }

create_project(fromProjectId?, name?, workspaceId?)

Create a new project and get back its server-minted id. The id is an opaque v4 UUID assigned automatically — you never choose or name it; refer to the project by its name. By default this mints a fresh blank project (empty canvas, no layers) — creation is independent from cloning. Pass fromProjectId only to clone: the new project then copies that project's JSON, uploaded assets, and uploaded clips, and — like duplicate_projectfollows 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 — you need an edit-capable role there — or null to force it into your 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. (To fork an existing project, duplicate_project is the friendlier alias.) The editor doesn't auto-refresh — the user reloads to see the new project.

{ "fromProjectId": "<existing id>", "name": "Summer teaser" }
// → { "projectId": "<new uuid>", "fromProjectId": "<existing id>", "workspaceId": null, "editorUrl": "…", … }

// create it straight into a workspace:
{ "name": "Team reel", "workspaceId": "…workspace-uuid…" }

move_project_to_workspace(projectId, workspaceId)

Move a project into a workspace, or back to your 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. Needs 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.

{ "projectId": "…uuid…", "workspaceId": "…workspace-uuid…" }
// → { "projectId": "…uuid…", "workspaceId": "…workspace-uuid…", "name": "Spring promo", "editorUrl": "…" }

// detach back to personal:
{ "projectId": "…uuid…", "workspaceId": null }

list_collection()

List the user's reusable Collection — layers they added with `add_to_collection` (lower-thirds, logo stings, brand intros, or any single layer). Takes no arguments. Each item's 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); the sourceProjectId + elementId feed add_from_collection. Works on a solo account — personal items are always included.

{}
// → { "items": [
//     { "scope": "personal", "workspaceId": null, "sourceProjectId": "…",
//       "sourceProjectName": "Brand kit", "ownerEmail": "sam@acme.com",
//       "elementId": "group.a1b2c3", "name": "Lower third — coral",
//       "kind": "group", "childCount": 4 } ] }

add_from_collection(projectId, sourceProjectId, elementId)

Drop a Collection item 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) and its image/clip/font bytes into the destination at fresh ids and its original canvas position — fully detached, so it's immediately yours to edit and nothing links back to the source (changing or deleting 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.

It arrives anchored at frame 0. The item's timeline position came from the project it was collected in, where it may have sat at any frame; that position means nothing here, so the whole subtree is shifted to start at 0 — as one rigid body, so a group whose children are staggered keeps that stagger. Move it with shift_group (a group, contents and all) or set_layer_block (a leaf). A copy dropped through the editor's own Collection panel is placed at the user's playhead instead, because there is a playhead there to place it at.

{ "projectId": "…dest-uuid…", "sourceProjectId": "…brand-kit-uuid…", "elementId": "group.a1b2c3" }
// → { "addedElementId": "group.9f8e7d", "count": 1 }

duplicate_project(projectId, name?, workspaceId?)

Fork an existing project into a brand-new copy — a friendlier alias for create_project with fromProjectId. Pass the source projectId; the copy is minted with a fresh id automatically (ids are opaque — you never name them). Copies the project JSON plus its uploaded assets and clips. 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 to place the copy in a specific workspace (you need an edit-capable role there), or null to force it 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 editor doesn't auto-refresh — the user reloads to see the copy.

Don't reach for this to make versions of one video (hooks to test, languages, one per spreadsheet row): those are pages of one project, made with `add_page` and duplicate_index. Pages have no limit and share the project's assets; an account caps how many projects it holds, and every copy made here duplicates the assets and clips.

{ "projectId": "spring-promo", "name": "Spring promo — remix" }
// Clones "spring-promo" into a new project with a fresh id.

rename_project(projectId, name)

Update a project's human-readable name (the picker label). An empty string clears the name and reverts to the id fallback. Doesn't touch layers, animations, or styles.

{ "projectId": "summer-teaser", "name": "Summer teaser — v2" }

save_version(projectId, name?)

Freeze the current project state as a named version the user can flick back to. Call this once after each meaningful change-set — it's how the user compares and rolls back. Use a short imperative-mood label; the user sees it verbatim. One version per logical change-set, never one per tool call.

{ "projectId": "summer-teaser", "name": "add 1s fade-in to title" }

list_versions(projectId)

Enumerate every saved version newest-first as summaries — id, name, timestamp, source, kind, version_number, cp. Read-only; the inner project payload is not returned. kind is bookmark (a deliberate save, gets a stable v<N> number) or auto (an editor auto-snapshot, restore-only). cp names which pages the version changed, as comma-joined 8-character page-id prefixes ("*" means only the page order changed); when absent the changed pages are unknown — treat the version as touching every page.

{ "projectId": "summer-teaser" }

restore_version(projectId, versionId, page_index?)

Load a saved version over the live project. Accepts a UUID id or the v<N> shorthand (bookmarks only for v<N>).

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 in the version list.

With page_index (0-based, same indexing as select_page / delete_page) it restores only that page: the page is matched across versions by its stable id and replaced verbatim — canvas size and page name included — while every other page is left untouched, and the write is compare-and-swapped against concurrent edits. Errors if the version predates that page (no matching id in the snapshot); restore the whole version instead. A restored page may reference assets deleted since the snapshot was taken; those render as missing.

{ "projectId": "summer-teaser", "versionId": "v3" }              // whole project
{ "projectId": "summer-teaser", "versionId": "v3", "page_index": 1 }  // one page

rename_version(projectId, versionId, name)

Relabel a saved version's display name. The v<N> identifier is unaffected — only the picker label changes. Empty names are rejected.

{ "projectId": "summer-teaser", "versionId": "v3", "name": "before clip swap" }

delete_version(projectId, versionId)

Permanently delete one saved version. The v<N> sequence keeps gaps — numbers never re-shuffle — so any external snippet pinned to the deleted v<N> stops resolving. Idempotent.

{ "projectId": "summer-teaser", "versionId": "v3" }

reid_project(projectId, newId?)

Change a project's id losslessly — re-keys its JSON, every saved version, and all assets and clips, then deletes the old id. Unlike create_project used as a clone, version history survives.

The new id is always a v4 UUID: pass newId to choose one, or omit it to mint a fresh one. A project's name is the only user-facing label and is untouched, so the picker and editor look unchanged. The editor won't auto-refresh — reload to pick up the new id in URLs.

{ "projectId": "3f1c…", "newId": "9b74…" }

delete_project(projectId)

Permanently wipe a project's JSON, versions, uploaded assets, and clips. Requires edit access — the owner, a workspace editor/admin/owner, or a direct-share editor. Deleting the last remaining project is allowed. The editor doesn't auto-refresh.

{ "projectId": "old-draft" }

Identity and lookup

describe_video()

Return a cheap structural overview of the composition — canvas size, duration, 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), an animated list naming which properties have a track, and the mask relation where one exists (mask on a host names the source element whose alpha clips it; mask_for on a consumed source names its host). It deliberately omits keyframe values and styles — those are unbounded. Free, no mutation. Call this first in every session so you address real ids instead of guessing; then inspect_layers for detail.

Each video node also carries its trim window (source_in_frame / source_out_frame / timeline_start_frame) and its `lane_id` — the track (lane) it belongs to. Clips sharing a lane_id are one visual track laid end-to-end — the pieces of a single take produced by a razor split or by cut_range. A lane is a time track, not a z-order group: split pieces keep their independent place in the tree, so the lanes summary is the only signal that ties them together. The top-level `lanes` array buckets the clips of every lane that holds 2+ clips ({ lane_id, clips: [elementId, …] }, clips ordered by timeline_start_frame); it's empty when every clip is its own lane (the common case, where each node's inline lane_id already says which lane it is).

On a multi-page project the overview describes the active page (its tree, duration, backdrop) and the data additionally carries a pages block — { page_count, active_index, pages: [{ index, name, has_video }], note } — so you can see the other pages exist. Pages are addressed by index; page ids are internal and never surfaced. See Pages.

`platform_safe_area` is the part of the canvas that stays visible on TikTok, Instagram Reels and Instagram's feed, as { x, y, width, height, note } in canvas pixels. On a 9:16 canvas it leaves out the UI those apps draw over a full-screen video (the Reels header, TikTok's action rail, TikTok's caption block and tab bar), and on any canvas taller than 4:5 it also leaves out the bands Instagram's feed crops. On 1080×1920 it is { x: 0, y: 285, width: 940, height: 1155 }; it is null when nothing is covered or cropped (1:1, 16:9, 4:5). The editor shows people the same area through its Safe zones overlay and crop marks, which an agent never sees. Put text, captions, logos, buttons and other key content inside it; backgrounds, footage and decoration can run to the canvas edge.

{}
// → { project_id, canvas_width, canvas_height, duration_seconds,
//     platform_safe_area: { x, y, width, height, note } | null,
//     background: { elementId, name, fill }, embed_origins, loop,
//     layer_count,
//     lanes: [ { lane_id, clips: ["video.aaa111","video.bbb222"] }, ... ],
//     tree: [ { elementId, type, name, filename?, clip?,
//     text?, kind?, x, y, width, height,
//     source_in_frame?, source_out_frame?, timeline_start_frame?, lane_id?,
//     animated?: ["x","opacity"], children?: [...] }, ... ],
//     pages: { page_count, active_index,
//              pages: [ { index, name, has_video }, ... ], note } }

inspect_layers(elementIds)

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. Free, no mutation. 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.

{ "elementIds": ["text.7f3a2c", "shapes.a1b2c3"] }
// → { layers: [ { elementId, type, ...allFields, animations, color_tracks,
//     track_loops, style }, ... ], notFound?: [ ... ] }

Layers

add_image_layer(filename, x, y, width, height, block?, name?)

Add an image layer. The asset must already be uploaded: over MCP with upload_image (a public URL) or create_upload_link (a file on your disk), both below, through the editor's drag-drop, or with POST /api/upload-asset/<projectId> (raw bytes, and the display name in an X-Upload-Name header). Every upload returns { filename, name }. Pass filename exactly as returned: it is the stored file's id, which Morpha mints, and people never see it. Pass name as the layer's label, which is what the Inspector and Timeline show, without a media extension (star.png reads as star); without one the layer is labelled by an older project's own filename, or by kind, and never by a stored id. Supported formats are .png/.jpg/.jpeg/.gif/.webp/.svg; SVG layers scale crisply at any size. To duplicate an existing layer, reuse its filename; a fresh id is assigned. (x, y) is the layer centre. block is the optional timeline window — see Bounded clip or persistent overlay? below.

// `filename` and `name` are what upload_image returned.
{ "filename": "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png", "name": "star",
  "x": 540, "y": 400, "width": 120, "height": 120 }
// Adds the star, 120×120, centred near the top of a 1080×1920 canvas.
// No `block` ⇒ always present, for the whole composition.

{ "filename": "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png", "name": "star",
  "x": 540, "y": 400, "width": 120, "height": 120,
  "block": { "start": 90, "duration": 150 } }
// The same star as a 5 s CLIP, on screen from 0:03 to 0:08.

Bounded clip or persistent overlay?

add_image_layer, add_shape and add_text_layer all take the same optional block: { start, duration } (composition frames, duration ≥ 1). It decides whether the new layer is a clip or an overlay, and the two surfaces differ on purpose:

CallerblockResult
The editor (drop an image, + shape, + text)always suppliedA bounded clip: 5 s at the playhead, one shared default for every layer kind.
You — SDK / MCP / HTTPomitted (default)Always present: a persistent overlay spanning the whole composition.
You, passing blockexplicitA bounded clip in exactly the window you asked for.

Omitting block is a deliberate default, not an oversight: you have no playhead, and an agent-placed layer is usually a watermark, logo or lower-third that should hold for the whole video. Pass block when you mean a clip — or call set_layer_block afterwards, which does the same thing to an existing layer.

Three consequences worth knowing:

exactly as the editor's own layers are — see set_layer_transition below. Omit block and it gets none, because an always-present layer has no edges to transition at.

of its animation is the frame it appears), so you don't re-time an animation when you move the block.

end of a block grows the block to cover it, rather than leaving the layer culled with the keyframe unreachable. Shrink it back with set_layer_block if you actually wanted the tail hidden.

set_layer_transition(elementId, edge, kind, frames?, curve?, direction?)

Set how a layer enters at the start of its on-timeline window and leaves at the end, instead of popping on and off.

{ "elementId": "text.t1", "edge": "both", "kind": "fade", "frames": 8 }
// → { "transition_in": {...}, "transition_out": {...} }

{ "elementId": "shapes.s3", "edge": "in", "kind": "slide", "direction": "up", "frames": 10 }
{ "elementId": "video.v1", "edge": "out", "kind": "fade", "frames": 15 }
{ "elementId": "image.i2", "edge": "both", "kind": "cut" }   // back to a hard edge

(scales up from 80% with an overshoot), or "cut" to clear the edge back to a hard cut (shown as None in the editor's Animation controls).

from; on the out edge, where it goes to.

Prefer this over `fade_layer` and `apply_preset` for entrances and exits. Those write opacity keyframes at absolute frames: they overwrite whatever opacity the layer already had, they leave dots in the timeline lanes, and they do not move when the edge moves — trim the clip and the fade is stranded mid-shot. A transition stores only a length and a look, resolved against the layer's window at render time, so it rides every later trim, slide and (for a welded caption) clip retime.

Two more things worth knowing:

hold both ramps they're squeezed proportionally at render time and the stored values are left intact — so trimming a layer right down and back out restores the transition you asked for.

same on every surface, whether you passed the block or the editor minted one from its playhead. A layer with no window is always-present, has no edges, and gets nothing: a transition on it would be inert, so writing one would be JSON describing a fade that never renders. Video clips are the exception and default to "cut" even when bounded — a hard cut between two shots is the grammar of short-form video, so you turn a transition on deliberately.

existing always-present layer gives it a window but does not retroactively add fades — that would change how an already-authored project renders. Use set_layer_transition when you want them.

upload_image(url, name?)

Upload an image into the project by fetching a direct, publicly-fetchable http(s) image URL server-side. The target is that URL and nothing else: Morpha's Worker fetches it with a plain HTTP GET, following redirects and identifying itself with Morpha's User-Agent, and no third-party API is involved. Stores the .png/.jpg/.jpeg/.gif/.webp/.svg in the project's asset bucket under a new id and returns { filename, name, sizeBytes, contentType }. filename is that id: pass it to add_image_layer exactly as returned, and never show it to the person. name is what people see, the name you passed or else the URL's file name; pass it to add_image_layer as the layer's label. Two uploads under one name are two files. Buffered in Worker memory, capped at 16 MB. The URL must be a real file link, not an auth-walled share page. SVGs are scanned on upload and 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.

There is no image search on this surface. Searching a public pool runs on the caller's own side, in the editor's prompt panel or the SDK's findPublicImage, so the search quota is the caller's rather than one shared by everyone through Morpha. Pass a direct image file URL here, such as one the person has given you, and respect the licence its source states. For a file that is already on your disk, use create_upload_link (below) instead.

{ "url": "https://example.com/logo.png", "name": "Company logo.png" }
// → { "filename": "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png", "name": "Company logo.png",
//     "sizeBytes": 24813, "contentType": "image/png" }
// Then add_image_layer with that `filename`, and `name` as the label.

upload_audio(url, name?)

Upload an audio track into the project by fetching a direct, publicly-fetchable http(s) audio URL server-side — the audio counterpart of upload_image, fetched the same way: a plain HTTP GET from Morpha's Worker, following redirects, with no third-party API involved. This is the only way to get audio bytes into a project over MCP/HTTP (add_audio_overlay / update_audio_overlay only reference a file that is already uploaded). Stores the .mp3/.m4a/.wav/.ogg/.aac in the project's asset bucket under a new id and returns { filename, name, sizeBytes, contentType }. Then pass filename (the 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 in describe_video's audio_overlays), with name as the track's label. Buffered in Worker memory, capped at 50 MB.

{ "url": "https://example.com/theme.mp3", "name": "Theme.mp3" }
// → { "filename": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f.mp3", "name": "Theme.mp3",
//     "sizeBytes": 512044, "contentType": "audio/mpeg" }

create_upload_link(name)

Put a file from your own disk into the project: an image (.png/.jpg/.jpeg/.gif/.webp/.svg), an audio track (.mp3/.m4a/.wav/.ogg/.aac) or a font (.woff2/.woff/.ttf/.otf). A tool call carries JSON, and an MCP client owns its connection's headers, so an agent can neither pass the bytes in an argument nor call the raw asset route with a key it never sees. This tool mints a signed link instead. name is the file's own name or path on disk, with its extension: command uploads that path, and people see its last segment. Run the returned command from a shell, and the file lands in the project's asset bucket through the same checks as an editor drag-drop (size cap, SVG safety, storage quota). Then pass the returned filename to add_image_layer, add_audio_overlay or set_custom_font, and name as the layer's or track's label.

The link takes one file, stored under the id it was minted with, for 15 minutes; a second upload in that window replaces the first. Send the file's raw bytes with PUT (what curl -T does) or POST; a multipart form is refused. Image and font files are capped at 16 MB, audio at 50 MB. A client with no shell passes a public URL to upload_image or upload_audio instead. A video clip is not taken here, because its preview proxy is built on your machine: use the SDK's client.addVideo (below).

{ "name": "hero.jpg" }
// → { "uploadUrl": "https://morphareels.ai/upload-link/eyJ…",
//     "filename": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d.jpg", "name": "hero.jpg",
//     "contentType": "image/jpeg", "maxBytes": 16000000, "expiresAt": "2026-09-16T10:15:00.000Z",
//     "command": "curl -sS --fail-with-body -T 'hero.jpg' 'https://morphareels.ai/upload-link/eyJ…'" }
curl -sS --fail-with-body -T 'hero.jpg' 'https://morphareels.ai/upload-link/eyJ…'

Then add_image_layer with the filename the tool returned (the id, not hero.jpg) and "name": "hero". The stored filename is Morpha's, whatever your file is called, so two files that share a name never overwrite each other.

Adding a video clip — via the npm client

There is no clip-upload tool on the MCP / HTTP surface. A raw upload would leave the clip unprocessed (no proxy, transcript, OCR, objects), so clip ingest lives in the morphareels-sdk npm client, which uploads and processes in one call:

import { createClient } from "morphareels-sdk";
const morpha = createClient({ token: process.env.MORPHA_API_KEY });

// Upload + process (drives local Chrome). Then add the layer where you want it.
const { filename } = await morpha.addVideo(projectId, { url: "https://example.com/clip.mp4" });
await morpha.callTool(projectId, "add_video_layer", { clip: filename, x: 540, y: 960, width: 1080, height: 1920 });

addVideo accepts { url } (worker-fetched) or { file } (a local path; small clips upload via presign→PUT→finalize, large clips via chunked multipart so a big file on a slow uplink won't time out; needs durationSeconds). It returns the stored filename plus a processing outcome ({ ok, steps, reasons?, error? }). The preview proxy is mandatory: every processing run builds it, steps cannot name it, and processing.ok is true only once it landed (the reason is in processing.error when it did not). Pass { steps: ["transcript", "audio_split"] } for the fast caption path — it skips the slow per-frame OCR pass (irrelevant for talking-head captioning) so the transcript lands in seconds, and the proxy still builds. To process a clip that's already uploaded, use processClip(projectId, clip) / processProject(projectId) (same steps); check state any time with clip_processing_status. (If an agent still calls upload_clip over MCP/HTTP, it gets a clear error pointing at client.addVideo.) Whatever route a clip arrived by, the editor also builds any missing proxy for every page's clip when the project is opened, before its own captioning pass.

add_video_layer(clip, x, y, width, height, name?)

Add a video layer. The clip must already be uploaded: add it first with the morphareels-sdk npm client (client.addVideo, above) or the editor's "+ Add video" button. clip is the stored filename the upload returned (an id Morpha minted, never shown to a person); name is the label the Inspector and Timeline show. The layer renders the source mp4 into its box; audio mixes into preview and export.

{ "clip": "5e4f3a2b-1c0d-4e9f-8a7b-6c5d4e3f2a1b.mp4", "name": "main clip",
  "x": 540, "y": 960, "width": 1080, "height": 1920 }
// Adds the uploaded clip as a full-canvas video layer labelled "main clip".

add_shape(kind, x?, y?, width?, height?, color?, block?)

Add a shape layer. kind is one of the native vector primitives — basic: rect, rounded-rect, ellipse, triangle, diamond; geometric: parallelogram, trapezoid, semicircle, ring, pill, cross; polygons: pentagon, hexagon, heptagon, octagon; stars: star, star-4, star-6, sparkle, burst; arrows: arrow, arrow-left, double-arrow, chevron, block-arrow-up, curve; symbols: heart, lightning, speech-bubble, location-pin, checkmark, x-mark, shield, cloud, crescent, teardrop, banner. All share the same fill / border / shadow pipeline. If x/y/width/height are omitted the shape is placed in the canvas centre. color is a #rrggbb fill. block is the optional timeline window — omit it for a persistent overlay, pass it for a clip (see Bounded clip or persistent overlay?).

{ "kind": "star", "x": 540, "y": 960, "width": 200, "height": 200, "color": "#FF7A66" }
// Adds a coral star at canvas centre.

duplicate_layer(elementId, count, dx?, dy?, d_rotation?, d_scale?)

Composition primitive: clone a leaf count times with 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 for rows, rings, grids, and fractals. Styles are copied; the clones get fresh ids.

{ "elementId": "shapes.chevron", "count": 11, "dx": 90 }
// A marching row of 12 chevrons, 90px apart.

add_curve(x1, y1, x2, y2, bend?, color?, stroke_width?, arrow_head?)

Draw an editable arrow / curved line — a stroked quadratic bezier with an arrowhead. (x1, y1)(x2, y2) are canvas px; bend bows the midpoint perpendicular to the line (0 = straight, sign picks the side). arrow_head is none, end (default), or both.

{ "x1": 200, "y1": 1500, "x2": 540, "y2": 1100, "bend": -120, "color": "#FF7A66", "stroke_width": 14 }
// A coral arrow swooping up toward the canvas centre.

remove_layer(elementId)

Delete a video, image, text, or shape layer. Not for groups — call ungroup_layers instead. Removing a leaf is permanent (use a version as a safety net). Deleting a video clip also deletes everything welded to it: its welded audio overlay and its welded caption lines (both derive their timing from the clip's trim, so they have no meaning without it).

{ "elementId": "shapes.bg_glow" }

move_layer(elementId, x?, y?, width?, height?, rotation?, scale?, opacity?, clear_animation?)

Patch a layer's static base transform. Works on video/image/text/shapes. For group.<id>, x/y set the pivot (no width/height/rotation — use add_keyframe for group rotation). Note: if a property has a keyframe track, the track overrides this static value — move_layer sets the un-animated default.

scale and opacity are part of that base on every layer kind, groups included. Because a track beats the base at every frame, setting either one on a layer where that property is animated is refused — a value nothing paints is worse than an error. Change it at a frame with add_keyframe, or pass clear_animation: true to replace the animation with the static value.

{ "elementId": "image.logo", "x": 540, "y": 200, "rotation": 0 }

Which space is `x`/`y` in? The element's own frame. For a layer at the top level that is canvas coordinates (1080 × 1920) — the common case, and what most examples show. For a layer inside a group it is the group's space, because the group's own translate / scale / rotation sits between the two: the stored x is not where the layer appears on the canvas.

Reads and writes use the same space, so the safe pattern is unchanged — read with inspect_layers, adjust, write back:

// inspect_layers on a grouped layer reports BOTH
{
  "elementId": "shapes.badge",
  "x": 400, "y": 700,            // the group's space — pass THIS back to move_layer
  "canvas_x": 812, "canvas_y": 431,   // where it actually is on the canvas
  "canvas_scale": 1.04, "canvas_rotation": 25
}

The canvas_* fields appear only for elements that have an ancestor group; at the top level the two spaces are identical and they are omitted. To place a grouped layer at a canvas position, either compare against canvas_x/canvas_y and adjust x/y by the difference the group's scale implies, or ungroup_layers first and position it at the top level.

set_pivot(elementId, anchor)

Set the rotation / scale pivot anchor on an image / video / shape / text leaf — one of the 9 standard bbox anchors. 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 anchor stuck to the same corner / edge / centre. Static (not animated). Default is c (centre).

anchor is one of: tl t tr (top row), l c r (middle row), bl b br (bottom row).

// A palm tree should sway from its base, not its centre.
{ "elementId": "image.palm-tree", "anchor": "b" }

Groups use a separate absolute pivot in canvas coords — set it via move_layer with x/y on group.<id> instead.

reorder_layer(elementId, newIndex)

Set a layer's z-order within its current parent's siblings — the root list when ungrouped, or the parent group's children[] when nested. newIndex is 0-based; 0 is the bottom of that subtree, the last index is the top.

Two kinds of layer refuse reordering because the renderer forces their position: the pinned canvas backdrop (always the bottom of the stack) and a root-level "captions" group (always the top — captions render above everything, so nothing you add or group can cover them).

{ "elementId": "image.logo", "newIndex": 0 }
// Sends image.logo to the bottom of its parent's stack.

set_layer_visible(elementId, visible)

Show or hide a layer instantly by writing a single opacity keyframe (1 or 0) at frame 0.

{ "elementId": "text.caption", "visible": false }

rename_layer(elementId, name)

Set the human-readable name of a video / image / text / shape layer — the Inspector label, and the basis for the layer's auto-derived <morpha-video> embed attribute (rename a layer to caption and its embed attribute becomes caption). Empty string clears it. For groups use rename_group.

{ "elementId": "text.t_01", "name": "caption" }

add_text_layer(text, x?, y?, width?, height?, font_family?, text_size?, text_color?, font_weight?, font_style?, text_transform?, letter_spacing?, curve?, line_height?, text_align?, text_autofit?, text_valign?, stroke_width?, stroke_color?, text_shadow?, decorations?, block?)

Create a new text layer — a first-class leaf that animates, groups, and z-orders 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" (the brand font; pass any Google Fonts family, e.g. "Anton"), text_size derived from existing text layers (or ~10% of canvas height), text_color white. Newlines in text are hard line breaks. Returns the new text.<id>. block is the optional timeline window — omit it for a persistent overlay (a lower-third that holds), pass it for a clip (see Bounded clip or persistent overlay?).

Type styling (also accepted by set_layer_text): font_weight (100–900; 400 regular, 700 bold, 800 black), font_style ("normal"/"italic"), text_transform ("none"/"uppercase"/"lowercase"), letter_spacing (px, negative = tighter tracking), curve (bend the baseline onto an arc — degrees of total sweep; 0 straight, POSITIVE a smile ⌣ where the ends rise, NEGATIVE an arch ⌒ / rainbow, clamped ±135, ~+60 for a tasteful smile; applies to a single line — multi-line text is joined while curved and the stored text is untouched, so curve: 0 restores it), line_height (multiplier, e.g. 1.2), text_align ("left"/"center"/"right"), text_autofit ("hug" default for new layers = hold text_size fixed and DERIVE the box from the measured text plus the set_text_background padding, honouring the literal newlines you pass, so the box shrink-wraps the exact content and can never re-wrap differently between the editor preview and the export — bake your own \n line breaks; the recipe for caption chips; "wrap" = hold text_size fixed in a fixed-size box and only word-wrap, hard-breaking an over-wide word; "fit" = ignore text_size and auto-size the font both ways — grow and shrink — to the largest size whose wrapped block fills the box, so resizing the box resizes the text; "shrink" = legacy shrink-only: word-wrap then auto-shrink the font from text_size until the block fits, never grows), text_valign ("middle" default centres the block / "bottom" pins it to the box floor so wrapped lines grow upward from a fixed baseline / "top"), an outline via stroke_width (px) + stroke_color (#rrggbb), text_shadow ({ offsetX, offsetY, blur, color }, where color is any CSS colour), and decorations — per-character underline / strikethrough: { underline?: [{ start, end }], strikethrough?: [{ start, end }] }, where each is a list of half-open character ranges [start, end) (UTF-16 offsets) into text. Ranges are normalized (sorted + merged); null clears all decorations, and editing text in the SAME call re-indexes existing ranges against the new text. Not rendered on curved text. When a text layer asks for a weight a font ships no real cut for, the editor preview faux-synthesizes it (slightly wider) while the export loads the real face — another reason "hug" with baked breaks is the robust default.

{ "text": "BIG NEWS", "x": 540, "y": 480, "font_family": "Anton", "font_weight": 800, "letter_spacing": -0.5 }

set_layer_text(elementId, text?, text_size?, font_family?, text_color?, font_weight?, font_style?, text_transform?, letter_spacing?, curve?, line_height?, text_align?, text_autofit?, text_valign?, stroke_width?, stroke_color?, text_shadow?, decorations?)

Edit an existing text layer (text.<id>) — patch its content, font, size, colour, or any of the type-styling props listed under add_text_layer. Pass only the fields you want to change. Does not create layers and does not touch image layers; use add_text_layer for a new one. font_family is a Google Fonts family name. To make text mask a video/image (filled letterforms), use set_matte_source with the text layer as the matte source.

{ "elementId": "text.headline", "font_weight": 800, "stroke_width": 6, "stroke_color": "#ffffff" }

Underline just one word (offsets into text — here the "free" in "Get it free today"):

{ "elementId": "text.headline", "decorations": { "underline": [{ "start": 7, "end": 11 }] } }

add_caption_track(lines, mode?, style?, x?, y?, width?, height?, clip_element_id?)

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 visible only during its [startFrame, endFrame) window — the active-line karaoke read; "static" joins all lines into one layer. style is classic, bold-outline, or word-pop. Lines default to a lower-third band. The caption layers are always wrapped in a "captions" group so a track never clutters the layers list; the return carries groupElementId (the captions group) alongside the created text elementIds. A root-level captions group is pinned to the top of the z-stack: captions always render above every other layer, no matter what is added, grouped, or reordered afterwards.

Pass clip_element_id (a "video.<id>") to weld the lines to that clip (line-sync mode): each line's startFrame/endFrame are then read as its window in the clip's OWN source timeline (which is exactly what transcribe_clip word timings are), and its on-timeline position is derived live from the clip's trim. Trimming or sliding the clip retimes/clips the captions automatically — the same way the clip's welded audio behaves. Omit it for fixed project-frame captions.

split_caption_line(elementId, atFrame)

Split one caption line into two at a composition frame strictly inside its window (the editor's Split at the playhead uses this). 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 and the right half starts empty (the result carries a note). A welded line stays welded on both halves (the frame is converted into 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.

{ "elementId": "text.b1c0b8", "atFrame": 120 }
// "That banana's going to poop." → "That banana's" [90,120) + "going to poop." [120,150)

merge_caption_lines(elementIds)

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 from the project (and the captions group). 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 (merge it too, or move it first — the tool errors naming the blocker).

{ "elementIds": ["text.018f3b", "text.b1c0b8"] }
// Two short lines become one: "Day three on the counter. That banana's going to poop."
{ "lines": [
    { "text": "welcome back", "startFrame": 0, "endFrame": 24 },
    { "text": "three quick tips", "startFrame": 24, "endFrame": 60 }
  ], "style": "bold-outline" }

list_fonts(q?, source?, limit?)

List available font families across every source the editor knows (Google + Bunny + Fontshare + Fontsource + Velvetyne) plus the project's uploaded custom fonts (source: "custom"). Filter with q (case-insensitive substring) and/or source; cap with limit (default 50). Any returned family Just Works in font_family.

{ "q": "grotesk", "limit": 20 }

set_custom_font(family, src, weight?, style?)

Register a typeface Morpha does not ship, so text layers can reference it by family name via font_family. A family already in the built-in catalogs — anything list_fonts returns from google/bunny/fontshare/fontsource/velvetyne — is rejected: built-in families need no registration, just set font_family to the name directly (a custom_fonts duplicate would shadow the reliable built-in loader). src is a full font URL or the filename a font upload returned (create_upload_link, or the editor) — uploading is the robust path (a pasted URL only loads if its host sends permissive CORS headers). A URL is not fetched when the tool runs: Morpha's renderer and every browser that opens the project download it later, which is why the tool is marked open-world. Dedupes by family + weight + style, replacing a matching face.

// `src` is the `filename` the font's upload returned.
{ "family": "Mylius Modern", "src": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e.woff2" }

set_image_filename(elementId, filename, name?)

Repoint an existing image layer at a different uploaded asset — keeps the layer's id, position, size, animations, and styles; only the bitmap changes. Use this to swap an image without losing its keyframes (remove_layer + add_image_layer would mint a new id and drop the animations). The new asset must already be uploaded: pass the filename its upload returned. Uploading again under the same name makes a new file, never a replacement, so this is how a layer's picture changes. Pass the upload's name too, and the layer is labelled after its new picture; leave it out to keep the current label, or pass null to clear it.

{ "elementId": "image.headshot", "filename": "8f7e6d5c-4b3a-4291-8e7d-6c5b4a392817.png", "name": "Headshot 2026.png" }

set_video_clip(elementId, clip, name?)

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. Use this to swap a clip without losing keyframes. clip is the filename the clip's upload returned, and name, when given, relabels the layer the same way set_image_filename does.

{ "elementId": "video.main", "clip": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d.mp4", "name": "demo-final.mp4" }

set_video_layer_trim(elementId, source_in_frame?, source_out_frame?, timeline_start_frame?)

Patch a video layer's trim window. source_in_frame is the frame in the source mp4 to start at; source_out_frame is where to stop (null = the source's natural end); timeline_start_frame is where on the project timeline the slice begins. Pass only the fields you want to change. To clip out a segment, duplicate the layer first, then give each copy a disjoint source window.

{ "elementId": "video.main", "source_in_frame": 90, "source_out_frame": 300, "timeline_start_frame": 0 }
// Plays source frames 90–300, starting at the top of the timeline.

set_layer_block(elementId, start, duration)

Give a layer a 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 the root, band-local inside an embedded morpha band. A layer with no block is always-present — that's the default for everything you create headlessly (see Bounded clip or persistent overlay?); the editor's own adds always land a bounded 5 s clip at the playhead instead. Authoring a keyframe past a block's end GROWS the block to cover it, so an animation is never silently truncated; call this tool again to trim it back deliberately. To place a whole embedded reel, use move_band.

Retiming a caption line: on a welded caption line (a text layer carrying a caption_source anchor) this tool is the retime path — the requested composition-frame window is converted back into the welded clip's source timeline and stored on the anchor, so the line stays welded and keeps following later clip trims/slides. Keep the window inside the clip's visible span (and clear of the neighbouring lines) — a caption's on-timeline window is derived by intersecting with the clip's trim, so frames outside it are clipped off. This is the same primitive the editor's caption-chip drag commits through.

{ "elementId": "text.title", "start": 90, "duration": 60 }
// The title shows from 0:03 to 0:05; any animation on it plays from frame 90.

move_band(bandId, start)

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 reel's 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 marked morpha: true).

{ "bandId": "group.702e1c", "start": 3112 }
// The embedded reel now starts (and animates in) at frame 3112 instead of at 0.

shift_group(elementId, start)

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 holds no media, so it has no window of its own. On the timeline it spans the hull of its contents, and moving it slides the whole subtree as one rigid body — children keep their spacing, and their animations keep their relative timing.

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 (which is what makes it safe to drive from a drag). The move stops when the earliest thing inside reaches frame 0 rather than squashing children together.

Two things deliberately don't move: welded caption lines (they follow their clip's speech, not this group) and the inside of an embedded morpha band (the band's own block is its subtree's time origin, so the band moves as one unit).

Fails when the group is empty, or when something inside it is always-present — there is no bounded window to move. Give that child a block first, or set one on the group.

{ "elementId": "group.ee158f", "start": 55 }
// → { elementId, start: 55, delta: 30, moved: 3 }
// Was spanning 25–195; now spans 55–225, with every child shifted +30.

To clip what's shown of a group instead of moving it, use set_group_window. For a single layer, use set_layer_block.

set_group_window(elementId, start, duration)

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 it's how you hide the head or tail of a whole section. Writing a window overrides the group's derived contents-hull from then on (drop it again with the editor's "Span whole video", or widen it back here).

Two safety corrections apply automatically, because a block both gates visibility and re-bases the layer's own keyframes to its start:

{ "elementId": "group.ee158f", "start": 73, "duration": 147 }
// → { block: { start: 73, duration: 147 }, keyframesCompensatedBy: -73, grownToCoverKeyframes: false }
// The group is drawn 73–220; its children stay exactly where they were.

set_video_layer_muted(elementId, muted)

Mute or unmute a video layer's baked audio (silenced in both preview and export). Splitting a clip's audio into its own track (the editor's Split audio action, or the SDK's audio_split step) sets this true, so the source audio doesn't double with the overlay. Pass muted: false to restore the baked audio.

{ "elementId": "video.main", "muted": true }

set_matte_source(elementId, matte_source_id, matte_inverted?)

Set or clear a mask (track matte). When matte_source_id is set, that element's alpha channel is multiplied onto the host layer at paint time — the host shows only where the mask source is opaque (After Effects "Alpha Matte"). The host can be a leaf (image/video/shapes/text) or a group (a group host clips all its children to a shape source's path); a leaf host takes any leaf source. Pass null to clear. Use a text.<id> as the source for video- or image-filled letterforms (set_matte_source("video.main", "text.title")).

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 and follows the host's: attaching drops the source's block and edge transitions, re-anchoring its keyframes to absolute frames, and the result reports source_window_dropped. The source's position, scale, rotation and keyframes shape the mask; the host's own opacity, blend mode and border are all inside the mask.

Pass matte_inverted: true to invert the mask (knock-out): the host shows everywhere except where the source is opaque — a spotlight / punch-through. Honored on leaf hosts; ignored on group hosts. Omitted leaves the current flag unchanged; clearing the mask resets it.

{ "elementId": "video.main", "matte_source_id": "shapes.circle_mask" }
// The video shows only inside the circle shape's silhouette.

{ "elementId": "image.photo", "matte_source_id": "shapes.circle", "matte_inverted": true }
// Knock-out: the photo shows everywhere EXCEPT the circle — a punched hole.

Groups

A group holds an ordered children[] and composes a translate/scale/rotate/opacity transform onto every descendant. Its pivot is frozen at the children's bounding-box centre at create time. Groups can nest.

group_layers(elementIds, name?)

Wrap sibling elements in a new group. Every listed id must currently share the same parent (the root, or one existing group). The pivot seeds to the children's centroid. The group's x/y track values become translation offsets around that pivot — groups have no static body of their own.

{ "elementIds": ["text.headline", "text.subhead", "image.logo"], "name": "header" }

ungroup_layers(groupId)

Dissolve a group: its children splice 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. Takes the bare group id.

{ "groupId": "header" }

set_group_parent(elementId, parentGroupId, index?)

Move an element into a group, or out to the root with parentGroupId: null. elementId is the full element id; parentGroupId is a bare group id (or null). index is the 0-based insert position among the new parent's children (defaults to the end). Refuses to nest a group inside its own descendants.

{ "elementId": "shapes.badge", "parentGroupId": "header", "index": 0 }

rename_group(groupId, name)

Rename a group — purely cosmetic; the label shows in the Inspector and describe_video. Takes the bare group id.

{ "groupId": "header", "name": "title block" }

set_group_box(elementId, box_width, box_height)

Set a group's backdrop rect size. The rect is centred on the group's pivot in group-local space and transforms with the group. Either dimension at 0 hides the backdrop entirely. Pair with set_layer_fill on the group.<id> to colour it.

{ "elementId": "group.header", "box_width": 900, "box_height": 360 }

add_to_collection(elementId) · remove_from_collection(elementId)

Add (or remove) a layer to the user's reusable Collection — a personal library of building blocks (lower-thirds, logo stings, brand intros) that can be dropped into any project. Pass any element id: a leaf (text.<id>, image.<id>, …) or a whole group.<id>. Once added it shows in the Collection (list_collection), where the user — and, if this project is in a workspace, every teammate — can place a self-contained copy into another project (add_from_collection). Copies are immutable: adding copies the whole subtree plus its asset bytes, so editing or deleting this source never changes a copy already placed elsewhere. Works on solo projects too. Name the layer first (rename_layer / rename_group) — that name is what shows in the Collection. remove_from_collection stops offering it; copies already placed are untouched.

{ "elementId": "group.lower-third" }

add_morpha_layer(source_morpha_id, version?)

Embed another of your projects ("a morpha") inside this one as a version-pinned band. The source project's layers are inlined into the host as a collapsible group, re-keyed to fresh ids and pinned to one immutable version of the source — so editing the source later never changes this video until you update the pin. Pass the source project's id; optionally pin a specific version label (e.g. "v3"), else the latest saved version is pinned. The server resolves and inlines the pinned snapshot — you only pass the id. A morpha cannot embed itself.

{ "source_morpha_id": "8f1c…", "version": "v3" }

Animation

All frame arguments are 0-indexed; 30 fps. Tracks override the static value at every frame.

add_keyframe(elementId, property, frame, value, easing?)

Add or overwrite a keyframe on an animation track. property is one of x, y, width, height, scale, rotation, opacity, curve. For leaves, x/y/rotation are absolute canvas-space values; for groups, x/y are translation offsets around the pivot and rotation is the group's absolute angle. scale orbits the layer/pivot centre (1 = no change), opacity is 0..1. curve (text layers only) is the arc-baseline angle in degrees — keyframe it 060 to bend a title into a smile over time (±135; positive smile ⌣, negative arch ⌒). easing is the interpolation to the next keyframe — linear, easeIn, easeOut, easeInOut, outQuart, outExpo, outBack, inBack, inOutBack, cubicBezier, or hold (a step function — the value holds flat and jumps only when the playhead crosses this keyframe).

{ "elementId": "image.logo", "property": "rotation", "frame": 60, "value": 360, "easing": "easeInOut" }
// With a rotation=0 keyframe at frame 0, the logo spins once over 2 seconds.

add_keyframes(elementId, property, keyframes, loop?)

Add many keyframes to ONE element's ONE property in a single call, with an optional track-loop mode folded in — the idiomatic form when every layer in a multi-element animation gets its own track. Each entry is { frame, value, easing? }.

{ "elementId": "shapes.dot", "property": "scale",
  "keyframes": [ { "frame": 0, "value": 1 }, { "frame": 15, "value": 1.4, "easing": "easeOut" }, { "frame": 30, "value": 1 } ],
  "loop": "loop" }
// An endless pulse — no separate add_keyframe + set_track_loop calls.

set_keyframes_batch(keyframes)

Add or overwrite many keyframes across MANY layers in one atomic call — each entry has the same fields as add_keyframe plus its elementId. Any invalid entry rejects the whole batch. Reach for this whenever you'd otherwise call add_keyframe more than a couple of times (rippling grids, staggered reveals).

{ "keyframes": [
    { "elementId": "shapes.s1", "property": "opacity", "frame": 0,  "value": 0 },
    { "elementId": "shapes.s1", "property": "opacity", "frame": 10, "value": 1 },
    { "elementId": "shapes.s2", "property": "opacity", "frame": 5,  "value": 0 },
    { "elementId": "shapes.s2", "property": "opacity", "frame": 15, "value": 1 }
  ] }
// A staggered two-layer fade-in in one round-trip.

remove_keyframe(elementId, property, frame)

Remove the keyframe at an exact frame. Removing the last keyframe from a track restores the layer's static base value across the timeline.

{ "elementId": "image.logo", "property": "rotation", "frame": 60 }

shift_track(elementId, property, delta)

Bulk-shift every keyframe's value on one property by delta. Keyframe times are untouched — this slides the whole curve while preserving the animation's relative shape. Mirrors "select all keyframes and nudge" in a desktop NLE. Use for "move all x by −30px", "rotate an existing wobble by 10°".

{ "elementId": "image.logo", "property": "x", "delta": -30 }
// Every x keyframe shifts 30px left; the animation's shape is unchanged.

set_track_loop(elementId, property, mode)

Set the extrapolation mode for one property's track. mode is hold (keep the boundary value), loop (wrap to the first keyframe), ping-pong (alternate direction each cycle), or cycle (wrap and add the boundary delta each cycle — endless rotation/scrolling). No effect on tracks with fewer than two keyframes.

{ "elementId": "image.logo", "property": "rotation", "mode": "cycle" }
// The logo rotates endlessly instead of stopping at the last keyframe.

fade_layer(elementId, fromFrame, toFrame, fromOpacity, toOpacity)

Convenience tool: write two opacity keyframes in one call. fromOpacity and toOpacity are 0..1.

{ "elementId": "image.title", "fromFrame": 0, "toFrame": 30, "fromOpacity": 0, "toOpacity": 1 }
// A 1-second fade-in on the title.

apply_preset(elementId, preset, startFrame?)

Apply a canned animation. preset is fade-in, fade-out, pulse, slide-in-left, slide-in-right, slide-up, shake, or pop. startFrame anchors the preset (default 0).

{ "elementId": "shapes.badge", "preset": "pop", "startFrame": 45 }

apply_preset_stagger(elementIds, preset, startFrame?, stagger?)

Apply the same preset to a LIST of layers with a per-element start offset — entry i starts at startFrame + i·stagger frames (stagger defaults to 1). Order elementIds in the order the cascade should fire. One call instead of N apply_preset calls for diagonal pop-in grids and sequential reveals.

{ "elementIds": ["shapes.s1", "shapes.s2", "shapes.s3"], "preset": "pop", "stagger": 3 }

freeze_frame(elementId, frame, image, holdFrames?)

Freeze the picture at frame — the NLE "frame hold". The clip is cut there, a still of that frame is inserted between the halves, and everything after it moves later. Play, freeze, continue.

{ "elementId": "video.main", "frame": 90, "image": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a.png" }
// -> video.main [0,90) | the still [90,240) | the remainder [240,…)

frame must be strictly inside the clip. (A clip with a null source_out_frame is treated as unbounded on the right — its real length would need the decoded source — so only the left edge is checked, the same convention as cut_range.) holdFrames defaults to 150 (5 s at 30 fps) — the same default every added clip gets.

image is the filename of a PNG of that frame, already uploaded to the project's assets — rendering one needs a browser, so the editor captures and uploads before calling this (the same split add_image_layer uses: neither verifies the bytes exist). The returned frozenSourceFrame tells you which SOURCE frame was frozen, which is not the same as frame on a retimed clip.

The still is an ordinary image layer with a block for its length: resize, split, move, delete or animate it exactly like any other layer. Nothing has to know that stills exist — which is the point, because a clip's own geometry is maintained through its trim, and giving one a second notion of length desynchronises the moment anything moves it.

The cut itself is the same razor as the editor's Split, so everything a split carries, a freeze carries: a speed-ramped clip's curve is resampled onto the remainder, both pieces share the source clip's lane, split-out (welded) audio is re-welded to both halves, and welded caption lines past the cut follow the remainder.

set_clip_speed(elementId, speed)

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 untouched, so the clip's length on the timeline changes to suit: the same trimmed source span takes twice as many frames at 0.5 and half as many at 2. Nothing stores the retimed length — it is derived from the speed, so speed and trim compose freely and either can be changed afterwards.

{ "elementId": "video.main", "speed": 0.5 }
// A 3s clip now occupies 6s of timeline, playing in slow motion.

Notes:

add_speed_keyframe(elementId, frame, rate)

Add or overwrite a speed-ramp (time-remap) keyframe on a video layer — for a rate that changes over the clip. 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 [0.1, 8]. Adjacent speed keyframes interpolate linearly; the renderer integrates the curve to pick the source frame.

The ramp multiplies the layer's constant speed, and the clip's timeline length is derived from the resulting curve — so a clip that ramps into slow motion gets longer, just as a constant-speed one does.

frame is a project-timeline frame (the position you'd scrub to), but the curve is anchored to the CLIP, not the timeline: moving the clip carries its ramp along and never changes its duration. Reading a project back, the stored keyframe positions are relative to the clip's own start.

frame must sit on the clip — at or after its timeline_start_frame, not before it. The curve is anchored to the clip, so moving the clip carries its ramp along and never changes the clip's duration; the frames you pass and read back are project frames either way.

// A clip starting at frame 90: keyframes are project frames ON that clip.
{ "elementId": "video.main", "frame": 90, "rate": 1 }
// Paired with a rate=0.3 keyframe later, the clip ramps into slow motion.

remove_speed_keyframe(elementId, frame)

Remove the speed keyframe at frame. frame is a project-timeline frame — the same space add_speed_keyframe takes and reports, and the space inspect_layers reports them in — so the number you read back is the number that removes it. Removing the last one restores 1× playback.

{ "elementId": "video.main", "frame": 90 }

set_duration(seconds)

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); set_duration 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. There is a 1-second floor and no ceiling. This is the reliable way to shorten a comp headless (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.

{ "seconds": 15 }
// Pins the composition at 15s; content past frame 450 is kept but not played.

fit_duration_to_content()

Clear an authored length and return to auto-fit — the comp length tracks the furthest content again, with a 1s floor. The inverse of set_duration. 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.

{}

cut_range(startFrame, endFrame)

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 that share the original clip's `lane_id` (so describe_video still shows the two fragments as one track). 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). endFrame is exclusive and clamped to the comp length.

{ "startFrame": 120, "endFrame": 180 }
// Removes the 2s..3s window; everything after frame 180 slides back by 60 frames.

Fills

Every fill site — the canvas backdrop, a shape body, an image/video/group backdrop — takes the same Fill discriminated union (solid, linear, radial, mask). Wherever a fill is accepted, a "#rrggbb" hex shorthand also works and is promoted to a solid fill.

Each fill parameter declares its JSON type as ["string", "object"]: the hex shorthand or a Fill. set_layer_fill and set_text_background add "null", because their fill can clear a backdrop. add_color_keyframe's value never takes null.

set_layer_fill(elementId, fill, clear_animation?)

Set a layer's fill. For the canvas backdrop, use elementId: "background.canvas" (the literal is accepted as a synonym for the pinned background layer's id); null is rejected there. Shapes require a Fill (null rejected). Image / video / text / group layers accept a Fill or null (clears the backdrop). Shapes paint their body; image/video paint behind the bitmap; groups paint a rect sized by set_group_box.

null clears the fill and any colour keyframes animating it (add_color_keyframe on fill). A colour track wins over the static field when both exist, so leaving the track behind would keep painting a backdrop you just cleared.

Setting a fill on a layer whose fill is ANIMATED is refused. Same reason, seen from the other side: the colour track wins at every frame, so the fill you wrote would never paint and an ok would be a lie. Two ways forward, both named in the error:

want when the animation should stay;

deletes its colour keyframes.

describe_video / inspect_layers report a layer's colour keyframes, so you can tell which case you are in before you call.

// Solid colour on the canvas backdrop:
{ "elementId": "background.canvas", "fill": "#14141B" }

// A linear gradient on a shape:
{ "elementId": "shapes.panel", "fill": {
    "type": "linear", "angle": 90,
    "stops": [{ "offset": 0, "color": "#FF7A66" }, { "offset": 1, "color": "#A371F7" }]
} }

set_text_background(elementId, fill?, padding?, cornerRadius?, strokeWidth?, strokeColor?, clear_animation?)

Add or update the rounded background box behind a text layer (text.<id>) in one call — fill (the box colour: #rrggbb, a Fill, or null to remove the box), padding (px between the box edge and the text: on a "hug" layer it sizes the box around the label, and on a fixed-size layer — "wrap", "fit", "shrink" — lines wrap inside the padded area, so padding narrows where they break), cornerRadius (px), and an optional outline via strokeWidth + strokeColor. Pass only the fields you want to change; only elementId is required. New text layers are already text_autofit: "hug", so padding alone shrink-wraps the box to the text — the recipe 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, or 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 changes or the layer scales, and the user drags one layer instead of two. The one exception: the box is not painted on curved text (a straight rounded box behind a bent baseline reads as broken), so an arc-shaped chip genuinely does need a shape behind it. Text layers only; for shapes/images/video use set_layer_fill.

When the text layer's backdrop fill is animated, passing fill is refused on the same rule as set_layer_fill — pass clear_animation: true to replace the animation, or leave fill out and change the box's padding / radius / stroke on their own.

// A coral rounded chip that hugs its caption:
{ "elementId": "text.caption", "fill": "#FF7A66", "padding": 24, "cornerRadius": 16 }
// (first set text_autofit:"hug" via set_layer_text so the box shrink-wraps the text)

// A pill button — one layer, padding makes the box, stroke outlines it:
{ "elementId": "text.cta", "fill": "#101820", "padding": 32, "cornerRadius": 999, "strokeWidth": 3, "strokeColor": "#FF7A66" }

// Remove the box again:
{ "elementId": "text.caption", "fill": null }

add_color_keyframe(elementId, property, frame, value, easing?)

Add or overwrite a colour keyframe on a fill track. property is "fill" (the only key today). elementId is a leaf (shapes/image/video/group) or "background.canvas". value is a Fill or #rrggbb. Adjacent keyframes crossfade stop-by-stop. 30 fps; frame is 0-indexed.

{ "elementId": "background.canvas", "property": "fill", "frame": 0, "value": "#FAFAFC" }
// Paired with a #14141B keyframe at frame 60, the backdrop fades to dark.

remove_color_keyframe(elementId, property, frame)

Remove the colour keyframe at an exact frame on a fill track. No-op when there's no track or no matching keyframe. Removing the last keyframe drops the track.

{ "elementId": "background.canvas", "property": "fill", "frame": 60 }

Style

set_style(elementId, ...patch)

Set style fields on a layer with a multi-field patch — only the fields you pass are changed. Available fields:

FieldApplies toPurpose
borderRadius (px)allRounded corners.
borderWidth (px) + borderColor (#rrggbb)allStroke.
borderAlign (inner \center \outer)image, video, textWhere the border sits relative to the edge. inner (default) draws it 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. Shapes ignore it (their stroke is always centred on the silhouette).
boxShadow (CSS shadow string)alle.g. "0 4px 12px rgba(0,0,0,0.5)". Pass "" (empty string) or null to remove it — not the string "null".
fit (stretch \cover \contain)image, videoObject-fit. Default stretch for images, cover for video.
anchorX, anchorY (0..1)image, videoObject-position under cover/contain. 0 = left/top, 1 = right/bottom, 0.5 = centre. Ignored under stretch.
tintColor (#rrggbb) + tintStrength (0..1)imageSource-atop colour overlay. 0 = none, 1 = silhouette filled with the tint.
alphaMaskimageLinear alpha-mask gradient — multiplies the layer's alpha along a gradient line. Pass null to clear.
blend_modeall, groups includedPhotoshop-style layer blend mode: normal (default), multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity. On a group it blends the whole composited subtree onto the parent canvas.
chroma_keyimage, videoGreen-screen removal. An object { color, similarity, smoothness }: color is the key colour as #rrggbb (default #00ff00), similarity (0..1, default 0.4) widens the matched colour range, and smoothness (0..1, default 0.1) softens the alpha falloff at the edge.

On a group.<id>, set_style accepts blend_mode and nothing else: a group has no styled body, but it does have a composite that blends onto the parent canvas. Every other field is rejected. Image-only fields land in the JSON but are ignored by the renderer on shapes (and tintColor/tintStrength on videos).

{ "elementId": "image.headshot", "borderRadius": 24, "borderWidth": 4,
  "borderColor": "#FF7A66", "borderAlign": "outer",
  "fit": "cover", "anchorX": 0.5, "anchorY": 0.3 }

The alphaMask object is { type: "linear", angle: number, stops: [{ offset, alpha }, …] }angle is CSS-style degrees (0 = to top, 90 = to right, 180 = to bottom, 270 = to left), at least two stops ordered by offset:

{ "elementId": "image.frontHalf", "alphaMask": {
    "type": "linear", "angle": 180,
    "stops": [{ "offset": 0, "alpha": 1 }, { "offset": 1, "alpha": 0 }]
} }
// Fades the layer's bottom edge to transparent.

Project-level

Composition length is derived from content by default. The timeline (and the export) length auto-fits the latest content — the furthest video-window end, keyframe, or audio-overlay end, with a 1-second floor — so trimming a layer or moving a keyframe re-fits the composition automatically. To pin an explicit length instead, call `set_duration(seconds)` (fit_duration_to_content() releases the pin); to ripple-delete a time window and close the gap, call `cut_range(startFrame, endFrame)`. All three live under Animation.

set_canvas_size(width, height)

Resize the active page's canvas with a fit + recenter reflow. The composition is scaled by a single uniform 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 and size, group pivots, and x/y/width/height keyframes follow this fit+recentre. A same-aspect resize scales exactly; an aspect change letterboxes the content, centred. Each page owns its size, so this leaves sibling pages untouched — `select_page` then call this 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).

{ "width": 1080, "height": 1080 }
// Converts a 9:16 project to square; the composition shrinks to fit and recentres.

set_loop(elementId, field?, values)

Set the project's loop section: the whole composition repeats once per value, with one field of one layer varying across the repeats. Each pass sets field of elementId to that value — e.g. a caption text layer cycling through several strings. field defaults to "text". An empty values array clears the loop (the comp plays once).

{ "elementId": "text.caption", "field": "text",
  "values": ["First tip", "Second tip", "Third tip"] }
// The comp plays three times, the caption text changing each pass.

Pages

A project is an ordered list of pages (always at least one). A single-page project is a plain video (one exported MP4); a multi-page project is a "carousel" — each page exports as its own file (a page with any video_layers → MP4, a still page → PNG, and a multi-page project downloads the ordered files zipped). There is no `mode` flag and no separate carousel record — every project is just pages. Each page is a FULL composition (video OR image, the same shape the editor edits) with its own `canvas_width`/`canvas_height`, so one project can hold a 1080×1920 story, a 1080×1080 square, and a 1920×1080 landscape cut side by side. Turn a single-page project into a multi-page one by calling `add_page`; there is no mode-switch.

How the catalog targets pages. Every content tool — layers, keyframes, fills, styles, groups, inspect_layers, … — operates on the active page (active_index), exactly as if that page were the open composition; on success the edit is folded back into that page. `describe_video` describes the active page and, for a multi-page project, adds a pages block listing every page with its own canvas_width/canvas_height. The page tools below switch the active page and manage the page list itself, and `set_canvas_size` resizes only the active page — check the pages block rather than assuming the deck is uniform. Pages are addressed by index (0-based); page ids are internal and never surfaced. To edit a page other than the active one, call `select_page` — the content tools then target that page (add_page also selects the page it creates, and the user can switch pages in the editor's pager).

add_page(name?, duplicate_index?)

Append a page to the project. Works on any project — a single-page video becomes multi-page. Without duplicate_index, appends a blank page sized to the active page's canvas. With duplicate_index, deep-copies the page at that index (a fresh id is minted, and the copy keeps that page's size). There is no limit on page count. The new page becomes the active page; its index is returned as index.

Versions of one video are pages. For several versions to post or test (different hooks, languages, one per spreadsheet row), duplicate a page with duplicate_index rather than making a project each, then select_page each copy, read its ids with describe_video and edit it. Exporting a multi-page project gives one video per page: the editor's Videos export, or the SDK's renderVideo({ page }) in a loop. Pages have no limit, while an account caps how many projects it holds. See example 9.

{ "duplicate_index": 0, "name": "Intro copy" }
// Copies page 1 as a new page named "Intro copy" and selects it.

delete_page(index)

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).

{ "index": 2 }
// Removes the third page.

reorder_pages(from_index, to_index)

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.

{ "from_index": 3, "to_index": 0 }
// Moves the fourth page to the front.

select_page(index)

Switch which page is active — the page the content tools target. Subsequent `describe_video` / `inspect_layers` / all content tools read and write the selected 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 succeeds and changes nothing; fails on an out-of-range index. Returns { index, page_count, name }.

{ "index": 2 }
// Makes the third page active; content tools now read and write that page.

Embedding

These tools control the public <morpha-video> embed allowlist — the hostnames permitted to load a project through the public embed. The worker mirrors the list into KV after every write, so these tools actually flip the public gate (a raw storage edit wouldn't). An empty allowlist turns embedding off — the embed endpoint 404s the project. Entries match exact hostname (no wildcards) and are normalised to a bare lowercased hostname (scheme/port/path stripped). Morpha sends the listed websites no request; the list decides which outside sites may load the project, which is why all three tools are marked open-world.

What an embed serves is public, because the allowlist only stops other websites loading the project in a browser: anyone holding the project id and an allowed hostname, both visible on the partner's page, can request it. So the embed serves a saved bookmark only (pin it with version="v<N>", from save_version), never unsaved edits or automatic snapshots. The payload is the bookmark's active page with the sharing lists, Collection, workspace and version bookkeeping left out. Its images and fonts load through signed links the payload carries, so no other file in the project can be fetched. A project shared by email but with an empty allowlist is not embeddable.

set_embed_origins(origins)

Replace the whole allowlist. Pass the full desired array; it overwrites the previous list. An empty array disables embedding.

{ "origins": ["shop.example.com", "blog.example.com"] }

add_embed_origin(origin)

Add one hostname. Idempotent — re-adding an existing entry is a no-op.

{ "origin": "https://landing.example.com/promo" }
// Normalised to "landing.example.com" before it's added.

remove_embed_origin(origin)

Remove one hostname. Idempotent. Removing the last entry turns embedding off.

{ "origin": "blog.example.com" }

Audio overlays

Independent sound clips on the project, played in the editor preview and mixed into the MP4 export. Audio assets (.mp3/.m4a/.wav/.ogg/.aac) are uploaded with `upload_audio(url, name?)` or create_upload_link(name) over MCP/HTTP (or via the editor, or POST /api/upload-asset/<projectId> with raw bytes and the display name in an X-Upload-Name header). Each upload returns { filename, name }: the track references filename, and shows name. describe_video reports every track under audio_overlays with its id, so you can target one for update_audio_overlay (replace / re-gain / re-fade) or remove_audio_overlay.

add_audio_overlay(filename, startFrame, gain?, fadeInFrames?, fadeOutFrames?, endFrame?, sourceLayerId?, name?)

Schedule an audio overlay at a frame-aligned startFrame. gain is linear 0..2 (default 1); fadeInFrames / fadeOutFrames are linear envelope lengths in frames (default 0); endFrame is optional — omit it to play the asset's natural length. sourceLayerId is optional — pass a "video.<id>" element id to weld the overlay to that clip, so the editor renders it as a waveform footer on the clip and drags it with the clip instead of showing a standalone bottom row. A welded overlay's playback timing is fully derived from its clip: the audible span is the clip's trim window and the file-time origin is timeline_start_frame − source_in_frame (its stored startFrame is ignored while welded), so trimming the clip (set_video_layer_trim) retimes what's heard — in preview, export, and the derived composition length — without ever desyncing the audio. The asset must already be uploaded: filename is the stored name its upload returned, and name (optional) is the track's label, which the Timeline and Inspector show; pass the upload's name. Returns the new overlay with an auto-assigned id (e.g. audio_1).

{ "filename": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f.mp3", "name": "swoosh",
  "startFrame": 0, "gain": 0.8, "fadeInFrames": 6, "fadeOutFrames": 12 }

update_audio_overlay(id, filename?, name?, startFrame?, gain?, fadeInFrames?, fadeOutFrames?, endFrame?, sourceLayerId?, denoiseStrength?)

Patch an existing overlay — only the fields you pass change. Pass filename to replace the track's audio file (upload the new file with upload_audio first and pass the filename it returned; any AI-cleaned companion of the old file is dropped so the replacement plays as-is), and name with it to relabel the track after the new file. name: null removes a label. Pass endFrame: null to clear an explicit end and revert to natural-length playback. Pass sourceLayerId: "video.<id>" to weld the overlay to a clip (renders as a clip footer, drags with the clip). The sourceLayerId: null detach direction is deprecated — clip audio is always welded to its clip and the editor auto-welds it back on load, so a detach won't stick; to silence a clip, mute it (or its footer overlay) instead. The param is kept for compatibility. denoiseStrength (0..1, or null to clear) sets the clean-strength mix on an overlay that has an AI-cleaned track (describe_video reports hasCleanedTrack): 1 plays the fully cleaned audio, 0 the original, values between blend them — preview and export identically. It has no audible effect while the overlay is switched to the Original track or when no cleaned track exists.

{ "id": "audio_1", "gain": 1.2, "endFrame": 300 }

remove_audio_overlay(id)

Delete an audio overlay by id.

{ "id": "audio_1" }

Media analysis (OCR / transcript)

These read side-car caches produced by the browser-side processing pipeline — proxy build, audio split, OCR, and Whisper transcription all run in a browser, never on the worker. Processing runs two ways: the npm client (client.processClip / client.processProject / client.addVideo, which drives local Chrome) or a human opening the project in the editor — either route writes the same caches. A headless caller that hits a cold cache gets { "ok": true, "status": "not-ready", "data": null, "note": … } (not an error): process the clip, then retry. Call clip_processing_status to see what's done. Coordinates are in SOURCE pixels unless noted. In the SDK these have typed methods (detectTextRegions, safeZones, transcribeClip, clipProcessingStatus) that return { status, data, note? }.

detect_text_regions(clip? , image?)

Return the OCR text regions baked into a clip's frames OR a still image. Pass clip (a video.<id>.clip filename) or image (an image.<id> filename), not both. Returns { status, frames: [{ frame, time, words: [{ text, x0, y0, x1, y1, confidence }] }], videoWidth, videoHeight }. Use it to place titles / lower-thirds so they don't collide with burned-in text.

safe_zones(clip, bandHeight?, occupancyThreshold?, minConfidence?)

Distil the OCR cache into layout-ready horizontal bands: the safeBands (y-ranges with no burned-in text) and textBands (where it lives), each in both source and CANVAS pixels — canvas coords already account for the video layer's fit/anchor/position, so captions/titles can be laid out without redoing the math. 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.

transcribe_clip(clip)

Return the cached transcript of a clip's audio: { status, data: { text, word_count, words: [{ word, start, end }], vtt? } }. Runs on the audio track only, so it works on HEVC/AV1 clips the OCR pipeline can't decode. Feed words into add_caption_track for synced captions.

clip_processing_status(projectId, clip?)

Report whether a clip — or every video clip in the project, if clip is omitted — has been through the browser-side pipeline (proxy, audio split, transcription, OCR). Returns { clips: [{ clip, processed, steps: { proxy, audio_split, transcript, text_regions } }], allProcessed }, each step 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) or the SDK's audio_split step runs; it never gates processed, and an unsplit clip plays its own sound. Use it to know whether the readers above will return data, and to surface an "unprocessed" state to the user. Processing runs via the npm client (client.processClip / client.processProject / client.addVideo) or by opening the project in the editor.

Rendering

A finished MP4 is always encoded by a browser, and there are three ways to get one. Two are free and unlimited:

The third is for an agent with no browser to drive — a chat app, a Linux container — and it is the only one that costs anything. render_video renders on Morpha's own container, which runs Chrome for Testing beside ffmpeg and encodes the audio Chrome on Linux cannot. It needs a subscription, and the render is charged to that subscription's credits at Morpha's measured compute cost, so a longer composition and a larger scale each cost more. It renders at 2× the canvas by default, the same as the editor and the npm client.

The cost is reserved before the render starts, so a render you cannot afford is refused up front rather than discovered when the bill lands. You are then charged the real measured cost, and never more than was reserved — the reservation behaves as a quote. A composition longer than 30 minutes is refused, as is a render whose cost the remaining credit will not cover, or a fourth render while three are already running for the account. There is no cap on how many renders you may do in an hour: what bounds you is the credit you hold. A render that fails is never charged, and its reservation is released.

Taking a single PNG frame (client.renderFrame) stays npm-only: it needs a browser and nothing else, so there is no server counterpart.

render_video(page?, scale?)

Start a server render. Returns { renderId, status: "rendering", scale, next } at once — rendering happens in the background, so poll `render_status` with the renderId after about 30 seconds. page is the 0-based page index of a multi-page project (default: the active page); render one page per call. scale is 2 (the default, twice the canvas, roughly 4K for a portrait project) or 1 (the canvas's own size, faster and cheaper); nothing else is accepted. The render spends the subscription's credit and downloads the project's fonts from their hosts, including a custom font's own URL, so the tool is marked destructive and open-world.

{ "page": 0, "scale": 2 }
// → { "renderId": "8f3c…", "status": "rendering", "scale": 2,
//      "next": "call render_status with this renderId in about 30 seconds" }

render_status(renderId)

Check a render and get its link. While it runs: { "ok": true, "status": "not-ready" } — wait and call again. When it is done:

{ "ok": true, "status": "ready", "data": {
  "downloadUrl": "https://morphareels.ai/render/…",
  "editorUrl": "https://morphareels.ai/app?project=…",
  "expiresAt": "2026-09-18T10:04:00.000Z",
  "videoSeconds": 30,
  "scale": 2,
  "chargedCents": 1.2
} }

downloadUrl is a plain https link that needs no Morpha account — hand it to the person. It stops working at expiresAt, seven days after the render, when the file is deleted; the project stays and can be rendered again. chargedCents is what the render cost the subscription. A failed render answers ok: false with a plain reason and is never charged. Only the account that started a render can read it.