Examples
Each example is a numbered tool-call sequence. Argument objects are the tool args. Every session opens with describe_video (the structural overview) and closes with save_version — see Getting started for why. When a step needs a layer's keyframe values or styles, it calls inspect_layers([elementId]) first; the overview only names which properties are animated, not their values.
Each sequence maps directly onto SDK callTool calls — Example 1 below shows the runnable form; the rest stay in the concise pseudo-call notation.
1. Make the title fade in over the first second
The simplest possible session — three calls.
1. describe_video()
→ the response lists video.main, image.title, image.subtitle, …
→ image.title has no opacity track yet.
2. fade_layer({
elementId: "image.title",
fromFrame: 0, toFrame: 30,
fromOpacity: 0, toOpacity: 1
})
3. save_version({ name: "add 1s fade-in to title" })
The same session with the SDK:
import { createClient } from "morphareels-sdk";
const morpha = createClient({ token: process.env.MORPHA_API_KEY });
const id = "your-project-id";
await morpha.callTool(id, "describe_video", {});
await morpha.callTool(id, "fade_layer", {
elementId: "image.title", fromFrame: 0, toFrame: 30, fromOpacity: 0, toOpacity: 1,
});
await morpha.callTool(id, "save_version", { name: "add 1s fade-in to title" });
fromFrame: 0, toFrame: 30 is one second at 30 fps. An open editor tab picks the new keyframes up within a few seconds — no refresh needed.
2. A circle of 30 stars
Bulk work the API exists for. Place 30 copies of a star image evenly around a circle, each rotated to face outward.
1. upload_image({ url: "https://example.com/star.png", name: "Star" })
→ { filename: "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f.png", name: "Star" }
Keep `filename`: it is the file's id. Never show it to the person.
2. For i in 0..29:
angle = i * 12 // 360° / 30
x = 540 + 360 * cos(angle°) // canvas centre is (540, 960)
y = 960 + 360 * sin(angle°)
add_image_layer({
filename: "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f.png",
name: "Star",
x: round(x), y: round(y),
width: 80, height: 80
})
// then, on the id the tool returns:
move_layer({ elementId: "<new id>", rotation: angle })
3. save_version({ name: "circle of 30 stars" })
Radius 360 keeps every 80px star inside a 1080-wide canvas. add_image_layer returns the new layer's id — feed it straight into move_layer for the rotation. All 30 layers show the one uploaded file, and each layer gets its own layer id.
3. Swap a clip while keeping its keyframes
The user re-rendered the source video and wants the new file in, with every animation on that layer intact.
1. describe_video()
→ video.main has scale and opacity keyframe tracks. Note its id.
2. Upload the new render with the morphareels-sdk: client.addVideo(projectId, { file: "./demo-final.mp4", durationSeconds: 42 })
→ { filename: "9c8b7a65-4d3e-4f21-8a0b-1c2d3e4f5a6b.mp4", name: "demo-final.mp4", … }
3. set_video_clip({
elementId: "video.main",
clip: "9c8b7a65-4d3e-4f21-8a0b-1c2d3e4f5a6b.mp4",
name: "demo-final.mp4"
})
4. save_version({ name: "swap to the final render" })
Use set_video_clip — not remove_layer + add_video_layer. Removing and re-adding mints a new id and drops every animation track. set_video_clip keeps the id, position, size, styles, trim window, and all keyframes; only the source mp4 changes. set_image_filename does the same for image layers.
4. Vary a caption across a loop
The user wants one composition that plays three times, showing a different caption tip each pass.
1. describe_video()
→ find the caption text layer, e.g. text.caption.
→ if there's no text layer yet, create one:
add_text_layer({ text: "First tip", x: 540, y: 1500,
font_family: "Anton", text_color: "#FFFFFF" })
2. set_loop({
elementId: "text.caption",
field: "text",
values: ["Batch your filming", "Hook in 3 seconds", "Caption every clip"]
})
3. save_version({ name: "caption loop — 3 tips" })
set_loop repeats the whole composition once per value, overriding field of elementId each pass. Pass an empty values array later to clear the loop.
5. Clip out a segment of a source video
Keep only source frames 90–300 of a clip, playing from the top of the timeline.
1. describe_video()
→ note video.main and its current trim window.
2. set_video_layer_trim({
elementId: "video.main",
source_in_frame: 90,
source_out_frame: 300,
timeline_start_frame: 0
})
3. save_version({ name: "trim to source 90–300" })
set_video_layer_trim patches the trim window — source_in_frame/source_out_frame are positions inside the source mp4; timeline_start_frame is where the slice lands on the project timeline. The composition length is derived from content, so it re-fits to the new window automatically — the export stays tight with no frozen tail and there's nothing else to set. To use two disjoint segments of the same clip, duplicate the layer in the editor first, then give each copy its own window.
6. Group the header and slide it in
Wrap the title block in a group and animate the whole block as one unit.
1. describe_video()
→ confirm text.headline, text.subhead, image.logo all sit at the root
(same parent — required for grouping).
2. group_layers({
elementIds: ["text.headline", "text.subhead", "image.logo"],
name: "header"
})
→ returns the new group, e.g. group.header.
3. add_keyframe({ elementId: "group.header", property: "y",
frame: 0, value: -400, easing: "outBack" })
4. add_keyframe({ elementId: "group.header", property: "y",
frame: 20, value: 0 })
5. save_version({ name: "group header + slide-in" })
Group x/y keyframes are translation offsets around the frozen pivot, so y: -400 → 0 slides the whole header down into place. Every child moves together. If any of the three layers had been inside a different group, you'd set_group_parent them to a common parent first — group_layers requires shared parentage.
7. Animate a backdrop colour shift
Fade the canvas backdrop from light to dark over two seconds.
1. describe_video() → read the current background fill.
2. add_color_keyframe({ elementId: "background.canvas", property: "fill",
frame: 0, value: "#FAFAFC" })
3. add_color_keyframe({ elementId: "background.canvas", property: "fill",
frame: 60, value: "#14141B", easing: "easeInOut" })
4. save_version({ name: "backdrop fade to dark" })
The canvas backdrop is addressed as background.canvas. Colour keyframes crossfade stop-by-stop, so this also works between two gradients, not just two solids.
8. Undo one page of a multi-page project
The user edited page 2 into a mess and wants it back the way it was yesterday — but pages 1 and 3 have good edits from today that must survive.
1. list_versions()
→ find yesterday's bookmark; its `cp` field names the pages each
version changed (8-char page-id prefixes; absent = unknown).
2. restore_version({ versionId: "v4", page_index: 1 })
→ ONLY page 2 (index 1) reverts, matched across versions by its stable
page id. Pages 1 and 3 keep today's edits. In an open editor this
lands as one ordinary undoable step.
Restoring without page_index replaces every page — the overwritten state is auto-checkpointed as a before restore: … version, so even a whole-project restore never loses work, but the surgical form is what a one-page regret calls for.
9. Five versions of one ad, one page each
The user wants to test five opening hooks for the same ad and upload each as its own video. The versions are pages of one project, not five projects: pages have no limit, while an account caps how many projects it holds, and every page shares the project's uploaded assets.
1. describe_video()
→ the ad is page 1 (index 0); its hook is a text layer.
2. add_page({ duplicate_index: 0, name: "Hook 2" })
… four times, naming them "Hook 2" to "Hook 5". Each call selects the
page it adds.
3. select_page({ index: 1 })
describe_video() → the copy's hook text layer id
set_layer_text({ elementId, text: "Stop scrolling: dinner in 15 minutes" })
set_layer_fill(...) → its own colour
… the same for indices 2 to 4.
4. save_version({ name: "five hooks" })
The person opens the editor link and exports with the Videos option, which gives one MP4 per page. From code, call renderVideo({ projectId, page }) for each page index.