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. They don't transform an existing composition — list_projects and create_project don't even take an existing projectId.

list_projects()

List every project in your workspace 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. Call this when you need to pick a project without being told its id.

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

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(projectId, fromProjectId?, name?)

Create a new project. With fromProjectId, clones that project's JSON, uploaded assets, and uploaded clips. Without it, clones the alphabetically-last existing project as the seed; if the workspace is empty, writes a stock minimal project. Refuses to overwrite an existing id. The editor doesn't auto-refresh — the user reloads to see the new project.

{ "projectId": "summer-teaser", "fromProjectId": "spring-promo", "name": "Summer teaser" }
// Creates "summer-teaser" as a full clone of "spring-promo".

duplicate_project(projectId, name?)

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. 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. Refuses to delete the only remaining project. 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.

{}
// → { project_id, canvas_width, canvas_height, duration_seconds,
//     background: { elementId, name, fill }, embed_origins, loop,
//     layer_count, tree: [ { elementId, type, name, filename?, clip?,
//     text?, kind?, x, y, width, height, animated?: ["x","opacity"],
//     children?: [...] }, ... ] }

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)

Add an image layer. The asset must already exist at users/<userId>/assets/<projectId>/<filename> — uploaded via the editor's drag-drop, /api/upload-asset, or over MCP with upload_image / find_public_image (below). To duplicate an existing layer, reuse its filename; a fresh id is assigned. (x, y) is the layer centre.

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

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. 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_clip(url, filename?, durationSeconds?)

Upload a video clip into the project — the MCP equivalent of the editor's "+ Add video". Fetches a direct, publicly-fetchable http(s) video URL server-side, stores it in the project's clip bucket, sniffs the codec, and kicks off background OCR + transcription. Returns { filename, sizeBytes, codec, durationSeconds, ocrSupported, warning }; pass the returned filename to add_video_layer. Buffered in Worker memory, so capped at 100 MB — for larger or local files use the presign pair below. The URL must be a real file link, not an auth-walled share page (iCloud / Drive shares won't resolve).

{ "url": "https://example.com/clip.mp4" }
// → { "filename": "clip.mp4", "codec": "h264", "durationSeconds": 22.4, "ocrSupported": true }

upload_clip_presign(filename) + upload_clip_finalize(filename, durationSeconds?)

Direct-to-R2 upload for local files and clips over 100 MB (no Worker memory cap). Three steps:

  1. upload_clip_presign(filename){ uploadUrl, contentType, key, expiresInSeconds } (a 15-minute presigned PUT URL).
  2. HTTP PUT the file bytes to uploadUrl with header Content-Type: <contentType> (no auth needed — the URL is signed).
  3. upload_clip_finalize(filename) → confirms the object landed, sniffs the codec, starts OCR + transcription. Returns the same shape as upload_clip.
# step 2, from a shell:
curl -X PUT --data-binary @clip.mp4 -H "Content-Type: video/mp4" "<uploadUrl>"

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

Add a video layer. The clip must already exist at users/<userId>/clips/<projectId>/<clip> — upload it first with upload_clip / upload_clip_presign (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?)

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.

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

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

{ "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?, line_height?, text_align?, text_autofit?, text_valign?, stroke_width?, stroke_color?, text_shadow?)

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

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), line_height (multiplier, e.g. 1.2), text_align ("left"/"center"/"right"), text_autofit ("wrap" default = hold text_size fixed and only word-wrap, hard-breaking an over-wide word — the size you set is the size rendered; "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), and text_shadow ({ offsetX, offsetY, blur, color }, where color is any CSS colour). The canvas synthesizes weights a static font doesn't ship.

{ "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?, line_height?, text_align?, text_autofit?, text_valign?, stroke_width?, stroke_color?, text_shadow?)

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

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

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.

{ "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 custom (non-Google) typeface on the project so text layers can reference it by family name via font_family. 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_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 }

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

add_speed_keyframe(elementId, frame, rate)

Add or overwrite a speed-ramp (time-remap) keyframe on a video layer. 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.

{ "elementId": "video.main", "frame": 0, "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. Removing the last one restores 1× playback.

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

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

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.
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", "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. There is no set_duration tool — the timeline (and the export) length always auto-fits the latest content: the furthest video-window end, keyframe, or audio-overlay end, with a 1-second floor. Trim a video layer or move a keyframe and the composition re-fits automatically.

set_canvas_size(width, height)

Resize the composition canvas. Every layer's position and size, group pivots, and x/y/width/height keyframes scale by the width/height ratio, so the composition keeps its proportions. 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; everything rescales.

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.

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) are uploaded the same way as images — via the editor or /api/upload-asset, not MCP.

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

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

Patch an existing overlay — only the fields you pass change. Pass endFrame: null to clear an explicit end and revert to natural-length playback.

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

remove_audio_overlay(id)

Delete an audio overlay by id.

{ "id": "audio_1" }