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:

my-extension/recordly-extension.json
{
  "id": "yourname.my-extension",
  "name": "My Extension",
  "version": "1.0.0",
  "description": "Does something useful",
  "author": "Your Name",
  "main": "index.js",
  "permissions": ["render"]
}
my-extension/index.js
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.

FieldTypeReqDescription
idstringUnique ID, e.g. "yourname.cool-effect"
namestringHuman-readable name shown in the UI
versionstringSemver version (1.0.0)
descriptionstringOne-line description
authorstringAuthor name or organisation
homepagestringHomepage / repo URL
licensestringSPDX license identifier
enginestringMinimum Recordly version (e.g. 1.0.0)
iconstringRelative path to icon image (PNG, 256×256)
mainstringEntry point JS file (e.g. index.js)
permissionsstring[]Required permissions (see below)
contributesobjectOptional asset metadata (frames, cursor styles, sounds, wallpapers, webcam frames); runtime registration still happens in activate().

Permissions

render

Hook into the frame render pipeline

cursor

Access cursor telemetry & register cursor effects

audio

Play bundled audio assets

timeline

Observe playback and timeline events

ui

Register settings panels and frames

assets

Resolve bundled asset paths and register wallpapers or cursor styles

export

Hook into export lifecycle

The contributes field is metadata only today. Recordly does not auto-register frames, panels, or runtime behavior from the manifest; wire those up in activate().

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): void

Project 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:

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.

background
Reserved pre-video phase; currently not dispatched by the renderer
scene transform applied
Inside the scene transform
post-videopost-zoompost-cursorcursor effects

These phases already follow zoom and motion in both preview and export.

canvas transform restored
Outside the scene transform
post-webcampost-annotationsfinal

These phases run after the built-in transform. Use sceneTransform manually if you want overlays to move with the scene.

Inside the scene transform

3
post-videopost-zoompost-cursor

These phases already follow zoom and motion in both preview and export.

Outside the scene transform

3
post-webcampost-annotationsfinal

These phases run after the built-in transform. Use sceneTransform manually if you want overlays to move with the scene.

The background phase exists in the type surface for forward compatibility, but the current renderer does not dispatch it yet.
  1. background - Reserved pre-video phase; currently not dispatched by the renderer
  2. post-video - After video frame, before zoom transform
  3. post-zoom - After zoom transform applied
  4. post-cursor - After cursor is drawn (click effects, trails)
  5. post-webcam - After webcam overlay
  6. post-annotations - After annotations rendered
  7. final - 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.

Example: Scene-following Border
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.

CursorEffectContext
{
  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;
  };
}
Example: Scene-relative Ring
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:

toggle

Boolean on/off switch

slider

Numeric range with min/max/step

select

Dropdown with predefined options

color

Color picker (hex string)

text

Free text input

Settings are stored per extension. Use parentSection to nest your panel inside an existing area such as cursor or scene.

Example
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.

EventDescription
playback:timeupdateFires each frame with current time
playback:playPlayback started
playback:pausePlayback paused
cursor:clickCursor click detected (includes position)
cursor:moveCursor moved (includes position)
timeline:region-addedA timeline region was created
timeline:region-removedA timeline region was deleted
export:startExport process started
export:frameSingle frame rendered during export
export:completeExport finished
Example
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.

Contributed assets are discoverable metadata, not auto-executed behavior. If your extension needs runtime registration, do it from activate().
Manifest with Assets
{
  "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 }
      }
    ]
  }
}
activate()
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.

Canvas Draw Function
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.

SVG Frame
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.

PNG Frame
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:

browserlaptopphonetabletdesktopcustom

Publishing

Packaging

Create a .zip archive with recordly-extension.json at the root (or inside a single top-level subfolder).

Terminal
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.

POST /extensions is public for new uploads. Public re-submission is still disabled until extension ownership verification exists.

Marketplace Publish Flow

This is the current production path from packaged extension to in-app installation.

Package extension
.zip + recordly-extension.json
Public upload
POST /extensions
Pending review
review_status = pending
Approved listing
Browse + download API
Install in Recordly
Extensions → Browse
cURL
curl -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).

GET/extensions

Search 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
}
GET/extensions/:id

Get 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"
}
GET/extensions/:id/download

Download the .zip archive (approved only). Increments download count.

Response

Binary .zip file
POST/extensions

Upload 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" }
POST/extensions/:id/submit

Public 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.

GET/admin/reviews

List extensions by review status.

Parameters

status(string)"pending" | "approved" | "rejected" | "flagged".

Response

{
  "reviews": [{ "id": "review-author.ext-name", "status": "pending", ... }],
  "total": 1
}
PATCH/admin/reviews/:id

Approve, reject, or flag an extension.

Parameters

status(string)"pending" | "approved" | "rejected" | "flagged".
notes(string)Optional reviewer notes.

Response

{ "success": true }
GET/admin/reviews/:id/download

Download 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:

recordly-extension.json
{
  "id": "recordly.click-effects",
  "name": "Click Effects",
  "version": "1.0.0",
  "description": "Beautiful cursor click animations",
  "author": "Recordly",
  "main": "index.js",
  "permissions": ["cursor", "ui"]
}
index.js
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))})\`;
}