Writing a native plugin
A Premation plugin is a shared library that exports one C function, plus a JSON manifest. Its shape follows the After Effects plugin API: a single entry point per effect that receives command selectors, declared parameters, pixel worlds, and the same ideas of smart render, sequence data and layer checkout. Beyond effects, a plugin can add menu commands, footage importers, output modules and dockable panels — After Effects’ AEGP, AEIO and output-module classes. If you have written an AE plugin, the vocabulary is the same.
This replaces the JavaScript/WGSL plugin system (plugin.json, main.js,
panels, the motion.* host API), which was removed in 0.9.0. Nothing in
that system carries over. See Plugins for what
happens to a project that used it.
What you need
- The SDK. Each release attaches
premation-sdk-<platform>.zipfor Windows x64, macOS arm64, macOS x64 and Linux x64 on GitHub Releases : the C99 headers with a versioned ABI, a CMake package, thepremation-pluginstest tool, the samples, and the pack and sign scripts. The same files are in the editor repository undernative/sdk/. The SDK is not part of the app installer. - The headers — include
premation_sdk.hand you have all of them (pr_effect.h,pr_params.h,pr_world.h,pr_scene.h,pr_gpu.h,pr_host.h,pr_command.h,pr_io.h). - A C or C++ toolchain that can build a shared library for your platform.
With the released SDK,
find_package(PremationSdk)andpremation_add_plugin(name SOURCES … MANIFEST premation-plugin.json)build the library and lay the bundle out under<build>/plugins/<name>/. The editor repository itself builds with Clang on every platform (clang-clon Windows), CMake presets and a vcpkg manifest.
The ABI is plain C on purpose: only integers, doubles, plain structs and pointers the host owns cross it. No C++ exceptions, no STL types, and no memory you must free on the other side — memory the host needs back is a host handle.
The bundle
A plugin ships as a bundle: a folder holding premation-plugin.json and the
binary it names for each platform, plus an optional ui/ folder for panels and
presets/ folder for presets.
- premation-plugin.json
- glow.dll
- libglow.dylib
{
"manifestVersion": 1,
"id": "com.example.glow",
"name": "Example Glow",
"version": "1.2.0",
"vendor": "Example",
"sdk": { "major": 1, "minor": 2 },
"binary": { "windows-x64": "glow.dll", "macos-universal": "libglow.dylib" },
"effects": [ { "matchName": "com.example.glow", "name": "Glow", "category": "Example" } ]
}| Field | Rule |
|---|---|
manifestVersion | 1 is the only version this engine reads |
id | 1–100 characters from [A-Za-z0-9._-], starting with a letter. Use a reverse-DNS name |
sdk | The SDK major and minor you built against. See versioning |
binary | The library’s file name inside the bundle, per platform key: windows-x64, macos-arm64, macos-x64, macos-universal, linux-x64, linux-arm64, or the plain windows / macos / linux. The engine takes the most specific key for the machine it runs on |
effects | Each has a matchName, a display name and a category. May be empty or absent when the bundle contributes something else |
effect matchName | Either the plugin id, or <id>.<name>. This keeps match names unique across plugins and apart from built-in effect types. Listing one twice is an error |
presets | Named starting points for the effects — see Presets |
commands · importers · outputModules · panels | What the bundle adds besides effects — see Plugins that are not effects |
The engine reads and validates the manifest before it loads the binary. A malformed manifest or entry, a missing binary for the platform, or an incompatible SDK leaves the plugin listed as Failed with the reason, and none of your code has run.
The plugin id and each effect’s match name must also match what the module
reports at run time, and every command, importer and output module the manifest
lists must have its entry point in the module, and the other way round.
The entry point
The module exports exactly one symbol:
PR_EXPORT const PrPluginInfo* PR_CALL PremationPluginInfo(void);PrPluginInfo names the plugin id, the SDK version the module was built with,
and the module’s effects — and, in its SDK 1.2 trailing fields, its commands,
importers and output modules. Each effect has its own entry point:
PrErr PR_CALL EffectMain(PrCmd cmd, const PrInData* in_data, PrOutData* out_data,
PrParamDef* const* params, PrWorld* output, void* extra);Every selector and every host callback returns a PrErr. To fail a call, return
non-zero and put an explanation in out_data->return_msg; the host records it
against the layer and renders the layer as if the effect were off.
Selectors
| Premation | After Effects | What the plugin does |
|---|---|---|
ABOUT | PF_Cmd_ABOUT | Writes text into return_msg |
GLOBAL_SETUP / GLOBAL_SETDOWN | same | Sets out-flags, version and global_data; frees it |
PARAMS_SETUP | same | Calls add_param for each parameter, in order |
SEQUENCE_SETUP / RESETUP / FLATTEN / SETDOWN | same | Per-instance state. FLATTEN hands back the pointer-free bytes the project stores |
FRAME_SETUP / RENDER / FRAME_SETDOWN | same | Non-smart render: params[0]->world to output |
SMART_PRE_RENDER / SMART_RENDER | same | checkout_layer in pre-render; checkout_layer_pixels and checkout_output in render |
USER_CHANGED_PARAM | same | Reacts to a button, or to a param flagged SUPERVISE. May write parameter values and change sequence data |
UPDATE_PARAMS_UI | same | set_param_ui — enable, hide or rename a parameter |
GPU_DEVICE_SETUP / SETDOWN, SMART_RENDER_GPU | PF_Cmd_GPU_DEVICE_SETUP, PF_Cmd_SMART_RENDER_GPU | The GPU path, on the engine’s own device |
DRAW_OVERLAY / OVERLAY_DRAG (1.1) | PF_Cmd_EVENT (draw / drag) | The viewer overlay — see Viewer overlays |
LIST_ASSET_FILES (1.2) | none | The files beside a FILE param’s asset, so Collect Files carries them — see Assets |
Out-flags you set in GLOBAL_SETUP say what the effect can do and what it
reads: DEEP_COLOR_AWARE (16-bit), FLOAT_COLOR_AWARE (32-bit float),
SMART_RENDER, GPU_RENDER, SEQUENCE_DATA, GENERATOR, WIDE_TIME_INPUT,
NON_PARAM_VARY, SEND_UPDATE_PARAMS_UI and THREADED_RENDER; since 1.1
USES_CAMERA, USES_LIGHTS, USES_LAYER_TRANSFORMS and CUSTOM_OVERLAY;
since 1.2 SAMPLES_TIME, USES_SHUTTER, COMP_SPACE and USES_SCENE_DEPTH.
A callback that needs a flag the effect did not declare answers
PR_ERR_INVALID_CALLBACK.
The host may run the render selectors of different frames on different threads at once. Everything else — setup, sequence, parameter UI — is serialised per plugin module, and sequence data is read-only during a render.
Parameters
You declare parameters; the engine keyframes and evaluates them. At every render you receive each parameter’s value at the frame’s time. In the document each one is an ordinary effect property, which is why undo, keyframes, expressions, copy and paste, save and open all work for it without any plugin code.
| Parameter | What it is in the document |
|---|---|
SLIDER, FLOAT_SLIDER, ANGLE | A number (an angle in degrees) |
POINT, POINT_3D | Numbers, in layer pixels from the layer centre; you receive layer pixels |
COLOR | A colour as the colour picker shows it; the plugin receives straight RGBA in linear light (1.2) |
POPUP | A choice, 1-based |
CHECKBOX | A boolean |
LAYER | A reference to another layer (empty means none) |
PATH | A mask of the layer — its Bézier at the frame’s time |
ARBITRARY_DATA | Bytes stored in the document |
BUTTON | An action |
GROUP_START / GROUP_END | A twirl-down section |
STRING (1.1) | Text, edited in a multi-line field. Not keyframed |
CURVE (1.1) | A Curves-style point list. Not keyframed |
GRADIENT (1.1) | Gradient stops; their colours reach the plugin in linear light (1.2). Not keyframed |
FILE (1.1) | A project item, chosen with Choose… — so Collect Files, Relink and Remove Unused treat it like footage. The plugin reads it with get_asset_path / get_asset_bytes. A missing file never fails the frame: the plugin is told it is missing and the layer reports it |
Flags: CANNOT_ANIMATE, SUPERVISE, HIDDEN, START_COLLAPSED, DISABLED.
Colours are linear (1.2). Every world a plugin reads and writes is in the
linear working space, and so are its colours: a COLOR value and a GRADIENT
stop’s r, g, b arrive decoded from the document’s sRGB-encoded values with the
same transfer the built-in effects use, so a #808080 grey is 0.2159, not 0.5.
A colour written back with set_param_value is taken as linear and encoded for
the document. Declared defaults and preset colours are document values, as the
picker shows them. SDK 1.0 and 1.1 plugins see the same linear values.
Pixels
- Worlds are full-frame, premultiplied, in the linear working space, at the
size of the effect chain’s buffer.
layer_to_worldmaps layer pixels to world pixels; scale pixel-sized parameters (a blur radius) bypixel_scale_xandpixel_scale_y. - Depth follows After Effects’ down-conversion. In a 32-bit project, an effect
flagged
FLOAT_COLOR_AWAREgets 32-bit float worlds, aDEEP_COLOR_AWAREeffect gets 16-bit, and any other effect gets 8-bit; 16-bit and 8-bit projects work the same way. The conversions are pure functions with fixed rounding, so the same frame always converts to the same bytes. iterate(count, fn)runsfnonce per row on the host’s worker pool, each job guarded against crashes.
Time and checkouts
Times are integer ticks of PR_TIME_SCALE — 705,600,000 per second — which is
exact for every standard and NTSC frame rate. current_time is the layer’s
time — its start offset, Time Stretch, reverse and freeze — and comp_time is
the composition’s (before 1.2 both were the comp time). layer_start,
comp_width, comp_height and layer_is_3d describe the layer and its
composition.
In SMART_PRE_RENDER you may check out:
- the effect’s own input (parameter index 0) at the current time — the chain’s buffer;
- another layer, through a
LAYERparameter; - your own layer at another time, if you set
WIDE_TIME_INPUT.
The engine resolves each checkout from the frame itself. A frame stays a pure function of the document, so preview, export and a restarted engine render the same pixels.
The comp camera, lights and layer transforms (1.1)
Declare USES_CAMERA, USES_LIGHTS or USES_LAYER_TRANSFORMS, then:
| Callback | Gives |
|---|---|
get_comp_camera(host, time, &camera) | Camera-to-world and world-to-camera matrices, the projection to comp pixels, eye, zoom, field of view, film size, depth of field. With no camera layer, the comp’s default view |
get_comp_lights(host, time, lights, capacity, &count) | Every comp light: type, colour, intensity, position, direction, cone, falloff, shadows |
get_layer_transform(host, param_index, time, m) | A LAYER parameter’s world matrix |
World space is After Effects’: pixels, +x right, +y down, +z away from the viewer, matrices column-major. They answer at the frame’s time and, since 1.2, at any time the effect checked out. An effect that reads the camera re-renders when the camera moves, and no other.
Other times and motion blur (1.2)
A trail, a plugin’s own motion blur or a simulation’s history reads the scene at times other than the frame’s. Here the effect says up front what it will read, and the engine evaluates exactly that:
- Declare
SAMPLES_TIME(withSMART_RENDER) and theUSES_*flags of what it reads. - In
SMART_PRE_RENDER, callcheckout_time(host, time, what)for each layer time, withwhatany ofPR_SAMPLE_PARAMS,PR_SAMPLE_CAMERA,PR_SAMPLE_LIGHTSandPR_SAMPLE_LAYER_TRANSFORMS(PR_SAMPLE_ALL) — at most 1,024 times per instance and frame. - In the render,
checkout_param(host, index, time, &def)and the camera, light and transform callbacks answer for the frame’s time and every checked-out time. Any other time answersPR_ERR_NOT_FOUND: skip that sample, never fail the frame.
The engine evaluates the requested values off the document — never a whole frame per time — and the frame stays a pure function of the document.
PrInData carries the composition’s shutter_angle, shutter_phase and
motion_blur_samples, and motion_blur is 1 when the render, the composition
and the layer all have motion blur on. An effect that declares USES_SHUTTER
renders its own blur over the shutter, and the engine then draws its layer once
rather than supersampling it through the effect.
Output beyond the layer (1.2)
A result_rect from SMART_PRE_RENDER that reaches past the layer — a glow’s
halo, a flare off the layer’s edge — grows the layer’s effect buffer by that
much, up to the engine’s texture limit. With COMP_SPACE, the effect’s output
covers the whole composition frame, whatever the size of its layer: draw with
PrInData.comp_to_world, which maps composition pixels to world pixels.
Assets
Since 1.2, a FILE parameter’s file is available beyond its path and bytes:
get_asset_image— the image decoded by the engine (PNG, JPEG, WebP, GIF, BMP, TIFF, OpenEXR, Radiance.hdr, binary.ppm/.pfm) as linear, premultiplied 32-bit float.get_asset_image_gpugives the same image as a texture on the engine’s device, uploaded once per file and modification time.get_asset_sibling_bytes(host, index, "textures/albedo.png", …)— a file beside the asset: a glTF’s.binand textures, an.obj’s.mtl. No.., no absolute paths, no symlinks out of the asset’s folder.LIST_ASSET_FILES— at Collect Files, the engine asks the effect which files beside its asset it needs (add_fileonce per file), and collects them with it. Opening the collected project lays them out under their names again.
Scene depth (1.2)
After Effects gives a plugin no depth buffer; Premation does, so a model can
pass behind the comp’s own 3D layers (“composite with scene depth”). Declare
USES_SCENE_DEPTH, then call checkout_scene_depth_gpu in SMART_RENDER_GPU
(an r32float texture; read it with textureLoad) or checkout_scene_depth on
the CPU path. Each pixel holds the camera-space depth of the nearest 3D layer
other than the effect’s own, in comp pixels, or +inf where there is none. On a
render with no GPU the call answers PR_ERR_UNSUPPORTED: render unoccluded. For
motion blur, add PR_SAMPLE_SCENE_DEPTH to a time’s checkout_time bits and
read it with checkout_scene_depth_gpu_at / checkout_scene_depth_at.
Presets
Since 1.2 a bundle ships named starting points for its effects, After Effects’
.ffx, in the manifest or as presets/*.json:
"presets": [
{ "name": "Warm Fountain", "category": "Fountains", "effect": "com.example.particles",
"values": { "p2": 600, "p8": "#ffb347", "p5": 2, "p1": [0, -100] } }
]effect defaults to the plugin’s first effect; values are by parameter key. A
key the effect lacks, or a value of the wrong kind, is dropped; a preset without
a name, or naming an effect the bundle does not list, is skipped — never a failed
plugin. The editor lists presets under their effect in Effects & Presets, and
applying one is one undo entry.
Viewer overlays
Declare CUSTOM_OVERLAY (1.1). In DRAW_OVERLAY, call overlay_line,
overlay_path and overlay_handle in layer pixels; in OVERLAY_DRAG (begin,
move, end) write parameters with set_param_value. A whole drag is one undo
entry, and a plugin that crashes while drawing draws nothing — the frame still
renders.
For a handle on a 3D point (1.2), read the camera in the overlay selectors,
project the point yourself, and call overlay_set_space(host, PR_OVERLAY_SPACE_COMP) to draw in composition pixels; a drag then reports the
pointer in composition pixels too. The model3d sample moves its model in the
camera’s view plane this way.
Panels
An effect panel (1.1). A bundle may ship ui/index.html, with its scripts,
styles, images and fonts beside it in ui/. The effect card then shows Open
Panel, and the editor shows the page in a sandboxed frame: served from the
bundle’s ui/ folder only, scripts allowed, no Node, no storage, no popups and
no network. The page talks to the editor with window.parent.postMessage
messages — set one or several parameters (several are one undo entry), write
arbitrary data, press a hidden button with a payload, ask for a rendered preview
of the layer, and forward undo / redo — and receives the effect’s state after
every change.
A dockable panel (1.2) has no effect behind it: a manifest panels entry
{ "id", "name", "page" } shows ui/<page> in the same sandbox. It opens from
Plugins ▸ Panels, docks like any panel and pops out into its own window. Its
messages are engine API queries and edit commands (one undo entry, or
several as one), and it receives the active composition, the selected layers and
the time after every document, selection or time change.
Plugins that are not effects
| After Effects | Premation | Header | Sample | |
|---|---|---|---|---|
| Command | AEGP menu command | manifest commands, PrCommandEntry | pr_command.h | distribute |
| Importer | AEIO input | manifest importers, PrImporterEntry | pr_io.h | ppmio |
| Output module | AEIO output | manifest outputModules, PrOutputModuleEntry | pr_io.h | ppmseq |
| Panel | AEGP panel | manifest panels (no code: a page) | — | statspanel |
Commands. A manifest entry { "id", "name", "menu" } puts the command in the
Plugins menu (in a submenu named by menu, or by the plugin) and the command
palette. PR_COMMAND_RUN receives the active composition, the selected layers,
the playhead and the frame rate, and reads and edits the document through the
engine API as JSON (engine_query, engine_command) — the same API the
editor speaks. Only edit commands are allowed, and one run is one undo entry:
if the command returns an error, any edit fails or the plugin crashes, all of it
rolls back.
Importers. A manifest entry lists file extensions; those files then import
and render through the plugin (PR_IMPORT_PROBE, OPEN, READ_FRAME,
READ_AUDIO, CLOSE). The footage plays, exports, and is what the tracker and
the other analysis jobs read; its sound reaches the mixer and the export.
Output modules. A manifest entry with an extension and options (popup,
number and checkbox controls) becomes an export format in the Export dialog’s
Plugins category and the Render Queue (PR_OUTPUT_BEGIN, WRITE_FRAME,
WRITE_AUDIO, END). The export job runs the module in its own process, and the
module writes its files itself.
Sequence data
Per-instance state lives in the document, not only in the process. When an
effect is added, the host runs SEQUENCE_SETUP, then FLATTEN, then
SETDOWN, and stores the flat bytes with the effect in the same undo entry. A
render rebuilds the instance from those bytes with RESETUP whenever they
change. A button press (USER_CHANGED_PARAM) produces new bytes and any
parameter writes as one undo entry, so undo restores the old bytes and the
old bytes render the old picture.
The rings sample uses this to keep a random palette: shuffle, undo and redo
all render exactly.
The GPU path
A GPU_RENDER effect records WebGPU commands into the host’s command
encoder, on the engine’s own Dawn device. You receive the chain’s texture as it
is and write to a texture the host owns; nothing is copied or read back.
- Call WebGPU through
device->procs, theDawnProcTablethe host hands you. Never link a second copy of Dawn. - The call runs inside a crash guard and a validation and out-of-memory error scope. A GPU error drops your command buffer unsubmitted and the effect renders on its CPU path for that frame.
GPU_DEVICE_SETUPruns once per device; if it fails, the effect uses its CPU path on that device. When the device changes, the host sendsGPU_DEVICE_SETDOWNfor the old one first.- Keep the two paths the same maths. At 16 bits per channel they can still differ on over-range values: the CPU world is integer and clips, the GPU buffer is half-float and does not.
Versioning
The SDK is versioned major.minor. The plugin’s major must equal the host’s
major, and the plugin’s minor must be no greater than the host’s — a plugin
whose manifest says "minor": 2 fails on an older engine with “needs SDK 1.2”.
Minor versions only add — new selectors, parameter types, host callbacks
(appended to the end of the host suite), trailing struct fields and new flags.
Every struct starts with struct_size; a field past the size the other side
reported does not exist, so check it before calling a newer callback. Enum values
are forever: a removed value is never reused. The SDK in this release is
1.2, and 1.0 and 1.1 plugins load and render unchanged.
What the host guarantees
Every selector call runs behind a crash guard (structured exception handling on Windows; signal handlers on an alternate stack on macOS) and a crash journal.
| What your plugin does | What happens |
|---|---|
| Faults or throws in a render selector | That instance is disabled; its layer renders as if the effect were off, the failure is on the layer’s error list, and everything else continues |
| Faults in setup, params or sequence selectors | The plugin fails to load and is listed as Failed with the reason |
| Returns an error | The call fails and the frame renders without the effect; the instance stays enabled |
| Faults in a command, importer or output module | The command rolls back and is off for the session; the importer is off for the session; the export job fails. The engine carries on |
| Hangs longer than the watchdog (10 s by default) | The plugin is quarantined and the engine ends; the supervisor restarts it and replays the document |
Calls abort() or exit(), or corrupts the heap without faulting | The engine dies. The next start quarantines the plugin |
A quarantined plugin is listed but not loaded. On macOS a stack overflow is reported as an access violation.
The samples
The samples live in the SDK’s samples/ folder (native/sdk/samples/ in the
repository), one per idea:
| Sample | Shows |
|---|---|
ripple | A CPU effect with smart render at 8, 16 and 32 bits, threaded rows |
rings | A generator with sequence data, a button (USER_CHANGED_PARAM), a viewer overlay and an effect panel |
checkout | Layer checkouts: Layer Displace reads another layer, Time Echo reads its own layer at other times |
grade | A GPU effect (SMART_RENDER_GPU) with a CPU fallback |
grademap | The 1.1 parameter types — string, curve, gradient and a file (a .cube LUT) |
particles | The comp camera and lights (1.1); trails and its own motion blur through other times, comp space and presets (1.2) |
model3d | An Element 3D class renderer: a glTF model through the comp camera and lights, on the GPU and the CPU, with motion blur, scene-depth occlusion, a 3D handle and a material panel |
silkwaves | A looping generator, GPU with a CPU path |
distribute | A command (Distribute Layers) |
ppmio | An importer for .ppm / .pfm, including moving footage and sound |
ppmseq | An output module writing a .ppm sequence, with a .wav beside it |
statspanel | A dockable panel and a command |
Most effect samples have a Debug ▸ Fault popup that injects each class of
fault, which the host’s tests use to prove every row of the table above. Building the
samples lays each out as an installable bundle, at <build>/plugins/<name>/, in
exactly the folder layout the engine loads.
Testing without the editor
premation-plugins runs the plugin host on its own. It ships in the SDK, not in
the app installer.
# list what a plugin folder loads to
premation-plugins --plugins <build>/plugins list
# render an effect on a test pattern at 32 bits per channel
premation-plugins --plugins <build>/plugins --bits 32 render com.premation.samples.ripple
# inject a fault and prove it is contained (2 = access violation)
premation-plugins --plugins <build>/plugins crash-check com.premation.samples.ripple 2| Option | Meaning |
|---|---|
--plugins <dir> | A bundle folder, or a folder of bundles. Repeatable. Defaults to PREMATION_PLUGIN_PATH |
--bits 8|16|32 | The project bit depth to render at (default 8) |
--size WxH | The test pattern’s size (default 64x48) |
--journal <file> | The crash journal; plugins that died last time are quarantined |
--watchdog <ms> | A call longer than this is a hang (default 10000) |
--enable <pluginId> | Enable a plugin before the command, clearing a quarantine |
render prints ok followed by a hash of the output pixels, or fail and the
message. crash-check renders one instance with the fault and expects it
contained, then renders a second instance and expects pixels. The exit code
is 0 on success, 1 when the check failed and 2 for a usage error. The test host
has no GPU depth pass, so scene-depth checkouts answer PR_ERR_UNSUPPORTED there.
To render a whole scene with plugin effects outside the editor, the engine’s
premation-render tool takes --plugins <dir>, and --plugin-gpu 0 forces the
CPU path.
Installing what you built
Copy the bundle into the plugins folder and press Rescan — see
Plugins — or point
PREMATION_PLUGIN_PATH at the folder while you develop. To hand a plugin to
other people, pack and sign it, and publish it to the store if you like: see
Publishing a plugin.