Recordly Extensions
Build extensions that hook into every part of the recording & editing pipeline — render effects, cursor animations, audio, settings panels, and more.
Getting Started
A Recordly extension is a folder containing a recordly-extension.json manifest and a JavaScript entry point. Extensions run inside the editor renderer and can draw on the canvas, react to events, and register settings.
Minimal Example
Create a folder with two files:
{
"id": "yourname.my-extension",
"name": "My Extension",
"version": "1.0.0",
"description": "Does something useful",
"author": "Your Name",
"main": "index.js",
"permissions": ["render"]
}export function activate(api) {
api.registerRenderHook('final', (hookCtx) => {
hookCtx.ctx.fillStyle = 'rgba(255,255,255,0.6)';
hookCtx.ctx.font = '14px sans-serif';
hookCtx.ctx.fillText('Hello from my extension', 24, 32);
});
api.log('My Extension activated');
}
export function deactivate() {}Installing Locally
Use Extensions -> Open Directory to reveal the real install location. Recordly stores user extensions in the app's userData/extensions folder rather than ~/.recordly/extensions. After copying your extension in, relaunch Recordly and check Extensions → Installed.
Extension Manifest
Every extension must contain a recordly-extension.json file at its root. This is validated on load and on marketplace upload.
| Field | Type | Req | Description |
|---|---|---|---|
id | string | ✓ | Unique ID, e.g. "yourname.cool-effect" |
name | string | ✓ | Human-readable name shown in the UI |
version | string | ✓ | Semver version (1.0.0) |
description | string | ✓ | One-line description |
author | string | Author name or organisation | |
homepage | string | Homepage / repo URL | |
license | string | SPDX license identifier | |
engine | string | Minimum Recordly version (e.g. 1.0.0) | |
icon | string | Relative path to icon image (PNG, 256×256) | |
main | string | ✓ | Entry point JS file (e.g. index.js) |
permissions | string[] | ✓ | Required permissions (see below) |
contributes | object | Optional asset metadata (frames, cursor styles, sounds, wallpapers, webcam frames); runtime registration still happens in activate(). |
Permissions
renderHook into the frame render pipeline
cursorAccess cursor telemetry & register cursor effects
audioPlay bundled audio assets
timelineObserve playback and timeline events
uiRegister settings panels and frames
assetsResolve bundled asset paths and register wallpapers or cursor styles
exportHook into export lifecycle
Extension API
When your extension's activate(api) function is called, it receives a RecordlyExtensionAPI object with these methods:
Registration Surface
Register draw hooks, cursor effects, frames, wallpapers, cursor styles, settings panels, and event listeners. Every registration returns a disposer.
registerRenderHook(phase, hook): () => void registerCursorEffect(effect): () => void registerFrame(frame): () => void registerWallpaper(wallpaper): () => void registerCursorStyle(cursorStyle): () => void registerSettingsPanel(panel): () => void on(event, handler): () => void
Settings Surface
Read, write, and observe per-extension settings. Values persist between launches.
getSetting(settingId): unknown
setSetting(settingId, value): void
onSettingChange((settingId, value) => { ... }): () => void
getAllSettings(): Record<string, unknown>Assets, Audio, and Logging
Resolve bundled files, play local audio, and write namespaced log output.
resolveAsset(relativePath): string
playSound(relativePath, { volume? }): () => void
log(message, ...args): voidProject and Playback Queries
Read scene, cursor, zoom, playback, frame, and canvas state without mutating the editor.
getVideoInfo() getVideoLayout() getCursorAt(timeMs) getSmoothedCursor() getZoomState() getShadowConfig() getKeystrokesInRange(startMs, endMs) getAspectRatio() getActiveFrame() isExtensionActive(extensionId) getPlaybackState() getCanvasDimensions()
Render Hooks
Render hooks let you draw directly on the output canvas at specific points in the render pipeline. Each hook receives a RenderHookContext:
{
width: number;
height: number;
timeMs: number;
durationMs: number;
cursor: { cx: number; cy: number; interactionType?: string } | null;
smoothedCursor?: {
cx: number;
cy: number;
trail: Array<{ cx: number; cy: number }>;
} | null;
ctx: CanvasRenderingContext2D;
videoLayout?: {
maskRect: { x: number; y: number; width: number; height: number };
borderRadius: number;
padding: number;
};
zoom?: { scale: number; focusX: number; focusY: number; progress: number };
sceneTransform?: { scale: number; x: number; y: number };
shadow?: { enabled: boolean; intensity: number };
getPixelColor(x: number, y: number): { r: number; g: number; b: number; a: number };
getAverageSceneColor(): { r: number; g: number; b: number; a: number };
getEdgeAverageColor(edgeWidth?: number): { r: number; g: number; b: number; a: number };
getDominantColors(count?: number): Array<{ r: number; g: number; b: number; frequency: number }>;
}Pipeline Phases
Hooks execute in this order each frame:
Render Flow Diagram
This is the effective render order used by both preview and export in the current system.
post-videopost-zoompost-cursorcursor effectsThese phases already follow zoom and motion in both preview and export.
post-webcampost-annotationsfinalThese phases run after the built-in transform. Use sceneTransform manually if you want overlays to move with the scene.
Inside the scene transform
3post-videopost-zoompost-cursorThese phases already follow zoom and motion in both preview and export.
Outside the scene transform
3post-webcampost-annotationsfinalThese phases run after the built-in transform. Use sceneTransform manually if you want overlays to move with the scene.
background- Reserved pre-video phase; currently not dispatched by the rendererpost-video- After video frame, before zoom transformpost-zoom- After zoom transform appliedpost-cursor- After cursor is drawn (click effects, trails)post-webcam- After webcam overlaypost-annotations- After annotations renderedfinal- Last pass (watermarks, HUD overlays)
For scene-relative sizing and true rounded borders, use videoLayout.maskRect and videoLayout.borderRadius instead of raw canvas width and height.
export function activate(api) {
api.registerRenderHook('post-video', (hookCtx) => {
const layout = hookCtx.videoLayout;
if (!layout) return;
const { x, y, width, height } = layout.maskRect;
const half = 5;
hookCtx.ctx.save();
hookCtx.ctx.globalAlpha = 0.6;
hookCtx.ctx.strokeStyle = '#ffffff';
hookCtx.ctx.lineWidth = 10;
hookCtx.ctx.beginPath();
hookCtx.ctx.roundRect(
x - half,
y - half,
width + half * 2,
height + half * 2,
layout.borderRadius + half,
);
hookCtx.ctx.stroke();
hookCtx.ctx.restore();
});
}Cursor Effects
Cursor effects animate at click positions. Your effect function is called every frame after a click occurs, until you return false.
Cursor effects now receive videoLayout, zoom, and sceneTransform. Use videoLayout.maskRect to size effects as a percentage of the scene rather than the full canvas.
{
timeMs: number;
cx: number;
cy: number;
interactionType: 'click' | 'double-click' | 'right-click' | 'mouseup';
width: number;
height: number;
ctx: CanvasRenderingContext2D;
elapsedMs: number;
zoom?: { scale: number; focusX: number; focusY: number; progress: number };
sceneTransform?: { scale: number; x: number; y: number };
videoLayout?: {
maskRect: { x: number; y: number; width: number; height: number };
borderRadius: number;
padding: number;
};
}api.registerCursorEffect((ctx) => {
const progress = ctx.elapsedMs / 400;
if (progress >= 1) return false;
const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width;
const x = ctx.cx * ctx.width;
const y = ctx.cy * ctx.height;
const radius = progress * sceneWidth * 0.03;
const alpha = 1 - progress;
ctx.ctx.beginPath();
ctx.ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.ctx.lineWidth = 2;
ctx.ctx.stroke();
return true;
});Settings Panels
Register a settings panel to give users control over your extension. Supports five field types:
toggleBoolean on/off switch
sliderNumeric range with min/max/step
selectDropdown with predefined options
colorColor picker (hex string)
textFree text input
Settings are stored per extension. Use parentSection to nest your panel inside an existing area such as cursor or scene.
api.registerSettingsPanel({
id: 'my-settings',
label: 'My Extension',
icon: 'sparkles',
parentSection: 'cursor',
fields: [
{ id: 'enabled', label: 'Enable Effect', type: 'toggle', defaultValue: true },
{ id: 'intensity', label: 'Intensity', type: 'slider', defaultValue: 0.5, min: 0, max: 1, step: 0.1 },
{
id: 'style', label: 'Style', type: 'select', defaultValue: 'smooth',
options: [{ label: 'Smooth', value: 'smooth' }, { label: 'Sharp', value: 'sharp' }],
},
{ id: 'color', label: 'Color', type: 'color', defaultValue: '#2563EB' },
],
});
const enabled = api.getSetting('enabled');
api.onSettingChange((settingId, value) => {
api.log('setting changed', settingId, value);
});Events
Subscribe to events to react to playback, cursor, timeline, and export state:
Permission gates apply here: playback:* and timeline:* require timeline, cursor:* requires cursor, and export:* requires export.
| Event | Description |
|---|---|
playback:timeupdate | Fires each frame with current time |
playback:play | Playback started |
playback:pause | Playback paused |
cursor:click | Cursor click detected (includes position) |
cursor:move | Cursor moved (includes position) |
timeline:region-added | A timeline region was created |
timeline:region-removed | A timeline region was deleted |
export:start | Export process started |
export:frame | Single frame rendered during export |
export:complete | Export finished |
api.on('cursor:click', (event) => {
api.log('Click at time:', event.timeMs);
});
api.on('export:start', () => {
api.log('Export started - switching to export state');
});Contributing Assets
Extensions can contribute bundled assets — cursor styles, sound effects, wallpapers, webcam frames, and device frames — via the contributes manifest field.
{
"id": "yourname.asset-pack",
"name": "Cool Asset Pack",
"version": "1.0.0",
"description": "Cursor styles, sounds, and wallpapers",
"main": "index.js",
"permissions": ["assets"],
"contributes": {
"cursorStyles": [
{
"id": "neon-pointer",
"label": "Neon Pointer",
"defaultImage": "cursors/neon.png",
"clickImage": "cursors/neon-click.png",
"hotspot": { "x": 0.1, "y": 0.1 }
}
],
"sounds": [
{
"id": "soft-click",
"label": "Soft Click",
"category": "click",
"file": "sounds/soft-click.wav"
}
],
"wallpapers": [
{
"id": "gradient-sunset",
"label": "Sunset Gradient",
"file": "wallpapers/sunset.png",
"thumbnail": "wallpapers/sunset-thumb.png"
}
],
"webcamFrames": [
{
"id": "circle-frame",
"label": "Circle Frame",
"file": "frames/circle.png"
}
],
"frames": [
{
"id": "macbook-bezel",
"label": "MacBook Bezel",
"file": "frames/macbook.svg",
"thumbnail": "frames/macbook-thumb.png",
"screenInsets": { "top": 0.025, "right": 0.06, "bottom": 0.08, "left": 0.06 }
}
]
}
}export function activate(api) {
api.registerCursorStyle({
id: 'neon-pointer',
label: 'Neon Pointer',
defaultImage: 'cursors/neon.png',
clickImage: 'cursors/neon-click.png',
hotspot: { x: 0.1, y: 0.1 },
});
api.registerWallpaper({
id: 'gradient-sunset',
label: 'Sunset Gradient',
file: 'wallpapers/sunset.png',
thumbnail: 'wallpapers/sunset-thumb.png',
});
}
export function deactivate() {}Frames
Frames wrap the recorded screen in a browser window, laptop bezel, phone mockup, etc. Extensions can register frames using three approaches:
Frame registration is a UI capability, so your manifest needs the ui permission.
Canvas Draw Function (recommended)
A draw(ctx, width, height) function that renders the frame chrome programmatically. Resolution-independent — stays crisp at any export size. This is how the built-in browser and macOS window frames work.
api.registerFrame({
id: 'safari-window',
label: 'Safari',
category: 'browser',
appearance: 'light',
screenInsets: { top: 0.052, right: 0.005, bottom: 0.005, left: 0.005 },
draw(ctx, W, H) {
// Draw title bar, traffic lights, URL bar...
const titleBarH = Math.round(H * 0.052);
ctx.fillStyle = '#F6F6F6';
roundRect(ctx, 0, 0, W, H, 10);
ctx.fill();
// Cut out screen area (make transparent)
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = '#000';
ctx.fillRect(0, titleBarH, W, H - titleBarH);
},
});SVG File
Provide an .svg file in the extension folder. SVGs are vector-based so they scale cleanly to any resolution. Good for complex mockups like laptop bezels.
api.registerFrame({
id: 'macbook-bezel',
label: 'MacBook Pro',
category: 'laptop',
appearance: 'dark',
file: 'frames/macbook.svg', // SVG in extension folder
thumbnail: 'frames/macbook-thumb.png',
screenInsets: { top: 0.025, right: 0.06, bottom: 0.08, left: 0.06 },
});PNG File
Provide a .png file with transparency. Simple but may show scaling artifacts at very high resolutions. Best for fixed-size decorative frames.
api.registerFrame({
id: 'iphone-mockup',
label: 'iPhone 15 Pro',
category: 'phone',
file: 'frames/iphone15.png', // PNG with transparency
screenInsets: { top: 0.06, right: 0.05, bottom: 0.06, left: 0.05 },
});All frames must specify screenInsets — fractional (0-1) insets defining where the video content sits inside the frame.
Categories
Frames are grouped by category in the picker UI:
browserlaptopphonetabletdesktopcustomPublishing
Packaging
Create a .zip archive with recordly-extension.json at the root (or inside a single top-level subfolder).
cd my-extension zip -r ../my-extension.zip .
Submitting
Upload your extension zip directly from the submit page or call POST /extensions with multipart/form-data.
Marketplace Publish Flow
This is the current production path from packaged extension to in-app installation.
.zip + recordly-extension.jsonPOST /extensionsreview_status = pendingBrowse + download APIExtensions → Browsecurl -X POST https://marketplace.recordly.dev/extensions/api/v1/extensions \ -F "[email protected]"
Accepted uploads enter the pending review queue. Approved extensions appear in the Browse tab and the download API.
Extension IDs must be unique. POST /extensions/:id/submit currently returns 409 until ownership verification exists.
Marketplace API
Base URL: https://marketplace.recordly.dev/extensions/api/v1 (or http://localhost:3001/extensions/api/v1 for local dev).
/extensionsSearch the marketplace for approved extensions.
Parameters
query(string)Search name, description, and tags.sort(string)"popular" (default) or "recent".page(number)1-based page number.pageSize(number)Results per page (default 50, max 100).Response
{
"extensions": [
{
"id": "author.ext-name",
"name": "Extension Name",
"description": "One-line summary",
"version": "1.0.0",
"author": "Author",
"permissions": ["render", "cursor"],
"downloads": 42,
"rating": 4.5,
"reviewStatus": "approved",
"publishedAt": "2026-04-10 12:00:00",
"updatedAt": "2026-04-10 12:00:00",
"downloadUrl": ".../extensions/author.ext-name/download"
}
],
"total": 1,
"page": 1,
"pageSize": 50
}/extensions/:idGet a single approved extension, including its manifest.
Response
{
"id": "author.ext-name",
"name": "Extension Name",
"description": "One-line summary",
"version": "1.0.0",
"author": "Author",
"permissions": ["render", "cursor"],
"tags": ["cursor", "fx"],
"downloads": 42,
"rating": 4.5,
"ratingCount": 0,
"reviewStatus": "approved",
"publishedAt": "2026-04-10 12:00:00",
"downloadUrl": ".../extensions/author.ext-name/download",
"manifest": { ... },
"createdAt": "2026-04-10 12:00:00",
"updatedAt": "2026-04-10 12:00:00"
}/extensions/:id/downloadDownload the .zip archive (approved only). Increments download count.
Response
Binary .zip file
/extensionsUpload a new extension zip for review. Public for new uploads; requires multipart/form-data.
Parameters
file(File (.zip))Extension zip archive.Response
{ "success": true, "id": "author.ext-name" }/extensions/:id/submitPublic re-submission endpoint. Currently returns 409 until ownership verification exists.
Response
{
"error": "Public re-submission is disabled until extension ownership is verified.",
"reviewStatus": "rejected"
}Admin Endpoints
Requires X-Admin-Key header matching the ADMIN_API_KEY environment variable.
/admin/reviewsList extensions by review status.
Parameters
status(string)"pending" | "approved" | "rejected" | "flagged".Response
{
"reviews": [{ "id": "review-author.ext-name", "status": "pending", ... }],
"total": 1
}/admin/reviews/:idApprove, reject, or flag an extension.
Parameters
status(string)"pending" | "approved" | "rejected" | "flagged".notes(string)Optional reviewer notes.Response
{ "success": true }/admin/reviews/:id/downloadDownload any submitted zip for reviewer inspection. Requires X-Admin-Key.
Response
Binary .zip file
Full Example: Click Effects
The built-in click effects extension demonstrating settings, cursor effects, and multiple animation styles:
{
"id": "recordly.click-effects",
"name": "Click Effects",
"version": "1.0.0",
"description": "Beautiful cursor click animations",
"author": "Recordly",
"main": "index.js",
"permissions": ["cursor", "ui"]
}const EFFECT_DURATION_MS = 600;
export function activate(api) {
api.setSetting('effectStyle', 'ripple');
api.setSetting('effectColor', '#2563EB');
api.setSetting('effectSize', 1.0);
api.setSetting('enabled', true);
api.registerSettingsPanel({
id: 'click-effects-settings',
label: 'Click Effects',
icon: 'sparkles',
parentSection: 'cursor',
fields: [
{ id: 'enabled', label: 'Enable', type: 'toggle', defaultValue: true },
{
id: 'effectStyle', label: 'Style', type: 'select',
defaultValue: 'ripple',
options: [
{ label: 'Ripple', value: 'ripple' },
{ label: 'Sparkle', value: 'sparkle' },
{ label: 'Pulse', value: 'pulse' },
],
},
{ id: 'effectColor', label: 'Color', type: 'color', defaultValue: '#2563EB' },
{ id: 'effectSize', label: 'Size', type: 'slider', defaultValue: 1.0, min: 0.3, max: 2.0, step: 0.1 },
],
});
api.registerCursorEffect((ctx) => {
if (!api.getSetting('enabled')) return false;
const progress = ctx.elapsedMs / EFFECT_DURATION_MS;
if (progress >= 1) return false;
const x = ctx.cx * ctx.width;
const y = ctx.cy * ctx.height;
const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width;
const color = api.getSetting('effectColor') || '#2563EB';
const size = api.getSetting('effectSize') || 1.0;
const eased = 1 - Math.pow(1 - progress, 3);
const radius = eased * sceneWidth * 0.03 * size;
const alpha = (1 - eased) * 0.6;
ctx.ctx.beginPath();
ctx.ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.ctx.strokeStyle = hexToRgba(color, alpha);
ctx.ctx.lineWidth = Math.max(1, 2 * (1 - eased));
ctx.ctx.stroke();
return true;
});
api.log('Click Effects activated');
}
export function deactivate() {}
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return \`rgba(\${r}, \${g}, \${b}, \${Math.max(0, Math.min(1, alpha))})\`;
}