Plugin Development · Adobe Premiere Pro · UXP

UXP Plugin Development:
What Adobe Doesn't Tell You

I'm a video editor. I cut to the beat — music videos, trailers, reels. One day I got tired of placing markers by hand and decided to build my own plugin. This is everything I wish I had known before starting.

BeatMarker Premiere screenshot

UXP: Adobe's Beautiful Promise

Adobe said it was simple. "Build plugins with HTML, CSS, and JavaScript," they said. "It's just a web page inside Premiere." And in a way, that's true — until it isn't.

UXP (Unified Extensibility Platform) is Adobe's modern runtime that replaced CEP (Chrome Embedded Framework) and old ExtendScript across Premiere Pro, Photoshop, InDesign, and others. It runs a proprietary JavaScript engine — not V8, not full Node.js — with access to native app APIs through specific modules like require('premierepro').

The pitch is solid: you write HTML + vanilla JS, use real CSS, communicate with Premiere through a documented API, and ship your plugin as a .ccx file. No C++, no deep SDK. For a video editor who knows a bit of JavaScript, it sounds like the perfect entry point.

🚫 Never use CEP or ExtendScript documentation as a reference for UXP. The APIs are completely different. Everything you find about the old system will mislead you.
"I wasn't a developer. I just wanted my beat-synced cut workflow to work better. So I opened the docs and started reading."

What Adobe Forgot to Mention

Here's where things get interesting. UXP is a sandboxed environment. It's not a browser. It's not Node.js. It's its own thing — and it has some very specific holes where features you'd expect simply don't exist.

I found these out the hard way, usually when Premiere Pro crashed, froze, or silently did nothing.

Works ✓

  • require('premierepro')
  • require('fs') — async
  • fetch('https://...')
  • navigator.language
  • TextDecoder / Encoder
  • BigInt
  • uxp.shell.openExternal()

Breaks ✗

  • new Worker(...)
  • AudioContext
  • fs.readFileSync
  • fetch('file://...')
  • WebAssembly + pthreads
  • SharedArrayBuffer
  • require('child_process')

That last one — WebAssembly with pthreads — deserves its own story. I tried using a WASM-based MP3 decoder. Premiere Pro crashed immediately, every single time, with zero error messages. It took me days to figure out why. I'll get to that in a moment.

"AudioContext? Not in this neighborhood."

Reading an Audio File Should Be Simple

My plugin needs to read an audio file — a WAV or MP3 from the user's drive — to detect the BPM. Sounds simple. In any browser, in any Node.js app, you'd do this in 5 lines. In UXP, there are two specific traps.

Trap #1: readFileSync doesn't exist

In UXP, fs.readFileSync throws Route not found. There's no sync filesystem API. Everything is async/await. That's fine — just make sure you're not copying Node.js patterns without thinking.

Trap #2: The buffer is a fake

When fs.readFile() returns, you don't get a real ArrayBuffer. You get a UXP proxy ArrayBuffer — an object that looks like an ArrayBuffer but behaves differently when you try to process it with audio decoding code. The fix is to copy it into a real native buffer before doing anything else:

const raw = await fs.readFile('/path/to/audio.wav');

// ⚠️ raw is a UXP proxy — copy it first!
const byteLength = raw.byteLength ?? raw.length;
const nativeBuffer = new ArrayBuffer(byteLength);
new Uint8Array(nativeBuffer).set(new Uint8Array(raw));

One more thing: if your plugin reads files, you must declare it in manifest.json. Otherwise the permission is silently denied:

"requiredPermissions": {
  "localFileSystem": "fullAccess"
}

The Markers That Lie to You

Once you have your BPM and beat timestamps, you need to create markers on the clip in Premiere. The Markers API looks clean in the docs. In practice, there are a few things that will waste your time.

Always use getter methods

This one burned me. If you try to access marker properties directly, they return undefined:

marker.name        // ❌ undefined — always
marker.colorIndex  // ❌ undefined — always

marker.getName()        // ✅
marker.getColorIndex()  // ✅
marker.getStart()       // ✅  →  TickTime
marker.getDuration()    // ✅

Cast first, then get markers

To access markers on a source clip, you can't just call getMarkers(projectItem) directly — it returns null. You have to cast the item first:

// ❌ This returns null
const markers = await ppro.Markers.getMarkers(projectItem);

// ✅ Cast first
const clipPI = ppro.ClipProjectItem.cast(projectItem);
const clipMarkers = await ppro.Markers.getMarkers(clipPI);

Premiere's 8 native marker colors

Marker colors are set by index — there's no free color picker. Here are the 8 available colors:

IndexColorHex
0🟢 Green#4aad4a
1🔴 Red#d53a3a
2🟣 Purple#a06db5
3🟠 Orange#e8832a
4🟡 Yellow#d4a017
5⚪ White#d0d0d0
6🔵 Blue#4084e5
7🩵 Cyan#1ab3a6
"When Premiere freezes because of your plugin, you learn fast what not to do."

50 Is the Magic Number

Every write to the Premiere Pro API must happen inside an executeTransaction() call. That's fine — it enables undo history. But there's a limit nobody documents: roughly 50 actions per transaction. Go over that limit and Premiere hangs silently.

The fix is batching. If you're creating 200 markers, split them into 4 transactions of 50:

const BATCH_SIZE = 50;

for (let i = 0; i < beats.length; i += BATCH_SIZE) {
  const batch = beats.slice(i, i + BATCH_SIZE);

  await project.executeTransaction(async (ca) => {
    for (const beat of batch) {
      ca.addAction(
        clipMarkers.createAddMarkerAction(
          `[BM] ${beat.index}`,
          'Comment',
          ppro.TickTime.createWithSeconds(beat.time),
          ppro.TickTime.createWithSeconds(0),
          'beatmarker-guid'
        )
      );
    }
  }, `Add beat markers — batch ${Math.floor(i / BATCH_SIZE) + 1}`);
}
⚠️ Also: never use continue inside a transaction callback. It can behave unpredictably. Pre-filter your array before entering the transaction loop.

CSS Inside Premiere Is a Different Dimension

UXP renders a real HTML/CSS interface, which is great. But there are some specificity rules that don't behave the way you'd expect from a browser.

The most painful one: ID selectors with !important override class selectors with !important. That sounds normal — until you're building a dynamic UI and wondering why your toggle button won't change color no matter what class you add to it.

The safest solution for dynamic visual state: use inline styles instead of class toggling:

// ❌ This might not work — class selectors lose to ID !important
beatBox.classList.add('active');

// ✅ This always works
beatBox.style.backgroundColor = '#d53a3a';
beatBox.style.color = '#fff';

One more UXP CSS gotcha: line-height must be at least 1.5 on small italic text, otherwise descenders get clipped by the rendering engine.

The MP3 Problem: WASM is a Trap

My plugin needed to support MP3 files. WAV was easy — I wrote a pure JavaScript decoder using DataView. But MP3 is complex, and I figured I'd use a proper library.

Here's what I tried, and what happened:

ApproachResult in UXP
WASM with pthreads💥 Crashes Premiere instantly
mpg123-decoder (single-thread WASM)💥 Still crashes Premiere on analysis
AudioContext.decodeAudioData🚫 Doesn't exist in UXP
Web Workers🚫 typeof Worker === 'undefined'
js-mp3 (pure JS)✅ Works perfectly

The solution was js-mp3 — a 100% pure JavaScript MP3 decoder. No WASM, no Workers. Just math and arrays. It's slower, but it works inside UXP without crashing anything.

There's also a timing correction you have to apply. The js-mp3 decoder introduces a startup delay of 2070 samples from the MDCT warm-up phase. Add the encoder delay from the file's Xing/LAME header, and you have to strip those samples from the output before running beat detection — otherwise every beat timestamp will be slightly off.

"I spent three days trying to make WASM work. On day four, I wrote a pure JS decoder and it worked on the first try."

Packaging Without Losing Your Mind

The manifest.json has some gotchas that aren't obvious from the docs:

WrongCorrect
"app": "PPRO""app": "premierepro" (lowercase)
"host": [{ ... }]"host": { ... } (object, not array)
missing "main""main": "index.html" (required)
missing launchProcessneeded for shell.openExternal()

If you're using npm packages that depend on Node.js modules missing in UXP — like worker_threads, vm, or url — you'll need to bundle with esbuild and inject stubs for the missing modules. The plugin folder needs to be self-contained: no node_modules, just your bundled JS.

Distribution is a .ccx file. During development, load it via the UXP Developer Tool (UDT). For production, users double-click the .ccx with Premiere closed. If you want to list on the Adobe Exchange, prepare for a review process that takes days to weeks.

💡 Quick pre-release checklist: lowercase "premierepro" in manifest, "host" as object, "main" field present, unique plugin ID, all required permissions declared, no leftover console.log calls in production.

Was It Worth It?

Absolutely. BeatMarker now runs inside Premiere Pro, detects BPM from any WAV or MP3 file, and creates color-coded markers on source clips — one click instead of an hour of manual work on every project.

UXP is genuinely a good platform. The frustrating parts aren't because it's badly designed — they're because the documentation doesn't cover production realities. Hopefully this article does.

All the source files, specs, and changelogs that inspired this article live in the open-source repo. Feel free to dig in.

"The best tool is the one you build for yourself — because you know exactly what's missing."