Morpha SDK
`morphareels-sdk` is the official npm client for Morpha — the friendliest way to drive the editor from code. It calls the same tool catalog as MCP, over the same Worker HTTP endpoints, and adds one-call PNG frame + MP4 video rendering with no ffmpeg (a real local browser does the encode). It's the recommended client; it doesn't replace the MCP server — MCP-native agents (Claude Desktop, Claude Code) still connect over MCP.
npm i morphareels-sdk
Requires Node ≥ 20. renderFrame / renderVideo additionally need Playwright + system Chrome (see below); everything else is pure HTTP.
Get a key with no human — registerAccount
If you have no mp_… key and nobody to ask for one, mint an account yourself. No browser, no email address, no sign-up form.
import { registerAccount, createClient } from "morphareels-sdk";
const { token, claimUrl } = await registerAccount({ name: "my-agent" });
const morpha = createClient({ token });
// … build the video, then give the person `claimUrl`
It is an anonymous account: it can build, edit and render, and has no AI credit envelope, so drive the tools with your own model, which is free and unmetered.
Nobody can sign into it, so don't leave a person's work there. It becomes theirs through its claim link: registerAccount() returns it as claimUrl, and so does every create_project, open_project and list_projects result for that account. The person opens it, signs in or signs up, and the project moves into their own account. An anonymous account nobody claims is deleted after 30 days.
Drive a hosted project — createClient (recommended)
createClient is the programmatic equivalent of an MCP session over the full catalog. Every tool you can call over MCP you can call here — the pure mutation tools and the workspace/lifecycle, version, upload, and vision tools (list_projects, create_project, save_version, upload_image, upload_audio, detect_text_regions, safe_zones, …). Most have a typed convenience method (below); the rest go through generic callTool.
import { createClient } from "morphareels-sdk";
const morpha = createClient({ token: process.env.MORPHA_API_KEY }); // origin defaults to https://morphareels.ai
const tools = await morpha.listTools(); // the tool catalog (OpenAI tool shape)
const project = await morpha.getProject("your-project-id"); // the live project JSON
// One tool call = load -> dispatch -> write back, server-side (exactly like MCP):
const { result, project: next, editorUrl } = await morpha.callTool(
"your-project-id",
"add_text_layer",
{ text: "HELLO", x: 540, y: 600, font_family: "Anton" },
);
// A composited PNG of one frame (real browser, no ffmpeg):
const png = await morpha.renderFrame("your-project-id", 150);
// The full composition as an MP4 Buffer — same WebCodecs pipeline as the
// editor's Share → Download (no ffmpeg, no server):
const mp4 = await morpha.renderVideo("your-project-id");
// The same MP4 written straight to disk, for long videos:
await morpha.renderVideoToFile("your-project-id", "video.mp4");
createClient({ origin?, token?, fetch? }) returns a MorphaClient:
- `getProject(id)` —
GET /api/project/:id→ a validatedProject. - `listTools()` —
GET /api/tools→ToolFunction[](OpenAI tool shape:{ type, function: { name, description, parameters } }). - `callTool(id, name, args?)` —
POST /api/tool/:name. For a pure mutation tool →{ result, project, editorUrl }(the server loads → dispatches → writes back, exactly like MCP). For a server tool (workspace/upload/vision) →{ result }with noproject. - `renderFrame(id, frame?, opts?)` — one composited PNG
Bufferof that frame (real browser, no ffmpeg). Passopts.page(0-based) to photograph a specific page of a multi-page project. - `renderVideo(id, opts?)` — the full composition as an MP4
Buffer(the same in-browser WebCodecs H.264 encode the editor's Share → Download runs — no ffmpeg, no server). It renders at 2× the canvas by default; passopts.scale: 1for the canvas's own size. Passopts.page(0-based) to export a specific page; loop the pages for one MP4 per page. It must run on macOS or Windows: no browser on Linux ships an AAC audio encoder, and the render page refuses rather than returning a silent MP4. On Linux, or anywhere with no browser, a subscriber can call the `render_video` tool instead and have Morpha render it. - `renderVideoToFile(id, path, opts?)` — the same MP4 as
renderVideo, written topathas it comes out of the browser, so your process never holds the file. Returns{ path, bytes }. Use it for long videos.
callTool throws only on transport/HTTP errors; a tool-level failure (e.g. a missing asset) comes back as result.ok === false — a normal outcome to inspect, not an exception. The token is the same mp_… API key MCP uses (mint it at `/app/settings`; any signed-in account can, free or paid, since this endpoint is gated identically to MCP). A key an MCP client obtained by signing in works here unchanged — it is the same kind of key, listed on the same page. For local dev, createClient({ origin: "http://localhost:8787" }) needs no token.
Typed methods
The catalog's non-mutation tools have typed convenience methods that unwrap result.data for you:
- Workspace / lifecycle —
listProjects(),openProject(id),createProject({ fromProjectId?, name? })(a fresh blank project by default — the id is minted server-side and returned; passfromProjectIdonly to clone that project's JSON + assets + clips),duplicateProject(sourceId, { name? }),renameProject(id, name),reidProject(id, newId?),deleteProject(id). - Versions —
saveVersion(id, { name? }),listVersions(id)(summaries includecp, which pages each version changed),restoreVersion(id, versionId, { pageIndex? })(omitpageIndexto restore the whole project — the overwritten state is auto-checkpointed; pass it to restore just that page as an ordinary edit),renameVersion(id, versionId, name),deleteVersion(id, versionId). - Ingest — every upload returns
{ filename, name }(see Uploads return the file's id below).addVideo(id, { url, name? } | { file, name?, durationSeconds }, { steps?, timeoutMs? })is the only way to put a clip in a project: it uploads AND processes in local Chrome, returns{ filename, name, processing }, and always builds the mandatory preview proxy (processing.okis true once it landed;stepsnarrows only the optional passes). One clip may be up to 500 MB on the free plan and 10 GB on a paid plan; a larger one is refused before it uploads, with reasonstarter-upload-sizeorsubscribed-upload-size. A clip is also refused when it and its preview proxy would not both fit in the account's storage, with the storage reason and a first sentence saying what the clip needs including the proxy and what is free. The room for the proxy is set aside when the clip is accepted, so other uploads cannot take it first.uploadImage(id, { url, name? } | { file, name? })anduploadAudio(id, { url, name? } | { file, name? })(a public URL fetched server-side, or a local path streamed from disk),setCustomFont(id, { family, src, weight?, style? })(register a non-Google typeface;srcis a font URL or thefilenamea font upload returned),findPublicImage(id, query, { licenseType?, minDimension? })(searches Openverse's Creative Commons pool from your machine, so the quota is yours, then stores the pick throughupload_image; returns{ filename, name, attribution, dimensions }, ornullwhen nothing downloadable and large enough came back). - Vision / transcription —
detectTextRegions(id, { clip } | { image }),safeZones(id, { clip, … }),transcribeClip(id, clip). These read caches produced when a clip is opened in the editor, so they return{ status: "ready" | "not-ready", data, note? }—not-ready(never an exception) until the clip has been opened once. - Handing work over —
shareProject(id, emails, editors?). Addresses inemailsget read access; addresses ineditorsget edit access (pass the same address in both to make someone an editor). This is how an agent gives a person their work: share to their address, then send them<origin>/app/<projectId>, and they sign in as themselves.
const [{ id }] = await morpha.listProjects();
const { filename, name } = await morpha.uploadImage(id, { url: "https://example.com/logo.png" });
await morpha.callTool(id, "add_image_layer", { filename, name, x: 540, y: 600, width: 300, height: 300 });
await morpha.saveVersion(id, { name: "add logo" });
Uploads return the file's id
Every upload (addVideo, uploadImage, uploadAudio and findPublicImage) returns { filename, name }. Morpha names each stored file itself with an opaque id, such as 3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png, the same way it mints a project's id. Pass that filename to add_image_layer, set_image_filename, add_video_layer (as clip), add_audio_overlay or set_custom_font exactly as returned, and never show it to a person. name is what people see: the file's own name unless you pass { name }, and the label to give the layer or track. Two uploads under one name are two separate files, each with its own id, so uploading over a name never replaces a file. To swap a layer's picture, upload the new file and pass its filename to set_image_filename.
Breaking change in 0.11. Earlier versions took a filename option that chose the stored name, and uploading under a name that was already there replaced that file. The option is now name, and it only sets what people see; passing filename throws. An earlier SDK is refused, and nothing is written, when it sends a stored name: a clip or a file from disk gets HTTP 409 upload-client-outdated, and a URL upload that passes filename gets a tool error. Update with npm i morphareels-sdk@latest and use the filename each call returns.
Render a frame to PNG — renderFrame (no ffmpeg)
The standalone form, if you don't need a full client:
import { renderFrame } from "morphareels-sdk";
import { writeFile } from "node:fs/promises";
const png = await renderFrame({ projectId: "demo", frame: 150, token: process.env.MORPHA_API_KEY });
await writeFile("frame-150.png", png);
Rendering composites the whole project (video + captions + shapes + text) in a real browser, so it's pixel-identical to the editor — and it decodes HEVC / iPhone `.MOV` via the OS decoder. It needs Playwright + Google Chrome on the calling machine:
npm i playwright # optional peer dependency; only needed for renderFrame()
Options: { projectId, frame?, page?, origin?, token?, width?, height?, channel?, timeoutMs? }. page is the 0-based page index for multi-page projects (default: the project's active page) — combined with frame it photographs any page at any moment of its timeline. channel defaults to "chrome" (system Chrome — decodes HEVC on macOS/Windows); pass "chromium" for H.264/VP9/AV1 only. If a video layer can't decode, renderFrame throws a descriptive error rather than returning a black frame.
Several frames at once — renderFrames
Sampling a strip of frames? renderFrame in a loop pays for a browser launch, a document load, a project fetch, a font load and a full clip download per frame. renderFrames does all of that once and re-seeks for the rest:
const [start, middle, end] = await morpha.renderFrames("demo", [0, 150, 300]);
// or standalone:
import { renderFrames } from "morphareels-sdk";
const pngs = await renderFrames({ projectId: "demo", frames: [0, 150, 300], token });
One PNG per frame, in the order given, and pixel-identical to the one-at-a-time path — the saving comes from not repeating work. It grows with the frame count, because what it removes is the fixed per-frame cost: measured against production, 5 frames 9.2s → 3.4s (2.7×), 10 frames 18.8s → 4.9s (3.9×). Worth reaching for when sampling a strip; not a reason to batch two.
Options are renderFrame's, with frames: number[] in place of frame.
Render the full video to MP4 — renderVideo (no ffmpeg, no server)
renderVideo exports the whole composition to an MP4 — the same in-browser WebCodecs H.264 pipeline the editor's Share → Download uses, driven by a real local browser. There's no ffmpeg and no server-side render: the encode happens in your machine's Chrome.
import { renderVideo } from "morphareels-sdk";
import { writeFile } from "node:fs/promises";
const mp4 = await renderVideo({ projectId: "demo", token: process.env.MORPHA_API_KEY });
await writeFile("demo.mp4", mp4);
Options: { projectId, page?, scale?, origin?, token?, channel?, timeoutMs? }. scale is the export quality: 2 (the default) renders at double the canvas size, 2160×3840 for a portrait canvas, the same default as the editor's 1×/2× cards; 1 renders at the canvas's own size. page is the 0-based page index for multi-page projects (default: the project's active page) — loop the pages to export one MP4 per page, the scripted equivalent of the editor's "Videos" export card:
import { createClient } from "morphareels-sdk";
import { writeFile } from "node:fs/promises";
const morpha = createClient({ token: process.env.MORPHA_API_KEY });
const project = await morpha.getProject("your-project-id");
for (let i = 0; i < project.pages.length; i++) {
await writeFile(`page-${i + 1}.mp4`, await morpha.renderVideo(project.project_id, { page: i }));
}
Like renderFrame, it needs Playwright + system Chrome (channel defaults to "chrome" — don't use "chromium", which ships without the H.264 encoder and will fail). timeoutMs defaults to 10 minutes. On an Apple M5 Pro, a 30-second composition of shapes rendered in 8 seconds at 2× and 5.6 seconds at 1×; footage, effects and long projects take longer. The browser writes the MP4 to disk as it encodes, and the file comes back from it in 8 MiB chunks, so there is no size ceiling on the handoff. If the export fails (e.g. a clip can't load), renderVideo throws a descriptive error rather than returning a truncated file.
renderVideo returns a Buffer, so the whole MP4 ends up in your process's memory. For a long video, write it to disk instead: renderVideoToFile takes the same options plus path, writes each chunk to the file as it arrives, and returns { path, bytes }. The file appears at path only once every byte has arrived, so a failed render leaves nothing there.
import { renderVideoToFile } from "morphareels-sdk";
const { bytes } = await renderVideoToFile({
projectId: "demo",
token: process.env.MORPHA_API_KEY,
path: "demo.mp4",
});
It needs Chrome on macOS or Windows. Chrome on Linux has no AAC audio encoder, so the render page refuses to export there, before any frame renders, rather than hand back a silent MP4. From a Linux machine, give the person the project's editor link and let them choose Share, then Download on their Mac or PC.
Build a project offline — the pure core
The package also exports the pure tool catalog — the same functions with no network and no persistence. Use it to construct or transform a project in memory (you save it yourself).
import { blankProject, dispatchOnProject, projectSchema } from "morphareels-sdk";
let project = blankProject({ projectId: "demo", canvasWidth: 1080, canvasHeight: 1920 });
project = dispatchOnProject(project, "add_text_layer", { text: "HELLO", x: 540, y: 600 }).project;
project = dispatchOnProject(project, "add_caption_track", {
lines: [{ text: "first line", startFrame: 0, endFrame: 30 }],
}).project;
projectSchema.parse(project); // a valid Morpha project
dispatchOnProject(project, name, args) returns { project, result } and changes nothing on the server — it's local-only. To edit a project the editor will see, use callTool (above) instead. TOOL_DEFINITIONS is the full catalog in OpenAI tool shape.
dispatchOnProject is the entry point for a whole project — the same router the hosted surfaces use. A Morpha project is an ordered list of pages (one for a plain video, several for a carousel); dispatchOnProject runs the page tools (add_page / delete_page / reorder_pages / select_page) and set_canvas_size on the project, and routes every content tool at the active page, folding the edit back into it. The lower-level dispatch[name] catalog is also exported, but it operates on a single page's composition (Composition), not a Project — use dispatchOnProject unless you're driving one page's composition directly.
Captions
import { transcriptToCaptionLines, buildCaptionsForClip } from "morphareels-sdk";
transcriptToCaptionLines(words) turns transcript word-timings into synced caption lines; buildCaptionsForClip / hasCaptionsForClip / removeCaptionsForClip / videoElementIdForClip build and manage a caption track on a clip.
Types
All TypeScript types are exported: Project, ImageLayer, VideoLayer, TextLayer, LayerStyle, Easing, ToolFunction, ToolResult, ToolDispatch, RenderFrameOptions, MorphaClient, MorphaClientOptions, ToolCallResult, ToolResultEnvelope, CacheReadResult, UploadedFile, AddVideoSource, UploadAssetSource, CaptionLine, TranscriptWordLike, BlankProjectOpts.
SDK vs MCP
Use the SDK for scripts and agents you write in JS/TS — it's typed, ergonomic, exposes the full tool catalog (every tool MCP has), and is the only route that also renders frames and full MP4s locally. Use [MCP](/docs/mcp) for MCP-native clients (Claude Desktop, Claude Code) that speak the protocol directly. Both hit the same catalog over the same backend; the SDK is a client of it, not a replacement.