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

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 (null for older projects with no name set); 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 }, …]

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.

{ "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.

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

{ "projectId": "summer-teaser" }

restore_version(projectId, versionId)

Overwrite the live project with a saved version's payload. Destructive on the current state — call save_version first if you want a rollback point. Accepts a UUID id or the v<N> shorthand (bookmarks only for v<N>).

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

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" }

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), and an animated list naming which properties have a track. 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.

{}
// → { project_id, canvas_width, canvas_height, duration_seconds,
//     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?)

Add an image layer. The asset must already exist at users/<userId>/assets/<projectId>/<filename> — uploaded via the editor's drag-drop, POST /api/upload-asset/<projectId> (raw bytes + X-Filename header), or over MCP with upload_image / find_public_image (below). 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": "star.png", "x": 540, "y": 400, "width": 120, "height": 120 }
// Adds star.png, 120×120, centred near the top of a 1080×1920 canvas.
// No `block` ⇒ always present, for the whole composition.

{ "filename": "star.png", "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.

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.

find_public_image(query, license_type?, min_dimension?)

Search a public, Creative-Commons + public-domain image pool (Openverse), download the top suitable result into the project's asset bucket, and return the filename ready for add_image_layer. Use this when you want someone else's openly-licensed image (e.g. "find a beach photo") rather than uploading your own — for that, use upload_image. Free; no model spend. license_type is all-cc (default), commercial, or cc0. Returns { filename, attribution, dimensions }; surface the attribution where the licence requires it. (Formerly fetch_image, still accepted as an alias.)

{ "query": "sunset over mountains", "license_type": "cc0", "min_dimension": 1200 }
// → { "filename": "sunset-over-mountains.jpg", "attribution": { ... }, "dimensions": { ... } }

upload_image(url, filename?)

Upload an image into the project by fetching a direct, publicly-fetchable http(s) image URL server-side — the still-image counterpart of upload_clip. Stores the .png/.jpg/.jpeg/.gif/.webp/.svg in the project's asset bucket and returns { filename, sizeBytes, contentType }; pass filename to add_image_layer. Buffered in Worker memory, capped at 16 MiB. 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. To search for an openly-licensed image instead of supplying a URL, use find_public_image.

{ "url": "https://example.com/logo.png" }
// → { "filename": "logo.png", "sizeBytes": 24813, "contentType": "image/png" }

upload_audio(url, filename?)

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. This is the only way to get audio bytes into a project over MCP/HTTP (add_audio_overlay / update_audio_overlay only reference a filename that must already be uploaded). Stores the .mp3/.m4a/.wav/.ogg/.aac in the project's asset bucket and returns { filename, sizeBytes, contentType }. Then pass filename 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). Buffered in Worker memory, capped at 16 MiB.

{ "url": "https://example.com/music.mp3" }
// → { "filename": "music.mp3", "sizeBytes": 512044, "contentType": "audio/mpeg" }

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 ({ steps, reasons? }). Pass { steps: ["transcript", "audio_split"] } for the fast caption path — it skips the slow per-frame OCR + object-detection passes (irrelevant for talking-head captioning) so the transcript lands in seconds. 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.)

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

Add a video layer. The clip must already exist at users/<userId>/clips/<projectId>/<clip> — add it first with the morphareels-sdk npm client (client.addVideo, above) or the editor's "+ Add video" button. The layer renders the source mp4 into its box; audio mixes into preview and export.

{ "clip": "demo.mp4", "x": 540, "y": 960, "width": 1080, "height": 1920, "name": "main clip" }
// Adds demo.mp4 as a full-canvas video layer.

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

Patch a layer's static base position, size, and rotation. 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.

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

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 "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 a font file already uploaded to the project's assets — uploading is the robust path (a pasted URL only loads if its host sends permissive CORS headers). Dedupes by family + weight + style.

{ "family": "Mylius Modern", "src": "mylius-modern.woff2" }

set_image_filename(elementId, filename)

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.

{ "elementId": "image.headshot", "filename": "raj-v2.png" }

set_video_clip(elementId, clip)

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.

{ "elementId": "video.main", "clip": "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). The processing pipeline's audio-split step sets this true after demuxing the clip's audio into a standalone overlay track, 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")).

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 }

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. Clamped to [1, 600] s. 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.

set_layer_fill(elementId, fill)

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

// 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?)

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

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

set_style on a group.<id> is an error — groups have no styled body. 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 composition canvas with a fit + recenter reflow. The whole 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. On a multi-page project this resizes every page: all pages share the project's dims, so each reflows by the same factor. 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) and all pages share the project's canvas_width/canvas_height. 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 so you can see the page list. The page tools below switch the active page and manage the page list itself, and `set_canvas_size` resizes every page (each reflows by the same factor). 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 project's canvas. With duplicate_index, deep-copies the page at that index (a fresh id is minted). There is no limit on page count. The new page becomes the active page; its index is returned as index.

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

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)` over MCP/HTTP (or via the editor, or POST /api/upload-asset/<projectId> with raw bytes + an X-Filename header). 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?)

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. Returns the new overlay with an auto-assigned id (e.g. audio_1).

{ "filename": "swoosh.mp3", "startFrame": 0, "gain": 0.8, "fadeInFrames": 6, "fadeOutFrames": 12 }

update_audio_overlay(id, filename?, 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; any AI-cleaned companion of the old file is dropped so the replacement plays as-is). 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.

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: { audio_demux, proxy, audio_split, transcript, text_regions } }], allProcessed }, each step ready | pending | running | unavailable | error. 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.