Writing a plugin
Start from the template
Plugins → Download starter template produces a working package. Install it with Choose folder…, then iterate with the row’s Reload, which re-reads the folder and reinstalls without asking for consent again — unless the manifest has started asking for something new.
Package format
- plugin.json
- main.js
- panel.html
Zipping the folder is fine — one wrapping directory is stripped automatically.
| Limit | Value |
|---|---|
| Per file | 2 MB |
| Per package | 8 MB |
| File count | 200 |
| Extensions | Text only |
Paths containing .. | Refused at the format level |
main is loaded as one file. Bundle your plugin if it has dependencies.
The manifest
{
"id": "studio.acme.easing-lab", // reverse-DNS, lowercase, unique
"name": "Easing Lab",
"version": "1.2.0", // semver
"description": "…", // shown to the user before install
"author": "Acme Studio",
"homepage": "https://…", // http(s) only
"apiVersion": 1, // refused if newer than the host
"main": "main.js",
"panel": "panel.html", // optional
"permissions": ["scene:read", "animation:write"]
}Ask for the fewest permissions you need — the list is the install screen.
The entry module
export function activate(motion) {
motion.commands.register(
{ id: 'bounce', label: 'Bounce selection', icon: 'zap', needsSelection: true },
async ({ selection }) => {
const t = await motion.timeline.getTime()
for (const id of selection) {
await motion.animation.setKeyframes(id, 'y', [
{ t, value: 0, easing: 'easeOut' },
{ t + 0.18, value: -60, easing: 'easeIn' },
{ t + 0.42, value: 0, easing: 'easeOut' },
])
}
await motion.ui.notify(`Bounced ${selection.length} layer(s)`, 'success')
},
)
}export default { activate } and export default function (motion) also work.
Every motion.* call returns a promise. It is a message to the editor, not
a function call into it — your code is in another thread.
The API
motion.manifest // your own manifest
motion.permissions / motion.has(p) // what the user actually granted
motion.ui.notify(message, level) // info | success | warning | error
motion.ui.openPanel() / closePanel()
motion.ui.sendToPanel(data) / onPanelMessage(fn)
motion.commands.register(spec, handler) // spec: { id, label, icon?, needsSelection? }
motion.composition.get() // { name, width, height, fps, durationSeconds }
motion.scene.getSelection() / setSelection(ids)
motion.scene.getLayers() / getLayer(id)
motion.scene.createLayer({ kind, name, x, y }) // shape | text | group | null
motion.scene.setProperty(id, prop, value)
motion.scene.renameLayer(id, name) / deleteLayer(id)
motion.animation.getTracks(id) / sample(id, prop, time)
motion.animation.setKeyframe(id, prop, time, value, easing)
motion.animation.setKeyframes(id, prop, [{ t, value, easing }]) // prefer this
motion.animation.removeKeyframe(id, prop, time)
motion.animation.setExpression(id, prop, source)
motion.timeline.getTime() / setTime(seconds)Prefer setKeyframes over a loop of setKeyframe. The bulk API sorts once
and notifies once; writing a generated track a keyframe at a time is quadratic,
and is what used to freeze the app on imports.
Degrading on a refused permission
Check rather than assume:
if (await motion.has('scene:write')) {
// offer the feature
} else {
// hide it, and say why
}A refused call returns an error naming the missing permission, so failing loudly is also fine — what is not fine is doing nothing silently.
Panels
panel.html is plain HTML, run in the sandboxed frame with two globals:
motionPanel.send(data) // → your plugin's onPanelMessage
motionPanel.onMessage(fn) // ← your plugin's sendToPanelThe panel talks to your plugin only. It has no access to the editor and no
access to the network — inline <script> runs, fetch does not.
It appears as the Plugins panel in the right-hand dock, tabbing alongside Effects and Graph, and can be floated or popped out like any other panel. Several plugins with panels share it as tabs.
The development loop
Generate the starter
Plugins → Download starter template.
Install it as a folder
Choose folder…, accept the permissions.
Edit and reload
Change the code, press Reload on the row. The picker still opens — a browser cannot re-read a directory without a gesture — but the consent screen does not.
Watch the log
Each row has a log carrying your console.* output, every call the permission
gate refused, and the crash that stopped it if it stopped. It is kept after the
plugin dies, because that is when you need it.
Constraints to design around
| Constraint | Why |
|---|---|
| Boot must complete in 8 seconds | Boot timeout |
| Never block the loop | A missed heartbeat twice over terminates the worker |
No DOM, no window, no localStorage | Worker realm |
| No network, in the worker or the panel | Locked down before your module is imported |
One bundled file for main | No module resolution at install time |