feat: initial commit - Jhonny Editor

- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
This commit is contained in:
João Henrique
2026-09-08 09:59:31 -04:00
commit b541f502ba
1507 changed files with 387650 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
sendCommand,
type BridgeHelpers,
type BridgeOptions,
type CommandResult,
} from "./file-bridge.js";
import {
afterEffectsHelpersFileName,
buildAfterEffectsBootstrap,
getAfterEffectsHelpersSource,
} from "./after-effects-script-builder.js";
export const AFTER_EFFECTS_TEMP_DIR_ENV = "AFTER_EFFECTS_MCP_TEMP_DIR";
export const AFTER_EFFECTS_DEFAULT_TEMP_DIR_NAME = "after-effects-mcp-bridge";
export const AFTER_EFFECTS_BRIDGE_HELPERS: BridgeHelpers = {
source: getAfterEffectsHelpersSource(),
fileName: afterEffectsHelpersFileName(),
buildBootstrap: buildAfterEffectsBootstrap,
};
export function getAfterEffectsTempDir(
configured = process.env[AFTER_EFFECTS_TEMP_DIR_ENV],
fallback = tmpdir(),
): string {
return configured?.trim() || join(fallback, AFTER_EFFECTS_DEFAULT_TEMP_DIR_NAME);
}
/**
* Routes only to the dedicated AE bridge directory. Never reuse the Premiere
* channel: both CEP panels may be open in the same logged-in desktop session.
*/
export function sendAfterEffectsCommand(
script: string,
options: BridgeOptions = {},
): Promise<CommandResult> {
// `BridgeOptions` is also used by Premiere callers. Its tempDir is therefore
// deliberately ignored here: an explicit Premiere bridge directory must never
// become the AE request/response channel.
const { tempDir: _premiereTempDir, ...afterEffectsOptions } = options;
return sendCommand(script, {
...afterEffectsOptions,
tempDir: getAfterEffectsTempDir(),
helpers: AFTER_EFFECTS_BRIDGE_HELPERS,
});
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The After Effects connector intentionally has a very small helper surface.
* Loading the Premiere helper bundle into AE would unnecessarily expose DOM
* assumptions from a different host in AE's long-lived ExtendScript engine.
*/
import { createHash } from "node:crypto";
const HELPERS = `
function __aeJsonStringify(value) {
if (value === null) return "null";
if (value === undefined) return "null";
if (typeof value === "string") return '"' + value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"').replace(/\\n/g, "\\\\n").replace(/\\r/g, "\\\\r") + '"';
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Array) {
var list = [];
for (var i = 0; i < value.length; i++) list.push(__aeJsonStringify(value[i]));
return "[" + list.join(",") + "]";
}
if (typeof value === "object") {
var fields = [];
for (var key in value) {
if (value.hasOwnProperty(key)) fields.push(__aeJsonStringify(key) + ":" + __aeJsonStringify(value[key]));
}
return "{" + fields.join(",") + "}";
}
return __aeJsonStringify(String(value));
}
function __aeResult(data) { return __aeJsonStringify({ success: true, data: data }); }
function __aeError(message) { return __aeJsonStringify({ success: false, error: String(message) }); }
`;
export const AFTER_EFFECTS_HELPERS_VERSION = createHash("md5")
.update(HELPERS)
.digest("hex")
.slice(0, 12);
export function getAfterEffectsHelpersSource(): string {
return `${HELPERS}\nvar __AE_MCP_HELPERS_V = "${AFTER_EFFECTS_HELPERS_VERSION}";\n`;
}
export function afterEffectsHelpersFileName(): string {
return `after-effects-helpers_${AFTER_EFFECTS_HELPERS_VERSION}.jsx`;
}
export function buildAfterEffectsBootstrap(helpersPath: string): string {
const escaped = helpersPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `if (typeof __AE_MCP_HELPERS_V === "undefined" || __AE_MCP_HELPERS_V !== "${AFTER_EFFECTS_HELPERS_VERSION}") { $.evalFile("${escaped}"); }`;
}
export function buildAfterEffectsScript(code: string): string {
return `(function() {\n try {\n ${code}\n } catch (error) {\n return __aeError(error.toString());\n }\n})();`;
}
export function escapeForAfterEffects(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
+565
View File
@@ -0,0 +1,565 @@
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync, renameSync, statSync, chmodSync, watch, FSWatcher } from "node:fs";
import { basename, dirname, isAbsolute, join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { getHelpersSource, helpersFileName, buildBootstrap } from "./script-builder.js";
export function getDarwinUserTempDirectory(): string | null {
try {
// GUI-launched MCP clients can omit TMPDIR. On macOS, getconf still returns
// the same per-user temporary root inherited by Premiere's CEP process.
const value = execFileSync("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return value && isAbsolute(value) ? value : null;
} catch {
return null;
}
}
export function getDefaultBridgeTempDir(
platform: NodeJS.Platform = process.platform,
fallbackTempDirectory = tmpdir(),
readDarwinUserTempDirectory: () => string | null = getDarwinUserTempDirectory,
environment: NodeJS.ProcessEnv = process.env,
): string {
const hasConfiguredNodeTempDirectory = Boolean(
environment.TMPDIR || environment.TMP || environment.TEMP,
);
const temporaryRoot =
platform === "darwin" && !hasConfiguredNodeTempDirectory
? readDarwinUserTempDirectory() ?? fallbackTempDirectory
: fallbackTempDirectory;
return join(temporaryRoot, "premiere-mcp-bridge");
}
const DEFAULT_TEMP_DIR = getDefaultBridgeTempDir();
const POLL_FALLBACK_MS = 250;
const DEFAULT_TIMEOUT_MS = 30000;
export const MAX_QUEUED_BRIDGE_COMMANDS = 32;
export const MAX_BRIDGE_RESPONSE_BYTES = 1_048_576;
export const BRIDGE_HEARTBEAT_FILE = "bridge-heartbeat.json";
export const BRIDGE_HEARTBEAT_STALE_MS = 3_000;
type ResponseListener = () => void;
interface SharedResponseWatcher {
watcher?: FSWatcher;
listeners: Map<string, Set<ResponseListener>>;
}
interface QueuedBridgeCommand {
run: () => Promise<CommandResult>;
resolve: (result: CommandResult) => void;
reject: (error: unknown) => void;
}
interface BridgeCommandScheduler {
running: boolean;
pending: QueuedBridgeCommand[];
}
// One Premiere scripting engine serves each bridge directory. Serialize command
// publication per directory so concurrent MCP requests cannot make independent
// CEP panels issue overlapping host edits. The bounded queue fails fast instead
// of accumulating unbounded command files and response watchers under load.
const commandSchedulers = new Map<string, BridgeCommandScheduler>();
function scheduleBridgeCommand(
tempDir: string,
run: () => Promise<CommandResult>,
): Promise<CommandResult> {
let scheduler = commandSchedulers.get(tempDir);
if (!scheduler) {
scheduler = { running: false, pending: [] };
commandSchedulers.set(tempDir, scheduler);
}
if (scheduler.running && scheduler.pending.length >= MAX_QUEUED_BRIDGE_COMMANDS) {
return Promise.resolve({
success: false,
error: `Bridge command queue is full (${MAX_QUEUED_BRIDGE_COMMANDS} waiting); retry after an active command finishes`,
});
}
return new Promise<CommandResult>((resolve, reject) => {
scheduler!.pending.push({ run, resolve, reject });
runNextBridgeCommand(tempDir, scheduler!);
});
}
function runNextBridgeCommand(tempDir: string, scheduler: BridgeCommandScheduler): void {
if (scheduler.running) return;
const next = scheduler.pending.shift();
if (!next) {
commandSchedulers.delete(tempDir);
return;
}
scheduler.running = true;
void next.run()
.then(next.resolve, next.reject)
.finally(() => {
scheduler.running = false;
runNextBridgeCommand(tempDir, scheduler);
});
}
// CEP commands can be issued concurrently, especially when an MCP client
// inspects several independent surfaces. A watcher is attached to the bridge
// directory rather than to an individual response so one OS handle wakes all
// matching in-flight commands. Timers below remain the correctness fallback
// for filesystems where fs.watch drops or coalesces events.
const responseWatchers = new Map<string, SharedResponseWatcher>();
function watchResponseFile(resFile: string, listener: ResponseListener): () => void {
const directory = dirname(resFile);
const responseName = basename(resFile);
let shared = responseWatchers.get(directory);
if (!shared) {
shared = { listeners: new Map() };
responseWatchers.set(directory, shared);
}
let listeners = shared.listeners.get(responseName);
if (!listeners) {
listeners = new Set();
shared.listeners.set(responseName, listeners);
}
listeners.add(listener);
if (!shared.watcher) {
try {
const watcher = watch(directory, { persistent: false }, (_event, filename) => {
const names = filename
? [filename.toString()]
: Array.from(shared!.listeners.keys());
for (const name of names) {
for (const callback of shared!.listeners.get(name) ?? []) callback();
}
});
shared.watcher = watcher;
watcher.on("error", () => {
if (shared!.watcher !== watcher) return;
shared!.watcher = undefined;
watcher.close();
if (shared!.listeners.size === 0) responseWatchers.delete(directory);
});
} catch {
// The polling fallback below remains active when a filesystem does not
// support notifications (for example some network or virtual drives).
}
}
return () => {
const registered = shared!.listeners.get(responseName);
registered?.delete(listener);
if (registered?.size === 0) shared!.listeners.delete(responseName);
if (shared!.listeners.size > 0) return;
shared!.watcher?.close();
responseWatchers.delete(directory);
};
}
export interface BridgeOptions {
tempDir?: string;
timeoutMs?: number;
/**
* Host-specific bootstrap contract. Premiere is the default; companion
* bridges (such as After Effects) supply their own narrow helper surface.
*/
helpers?: BridgeHelpers;
/**
* Reject a health-style command without publishing it when a current CEP
* connector explicitly reports that it is waiting or its heartbeat is stale.
* A missing heartbeat remains compatible with older installed connectors.
*/
failFastOnUnreadyHeartbeat?: boolean;
}
export interface BridgeHelpers {
source: string;
fileName: string;
buildBootstrap: (helpersPath: string) => string;
}
export interface CommandResult {
success: boolean;
data?: unknown;
error?: string;
}
export type BridgeLivenessState = "running" | "waiting" | "stale" | "unknown";
export interface BridgeLiveness {
state: BridgeLivenessState;
ageMs: number | null;
}
/**
* Create the bridge temp dir private to this user, and — critically — refuse to trust
* one we didn't create.
*
* The dir sits at a predictable, world-accessible path (e.g. /tmp/premiere-mcp-bridge)
* and the CEP panel executes ANY cmd_*.jsx it finds there, inside Premiere, as the
* logged-in user. On a shared machine another user could pre-create that path and drop
* command files, or read the res_*.json we write (which contain project data). And
* mkdirSync({recursive:true}) is a no-op on an existing dir — it does NOT re-apply the
* mode — so "create it 0o700" alone does not protect against a dir that was already there.
*
* So: if it exists, verify it's ours and lock its permissions down; if it isn't ours,
* fail loudly rather than executing whatever an attacker staged in it.
*/
function ensureDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
return;
}
// POSIX only — Windows doesn't model uid/mode the same way, and its per-user temp
// dir isn't world-writable to begin with.
if (process.platform === "win32") return;
const st = statSync(dir);
const myUid = typeof process.getuid === "function" ? process.getuid() : undefined;
if (myUid !== undefined && st.uid !== myUid) {
throw new Error(
`Bridge temp dir ${dir} is owned by uid ${st.uid}, not this user (${myUid}). ` +
`Refusing to use it — another user may have staged command files. ` +
`Set PREMIERE_TEMP_DIR to a path only you control.`
);
}
// Clamp to owner-only, in case it was created with looser perms before this fix.
if ((st.mode & 0o077) !== 0) {
chmodSync(dir, 0o700);
}
}
export function getTempDir(options?: BridgeOptions): string {
return options?.tempDir || process.env.PREMIERE_TEMP_DIR || DEFAULT_TEMP_DIR;
}
/**
* Inspect the CEP panel's small, content-free heartbeat. This never creates a
* directory or reads command, response, project, or media data. Unknown is
* intentionally non-fatal so a server upgrade stays compatible with older CEP
* panels that do not publish a heartbeat yet.
*/
export function getBridgeLiveness(
options?: BridgeOptions,
nowMs = Date.now(),
): BridgeLiveness {
const heartbeatPath = join(getTempDir(options), BRIDGE_HEARTBEAT_FILE);
try {
if (!existsSync(heartbeatPath)) return { state: "unknown", ageMs: null };
const raw = readFileSync(heartbeatPath, "utf-8");
const heartbeat = JSON.parse(raw) as Record<string, unknown>;
if (
heartbeat.protocolVersion !== 1 ||
(heartbeat.state !== "running" && heartbeat.state !== "waiting")
) {
return { state: "unknown", ageMs: null };
}
const ageMs = Math.max(0, nowMs - statSync(heartbeatPath).mtimeMs);
return ageMs > BRIDGE_HEARTBEAT_STALE_MS
? { state: "stale", ageMs }
: { state: heartbeat.state, ageMs };
} catch {
return { state: "unknown", ageMs: null };
}
}
function heartbeatFailure(liveness: BridgeLiveness): CommandResult | null {
if (liveness.state === "waiting") {
return {
success: false,
error:
"The CEP connector is open but not running. In Premiere Pro, open Window > Extensions > MCP Bridge, wait for it to finish starting, then retry once.",
};
}
if (liveness.state === "stale") {
return {
success: false,
error:
"The CEP connector heartbeat is stale. Reopen Window > Extensions > MCP Bridge in Premiere Pro, dismiss any blocking dialog, and retry once after it reports running.",
};
}
return null;
}
/**
* Make sure this server version's helpers file exists in the temp dir, and return
* the bootstrap line each command must carry so the CEP-side engine loads it once.
*/
function ensureHelpers(tempDir: string, helpers?: BridgeHelpers): string {
const activeHelpers = helpers ?? {
source: getHelpersSource(),
fileName: helpersFileName(),
buildBootstrap,
};
const helpersPath = join(tempDir, activeHelpers.fileName);
if (!existsSync(helpersPath)) {
writeFileSync(helpersPath, activeHelpers.source, "utf-8");
}
return activeHelpers.buildBootstrap(helpersPath);
}
/**
* Send a command (ExtendScript) to the CEP plugin and wait for a response.
*
* Protocol:
* 1. Write the script to a staging file, then atomically publish it as
* <tempDir>/cmd_<id>.jsx. The CEP panel only sees complete commands.
* 2. CEP plugin picks it up, executes, writes result to <tempDir>/res_<id>.json
* 3. We poll for the response file and parse it.
*/
export async function sendCommand(
script: string,
options?: BridgeOptions
): Promise<CommandResult> {
validateScript(script);
const tempDir = getTempDir(options);
return scheduleBridgeCommand(tempDir, () => sendCommandUnchecked(script, options));
}
async function sendCommandUnchecked(
script: string,
options?: BridgeOptions,
): Promise<CommandResult> {
const tempDir = getTempDir(options);
const timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS;
ensureDir(tempDir);
if (options?.failFastOnUnreadyHeartbeat) {
const failure = heartbeatFailure(getBridgeLiveness(options));
if (failure) return failure;
}
const id = randomUUID();
const cmdFile = join(tempDir, `cmd_${id}.jsx`);
const stagedCmdFile = `${cmdFile}.staged`;
const resFile = join(tempDir, `res_${id}.json`);
const busyFile = join(tempDir, `busy_${id}.json`);
try {
// Write a complete command before its .jsx name makes it visible to CEP.
// renameSync is atomic when both paths are in the bridge directory.
writeFileSync(stagedCmdFile, `${ensureHelpers(tempDir, options?.helpers)}
${script}`, "utf-8");
renameSync(stagedCmdFile, cmdFile);
return await pollForResponse(resFile, busyFile, timeoutMs);
} finally {
safeUnlink(stagedCmdFile);
safeUnlink(cmdFile);
safeUnlink(resFile);
safeUnlink(busyFile);
}
}
function validateScript(script: string, allowUnsafe = false): void {
const MAX_SCRIPT_SIZE = 500 * 1024; // 500KB
if (Buffer.byteLength(script, "utf-8") > MAX_SCRIPT_SIZE) {
throw new Error("Script exceeds 500KB size limit");
}
if (allowUnsafe) return;
// Block dangerous patterns in user-provided parameters
// Note: we don't block these in our own generated code, only check for injection
const dangerousPatterns = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\bSystem\s*\.\s*callSystem\s*\(/,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(script)) {
throw new Error(`Script contains blocked pattern: ${pattern.source}`);
}
}
}
/**
* Send a raw/custom ExtendScript allowing all patterns (for LLM-authored scripts).
* Still enforces size limit. The script should already include helpers via buildToolScript.
*/
export async function sendRawCommand(
script: string,
options?: BridgeOptions
): Promise<CommandResult> {
validateScript(script, true);
const tempDir = getTempDir(options);
return scheduleBridgeCommand(tempDir, () => sendRawCommandUnchecked(script, options));
}
async function sendRawCommandUnchecked(
script: string,
options?: BridgeOptions,
): Promise<CommandResult> {
const tempDir = getTempDir(options);
const timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS;
ensureDir(tempDir);
if (options?.failFastOnUnreadyHeartbeat) {
const failure = heartbeatFailure(getBridgeLiveness(options));
if (failure) return failure;
}
const id = randomUUID();
const cmdFile = join(tempDir, `cmd_${id}.jsx`);
const stagedCmdFile = `${cmdFile}.staged`;
const resFile = join(tempDir, `res_${id}.json`);
const busyFile = join(tempDir, `busy_${id}.json`);
try {
writeFileSync(stagedCmdFile, `${ensureHelpers(tempDir, options?.helpers)}
${script}`, "utf-8");
renameSync(stagedCmdFile, cmdFile);
return await pollForResponse(resFile, busyFile, timeoutMs);
} finally {
safeUnlink(stagedCmdFile);
safeUnlink(cmdFile);
safeUnlink(resFile);
safeUnlink(busyFile);
}
}
async function pollForResponse(
resFile: string,
busyFile: string,
timeoutMs: number
): Promise<CommandResult> {
const start = Date.now();
// The CEP plugin writes busy_<id>.json every ~2s while evalScript is in flight.
// A fresh busy file past the deadline means Premiere accepted the script but hasn't
// returned — nearly always a modal dialog blocking the scripting engine, or a
// genuinely long operation — so we keep waiting up to a hard cap instead of
// misreporting "is the plugin running?".
const hardCapMs = Math.max(timeoutMs * 4, 120_000);
let sawBusy = false;
let lastResponseParseError: string | undefined;
const busyIsFresh = (): boolean => {
try {
if (!existsSync(busyFile)) return false;
sawBusy = true;
return Date.now() - statSync(busyFile).mtimeMs < 6_000;
} catch {
return false;
}
};
return new Promise((resolve) => {
let settled = false;
let timer: NodeJS.Timeout | undefined;
let stopWatching = () => {};
let fallbackDelay = 100;
const finish = (result: CommandResult) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
stopWatching();
resolve(result);
};
const scheduleFallback = () => {
if (!settled) {
timer = setTimeout(check, fallbackDelay);
fallbackDelay = POLL_FALLBACK_MS;
}
};
const check = () => {
if (settled) return;
if (existsSync(resFile)) {
try {
const responseSize = statSync(resFile).size;
if (responseSize > MAX_BRIDGE_RESPONSE_BYTES) {
finish({
success: false,
error: `Bridge response exceeds the ${MAX_BRIDGE_RESPONSE_BYTES}-byte limit`,
});
return;
}
const raw = readFileSync(resFile, "utf-8");
const result = JSON.parse(raw) as CommandResult;
if (typeof result !== "object" || result === null || typeof result.success !== "boolean") {
lastResponseParseError = "Failed to parse response: missing boolean success field";
} else {
finish(result);
return;
}
} catch (e) {
// A CEP response can be observed while an older connector is still writing it.
// Keep polling the same response file; never resend the host operation.
lastResponseParseError =
`Failed to parse response: ${e instanceof Error ? e.message : String(e)}`;
}
}
const elapsed = Date.now() - start;
if (elapsed >= timeoutMs) {
const stillBusy = busyIsFresh();
if (stillBusy && elapsed <= hardCapMs) {
scheduleFallback();
return;
}
if (lastResponseParseError) {
finish({ success: false, error: lastResponseParseError });
return;
}
finish({
success: false,
error: sawBusy
? `Premiere accepted the script but did not finish within ${elapsed}ms. ` +
`A modal dialog inside Premiere Pro is likely blocking the scripting engine — ` +
`check the Premiere window and dismiss any open dialog. ` +
`(The result, if any, will be discarded.)`
: `Command timed out after ${timeoutMs}ms. Is the CEP plugin running in Premiere Pro?`,
});
return;
}
scheduleFallback();
};
// Prefer event-driven notification for low response latency without
// allocating one fs.watch handle per concurrent command. The timer above
// still protects against missed or coalesced filesystem events.
stopWatching = watchResponseFile(resFile, check);
check();
});
}
function safeUnlink(path: string): void {
try {
if (existsSync(path)) {
unlinkSync(path);
}
} catch {
// Ignore cleanup errors
}
}
/**
* Clean up any stale command/response files from the temp directory.
*/
export function cleanupTempDir(options?: BridgeOptions): void {
const tempDir = getTempDir(options);
if (!existsSync(tempDir)) return;
try {
const files = readdirSync(tempDir);
for (const file of files) {
if (file.startsWith("cmd_") || file.startsWith("res_") || file.startsWith("busy_")) {
safeUnlink(join(tempDir, file));
}
}
} catch {
// Ignore cleanup errors
}
}
+630
View File
@@ -0,0 +1,630 @@
/**
* Builds ExtendScript strings with helper functions prepended.
* All generated code must be ES3-compatible (var, no arrow functions, no let/const).
*/
import { createHash } from "node:crypto";
const HELPERS = `
// === MCP Bridge Helpers (auto-prepended) ===
// ExtendScript (ES3) has no native JSON object. Tool scripts use __jsonStringify
// directly, but LLM-authored code via execute_extendscript reaches for
// JSON.stringify reflexively — give it a global. Parse is intentionally omitted:
// implementing it needs eval, which the command validator blocks.
// The engine is shared and long-lived, so also REPLACE our own earlier wrapper if
// one is already installed (detected via the __mcpPolyfill flag or its source) —
// a stale wrapper closing over an older __jsonStringify caused recursion bugs.
// A real json2-style implementation loaded by another extension is left alone.
if (typeof JSON === "undefined") {
JSON = {};
}
if (!JSON.stringify || JSON.__mcpPolyfill === true || String(JSON.stringify).indexOf("__jsonStringify") !== -1) {
JSON.__mcpPolyfill = true;
JSON.stringify = function (obj) { return __jsonStringify(obj); };
}
// Premiere's createNewSequence(name, id) expects a UUID-shaped id; anything else
// can fall back to interactive UI (a modal New Sequence dialog) and wedge the bridge.
function __uuid() {
var hex = "0123456789abcdef";
var s = "";
for (var i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) { s += "-"; continue; }
if (i === 14) { s += "4"; continue; }
var r = Math.floor(Math.random() * 16);
if (i === 19) { r = (r & 3) | 8; }
s += hex.charAt(r);
}
return s;
}
var TICKS_PER_SECOND = 254016000000;
function __ticksToSeconds(ticks) {
return parseFloat(ticks) / TICKS_PER_SECOND;
}
function __secondsToTicks(seconds) {
return Math.round(parseFloat(seconds) * TICKS_PER_SECOND);
}
function __ticksToTimecode(ticks, fps) {
var totalSeconds = __ticksToSeconds(ticks);
var hours = Math.floor(totalSeconds / 3600);
var minutes = Math.floor((totalSeconds % 3600) / 60);
var secs = Math.floor(totalSeconds % 60);
var frames = Math.floor((totalSeconds % 1) * fps);
return __pad(hours) + ":" + __pad(minutes) + ":" + __pad(secs) + ":" + __pad(frames);
}
function __pad(n) {
return n < 10 ? "0" + n : "" + n;
}
function __findSequence(idOrName) {
var project = app.project;
var wantedId = String(idOrName);
for (var i = 0; i < project.sequences.numSequences; i++) {
var seq = project.sequences[i];
if (String(seq.sequenceID) === wantedId || seq.name === idOrName) {
return seq;
}
}
return null;
}
// Premiere can retain a reference to the last active sequence immediately after
// app.newProject() switches to a new, empty project. Never expose or mutate
// through that stale object: only a sequence currently enumerated by this
// project's SequenceCollection is a valid active sequence for this command.
function __isCurrentProjectSequence(sequence) {
if (!sequence || !app || !app.project || !app.project.sequences) return false;
var wantedId = "";
try { wantedId = String(sequence.sequenceID); } catch (e) { return false; }
for (var i = 0; i < app.project.sequences.numSequences; i++) {
var candidate = app.project.sequences[i];
try {
if (candidate === sequence || String(candidate.sequenceID) === wantedId) return true;
} catch (e) {}
}
return false;
}
function __getCurrentActiveSequence() {
var sequence = null;
try { sequence = app.project.activeSequence; } catch (e) { return null; }
return __isCurrentProjectSequence(sequence) ? sequence : null;
}
function __findProjectItem(nodeIdOrName, rootItem) {
if (!rootItem) rootItem = app.project.rootItem;
var wantedId = String(nodeIdOrName);
for (var i = 0; i < rootItem.children.numItems; i++) {
var item = rootItem.children[i];
if (String(item.nodeId) === wantedId || item.name === nodeIdOrName) {
return item;
}
if (item.type === 2) { // Bin
var found = __findProjectItem(nodeIdOrName, item);
if (found) return found;
}
}
return null;
}
function __findClip(nodeId) {
var seq = app.project.activeSequence;
if (!seq) return null;
var wantedId = String(nodeId);
// Search video tracks
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
if (String(clip.nodeId) === wantedId) {
return { clip: clip, trackIndex: t, clipIndex: c, trackType: "video" };
}
}
}
// Search audio tracks
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
if (String(clip.nodeId) === wantedId) {
return { clip: clip, trackIndex: t, clipIndex: c, trackType: "audio" };
}
}
}
return null;
}
// QE tracks include gaps and transitions in addition to clips, so a DOM clip
// index cannot safely be passed to qeTrack.getItemAt(). Resolve a QE clip by
// its timeline start instead. Return null rather than a nearest candidate: a
// mutation must never be redirected to a neighbouring clip.
function __findQeClipByDomClip(qeTrack, domClip) {
if (!qeTrack || !domClip) return null;
var wantedStart = null;
try { wantedStart = parseFloat(domClip.start.ticks); } catch (eStart) {}
if (wantedStart === null || isNaN(wantedStart)) return null;
for (var qi = 0; qi < qeTrack.numItems; qi++) {
var candidate = null;
try { candidate = qeTrack.getItemAt(qi); } catch (eItem) {}
if (!candidate || String(candidate.type) !== "Clip") continue;
try {
if (Math.abs(parseFloat(candidate.start.ticks) - wantedStart) < 1) return candidate;
} catch (eCandidate) {}
}
return null;
}
// CEP's legacy QE path can enumerate a host's effect catalog before adding an
// effect to a timeline clip. Recent Premiere builds can expose QE yet return an
// empty catalog, so distinguish that host limitation from a misspelled effect
// name. Calling addVideoEffect/addAudioEffect without a catalog entry is not a
// safe fallback; an available UXP bridge has its own documented effect workflow.
function __getQeEffectCatalog(kind) {
var label = kind === "audio" ? "audio" : "video";
if (typeof app === "undefined" || typeof app.enableQE !== "function") {
return { ok: false, error: "QE is unavailable in this Premiere build, so " + label + " effects cannot be enumerated or applied." };
}
try {
app.enableQE();
} catch (eEnable) {
return { ok: false, error: "Premiere could not enable QE for " + label + " effect discovery: " + eEnable.toString() };
}
if (typeof qe === "undefined" || !qe.project) {
return { ok: false, error: "QE did not expose a project after enableQE(), so " + label + " effects cannot be enumerated or applied." };
}
var getter = kind === "audio" ? qe.project.getAudioEffectList : qe.project.getVideoEffectList;
if (typeof getter !== "function") {
return { ok: false, error: "This Premiere QE build does not expose the " + label + " effect catalog API." };
}
var effects = null;
try {
effects = getter.call(qe.project);
} catch (eList) {
return { ok: false, error: "Premiere could not read its QE " + label + " effect catalog: " + eList.toString() };
}
var count = effects && typeof effects.numItems !== "undefined" ? Number(effects.numItems) : NaN;
if (isNaN(count) || count < 1) {
return {
ok: false,
error: "Premiere returned an empty legacy QE " + label + " effect catalog; no effect was applied. If the authenticated Premiere UXP bridge is connected, use manage_clip_effects_uxp with action 'catalog' and then 'add' instead. Existing clip components can still be inspected or edited."
};
}
return { ok: true, effects: effects, count: count };
}
function __getAllClips(seq) {
if (!seq) seq = app.project.activeSequence;
if (!seq) return [];
var clips = [];
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
clips.push({
nodeId: clip.nodeId,
name: clip.name,
trackIndex: t,
trackType: "video",
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks),
mediaType: clip.mediaType
});
}
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
clips.push({
nodeId: clip.nodeId,
name: clip.name,
trackIndex: t,
trackType: "audio",
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks),
mediaType: clip.mediaType
});
}
}
return clips;
}
// Premiere's ExtendScript API exposes no preset/format enumeration (there is no
// encoder.getFormatList()), so presets have to be discovered by walking the .epr
// files Adobe ships on disk.
function __isMacOS() {
return !!($.os && $.os.toLowerCase().indexOf("mac") !== -1);
}
// Version-agnostic: returns install folders whose name starts with appNamePrefix,
// e.g. "Adobe Premiere Pro" -> [.../Adobe Premiere Pro 2026, .../Adobe Premiere Pro 2025]
function __adobeAppFolders(appNamePrefix) {
var base = new Folder(__isMacOS() ? "/Applications" : "C:\\\\Program Files\\\\Adobe");
if (!base.exists) return [];
var found = [];
var subs = base.getFiles(function(f) { return f instanceof Folder; });
for (var i = 0; i < subs.length; i++) {
if (subs[i].displayName.indexOf(appNamePrefix) === 0) found.push(subs[i]);
}
// Newest version first, so a 2026 preset wins over a stale 2024 one.
found.sort(function(a, b) { return a.displayName < b.displayName ? 1 : -1; });
return found;
}
function __collectEprFiles(folder, out) {
if (!folder || !folder.exists) return out;
var entries = folder.getFiles();
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (entry instanceof Folder) __collectEprFiles(entry, out);
else if (/\\.epr$/i.test(entry.name)) out.push(entry);
}
return out;
}
// macOS applications are bundles: AME/Premiere resources live below Contents,
// whereas the Windows installers put the same folders directly below the app root.
function __adobeApplicationResourceFolder(appFolder, relativePath) {
var prefix = appFolder.fsName + (__isMacOS() ? "/Contents/" : "/");
return new Folder(prefix + relativePath);
}
// All export presets AME ships, plus the user's own saved presets.
function __collectAllPresets() {
var roots = [];
var ame = __adobeAppFolders("Adobe Media Encoder");
for (var i = 0; i < ame.length; i++) {
roots.push(__adobeApplicationResourceFolder(ame[i], "MediaIO/systempresets"));
}
var ppro = __adobeAppFolders("Adobe Premiere Pro");
for (var j = 0; j < ppro.length; j++) {
roots.push(__adobeApplicationResourceFolder(ppro[j], "Settings/IngestPresets"));
}
// User-saved presets live under the Documents tree on both platforms.
var userRoot = new Folder(Folder.myDocuments.fsName + "/Adobe/Adobe Media Encoder");
if (userRoot.exists) {
var versions = userRoot.getFiles(function(f) { return f instanceof Folder; });
for (var v = 0; v < versions.length; v++) {
roots.push(new Folder(versions[v].fsName + "/Presets"));
}
}
var presets = [];
for (var r = 0; r < roots.length; r++) {
var eprs = __collectEprFiles(roots[r], []);
for (var e = 0; e < eprs.length; e++) {
presets.push({
name: decodeURI(eprs[e].displayName).replace(/\\.epr$/i, ""),
path: eprs[e].fsName,
// The parent folder is the format bucket, e.g. "48323634" (hex "H264").
format: eprs[e].parent ? decodeURI(eprs[e].parent.displayName) : ""
});
}
}
return presets;
}
function __presetSearchText(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
// Default export preset. "48323634" is hex for "H264" — the folder name AME uses
// for the H.264 format bucket on disk.
function __findH264Preset() {
var presets = __collectAllPresets();
var candidates = [];
for (var i = 0; i < presets.length; i++) {
var haystack = (presets[i].name + " " + presets[i].format).toLowerCase();
if (haystack.indexOf("h264") !== -1 || haystack.indexOf("h.264") !== -1 || haystack.indexOf("48323634") !== -1) {
candidates.push(presets[i]);
}
}
if (!candidates.length) return "";
for (var j = 0; j < candidates.length; j++) {
if (candidates[j].name.toLowerCase().indexOf("match source - high") !== -1) return candidates[j].path;
}
return candidates[0].path;
}
function __findProxyPreset() {
var ppro = __adobeAppFolders("Adobe Premiere Pro");
for (var i = 0; i < ppro.length; i++) {
var proxyDir = __adobeApplicationResourceFolder(ppro[i], "Settings/IngestPresets/Proxy");
var eprs = __collectEprFiles(proxyDir, []);
if (eprs.length) {
eprs.sort(function(a, b) { return a.displayName < b.displayName ? -1 : 1; });
return eprs[0].fsName;
}
}
return "";
}
function __findStillPreset(outputPath) {
var wantJpeg = /\\.jpe?g$/i.test(outputPath);
var needles = wantJpeg ? ["jpeg", "jpg"] : ["png"];
var presets = __collectAllPresets();
for (var n = 0; n < needles.length; n++) {
for (var i = 0; i < presets.length; i++) {
var haystack = (presets[i].name + " " + presets[i].format).toLowerCase();
if (haystack.indexOf(needles[n]) !== -1) return presets[i].path;
}
}
return "";
}
// Returns the path actually written, or "" if nothing was. Media Encoder treats a
// still export as a one-frame image *sequence* and appends a frame number to the
// filename, so an exact-path miss is not proof that nothing was written.
function __firstWrittenFile(outputPath) {
var exact = new File(outputPath);
if (exact.exists && exact.length > 0) return exact.fsName;
var dir = exact.parent;
if (!dir || !dir.exists) return "";
var fullName = decodeURI(exact.name);
var dot = fullName.lastIndexOf(".");
var base = dot === -1 ? fullName : fullName.substring(0, dot);
var ext = dot === -1 ? "" : fullName.substring(dot).toLowerCase();
var matches = dir.getFiles(function(candidate) {
if (candidate instanceof Folder) return false;
var nm = decodeURI(candidate.name);
if (nm.indexOf(base) !== 0) return false;
return ext === "" || nm.toLowerCase().substring(nm.length - ext.length) === ext;
});
if (!matches || !matches.length) return "";
// Normalize back to the caller's requested path so they get the name they asked for.
var produced = matches[0];
if (produced.length <= 0) return "";
try {
if (produced.fsName !== exact.fsName) produced.rename(fullName);
return exact.exists ? exact.fsName : produced.fsName;
} catch (e) {
return produced.fsName;
}
}
// Export a single frame to disk. Returns { ok, method, path, notes } / { ok:false, error, notes }.
//
// exportFramePNG/exportFrameJPEG do NOT exist on the public DOM sequence — only on
// the QE sequence — and even there they return false and write nothing on some
// builds. So we try QE first, verify against the filesystem rather than the return
// value, and fall back to a one-frame Media Encoder export.
function __exportStillFrame(outputPath, ticks) {
var seq = app.project.activeSequence;
if (!seq) return { ok: false, error: "No active sequence", notes: [] };
var notes = [];
var savedPos = null;
try { savedPos = seq.getPlayerPosition().ticks; } catch (e) {}
if (ticks) {
try { seq.setPlayerPosition(String(ticks)); } catch (e) { notes.push("setPlayerPosition: " + e.toString()); }
}
var atTicks = ticks;
if (!atTicks) {
try { atTicks = seq.getPlayerPosition().ticks; } catch (e) { atTicks = "0"; }
}
// Clear any stale file so that a file existing afterwards proves we wrote it.
var stale = new File(outputPath);
if (stale.exists) { try { stale.remove(); } catch (e) {} }
var wantJpeg = /\\.jpe?g$/i.test(outputPath);
// --- Path 1: QE DOM. Signature is (path, width, height) with string args. ---
try {
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) {
notes.push("QE: no active sequence");
} else {
var fn = wantJpeg ? qeSeq.exportFrameJPEG : qeSeq.exportFramePNG;
if (typeof fn !== "function") {
notes.push("QE: exportFrame" + (wantJpeg ? "JPEG" : "PNG") + " unavailable on this build");
} else {
var w = String(seq.frameSizeHorizontal);
var h = String(seq.frameSizeVertical);
try {
notes.push("QE returned " + fn.call(qeSeq, outputPath, w, h));
} catch (eArgs) {
try { notes.push("QE returned " + fn.call(qeSeq, outputPath, w)); }
catch (eArgs2) { notes.push("QE: " + eArgs2.toString()); }
}
}
}
} catch (eQE) {
notes.push("QE: " + eQE.toString());
}
var written = __firstWrittenFile(outputPath);
if (written) {
if (savedPos) { try { seq.setPlayerPosition(savedPos); } catch (e) {} }
return { ok: true, method: "qe", path: written, notes: notes };
}
notes.push("QE wrote no file; falling back to Media Encoder");
// --- Path 2: one-frame export through Media Encoder. ---
try {
var preset = __findStillPreset(outputPath);
if (!preset) {
notes.push("AME: no " + (wantJpeg ? "JPEG" : "PNG") + " still preset found on disk");
} else {
var savedIn = null, savedOut = null;
try {
savedIn = seq.getInPointAsTime().ticks;
savedOut = seq.getOutPointAsTime().ticks;
} catch (e) {}
// seq.timebase is ticks-per-frame, but Sequence.setInPoint/setOutPoint take
// seconds (unlike setPlayerPosition, which takes ticks). Convert before
// setting the one-frame range or Premiere targets an astronomically large
// interval and the still export produces no file.
var frameTicks = parseFloat(seq.timebase);
var startTicks = parseFloat(atTicks);
seq.setInPoint(__ticksToSeconds(startTicks));
seq.setOutPoint(__ticksToSeconds(startTicks + frameTicks));
try {
seq.exportAsMediaDirect(outputPath, preset, app.encoder.ENCODE_IN_TO_OUT);
notes.push("AME preset: " + preset);
} finally {
try {
if (savedIn !== null) seq.setInPoint(__ticksToSeconds(savedIn));
if (savedOut !== null) seq.setOutPoint(__ticksToSeconds(savedOut));
} catch (e) {}
}
}
} catch (eAME) {
notes.push("AME: " + eAME.toString());
}
if (savedPos) { try { seq.setPlayerPosition(savedPos); } catch (e) {} }
written = __firstWrittenFile(outputPath);
if (written) return { ok: true, method: "ame", path: written, notes: notes };
return {
ok: false,
error: "Frame export produced no file on disk. Neither the QE DOM nor Media Encoder wrote " + outputPath,
notes: notes
};
}
function __jsonStringify(obj) {
// ES3-compatible JSON stringify. Never delegate to JSON.stringify here: the
// global JSON polyfill above is a wrapper around THIS function, so delegating
// creates infinite mutual recursion ("InternalError: Stack overrun") that took
// down every __result call in the shared engine.
if (obj === null) return "null";
if (obj === undefined) return "undefined";
if (typeof obj === "string") return '"' + obj.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"').replace(/\\n/g, "\\\\n") + '"';
if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
if (obj instanceof Array) {
var arr = [];
for (var i = 0; i < obj.length; i++) {
arr.push(__jsonStringify(obj[i]));
}
return "[" + arr.join(",") + "]";
}
if (typeof obj === "object") {
var parts = [];
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
parts.push(__jsonStringify(k) + ":" + __jsonStringify(obj[k]));
}
}
return "{" + parts.join(",") + "}";
}
return String(obj);
}
function __result(data) {
return __jsonStringify({ success: true, data: data });
}
function __error(msg) {
return __jsonStringify({ success: false, error: String(msg) });
}
// === End MCP Bridge Helpers ===
`;
/**
* The helpers are NOT inlined into every command. Re-sending ~14KB of helper code
* with each evalScript both wastes the 200ms-polling pipe and — observed on
* Premiere 26.2.2 — can hit "InternalError: Stack overrun" once the long-lived
* ExtendScript engine has degraded, at which point every tool call dies with an
* opaque "EvalScript error.". Instead the file bridge writes the helpers to
* <tempDir>/helpers_<version>.jsx once, and each command carries only a tiny
* bootstrap that $.evalFile's them into the engine if this exact version isn't
* loaded yet. Self-healing across engine restarts, and each version of the server
* loads its own helpers file, so upgrades can't execute stale helpers.
*/
export const HELPERS_VERSION = createHash("md5").update(HELPERS).digest("hex").slice(0, 12);
export function getHelpersSource(): string {
return `${HELPERS}
var __HELPERS_V = "${HELPERS_VERSION}";
`;
}
export function helpersFileName(): string {
return `helpers_${HELPERS_VERSION}.jsx`;
}
/**
* Build the bootstrap + user-code command script. The helpers file path is only
* known to the file bridge, which injects it via buildBootstrap().
*/
export function buildBootstrap(helpersPath: string): string {
const escaped = helpersPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `if (typeof __HELPERS_V === "undefined" || __HELPERS_V !== "${HELPERS_VERSION}") { $.evalFile("${escaped}"); }`;
}
/**
* Build a complete ExtendScript by wrapping user code in an IIFE.
* Helper functions are loaded by the bootstrap the file bridge prepends.
*/
export function buildScript(code: string): string {
return `(function() {
try {
${code}
} catch(e) {
return __error(e.toString());
}
})();`;
}
/**
* Escape a string for safe embedding in ExtendScript.
*/
export function escapeForExtendScript(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
/**
* Build a script that wraps code returning a value.
* The code should use `return __result(...)` or `return __error(...)`.
* @deprecated Use buildScript() directly. This is an alias kept for backward compatibility.
*/
export const buildToolScript = buildScript;
+320
View File
@@ -0,0 +1,320 @@
import { randomUUID, timingSafeEqual } from "node:crypto";
import { EventEmitter } from "node:events";
import { createServer, type Server } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
const LOOPBACK_HOST = "127.0.0.1";
const SUPPORTED_PROTOCOLS = new Set([1, 2]);
export interface UxpBridgeOptions {
token: string;
port?: number;
path?: string;
requestTimeoutMs?: number;
handshakeTimeoutMs?: number;
}
export interface UxpCapability {
supported: boolean;
[key: string]: unknown;
}
export interface UxpRequestOptions {
/** Do not let the bridge timeout before a bounded host-side wait can settle. */
minimumTimeoutMs?: number;
}
export interface UxpHello {
backend: "uxp";
protocolVersion: number;
commands: Record<string, UxpCapability>;
[key: string]: unknown;
}
export type UxpConnectionState =
| { status: "stopped" | "listening"; connected: false }
| {
status: "connected";
connected: true;
protocolVersion: number;
capabilities: UxpHello;
connectedAt: string;
};
interface PendingRequest {
command: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export class UxpBridgeError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "UxpBridgeError";
}
}
function secureTokenEqual(actual: string, expected: string): boolean {
const left = Buffer.from(actual);
const right = Buffer.from(expected);
return left.length === right.length && timingSafeEqual(left, right);
}
function validPort(value: number): number {
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new Error("UXP bridge port must be an integer between 0 and 65535");
}
return value;
}
/**
* Authenticated loopback WebSocket server used only by the local Premiere UXP
* panel. It never binds a LAN/WAN interface and does not silently fall back to
* CEP after a UXP command has been sent.
*/
export class UxpWebSocketBridge extends EventEmitter {
private readonly options: Required<UxpBridgeOptions>;
private httpServer: Server | null = null;
private wsServer: WebSocketServer | null = null;
private socket: WebSocket | null = null;
private hello: UxpHello | null = null;
private connectedAt: string | null = null;
private handshakeTimer: NodeJS.Timeout | null = null;
private readonly pending = new Map<string, PendingRequest>();
constructor(options: UxpBridgeOptions) {
super();
if (!options.token || options.token.length < 16) {
throw new Error("PREMIERE_UXP_TOKEN must contain at least 16 characters");
}
this.options = {
token: options.token,
port: validPort(options.port ?? 7777),
path: options.path ?? "/uxp",
requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
handshakeTimeoutMs: options.handshakeTimeoutMs ?? 5_000,
};
if (!this.options.path.startsWith("/")) {
throw new Error("UXP bridge path must begin with '/'");
}
}
async start(): Promise<void> {
if (this.httpServer) return;
const httpServer = createServer((_req, res) => {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
const wsServer = new WebSocketServer({ noServer: true, maxPayload: 1_048_576 });
httpServer.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
const authorized =
url.pathname === this.options.path &&
secureTokenEqual(url.searchParams.get("token") ?? "", this.options.token);
if (!authorized) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wsServer.handleUpgrade(request, socket, head, (client) => {
wsServer.emit("connection", client, request);
});
});
wsServer.on("connection", (client) => this.acceptConnection(client));
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(this.options.port, LOOPBACK_HOST, () => {
httpServer.off("error", reject);
resolve();
});
});
this.httpServer = httpServer;
this.wsServer = wsServer;
this.emit("listening", this.address());
}
address(): { host: string; port: number; path: string } {
const address = this.httpServer?.address();
return {
host: LOOPBACK_HOST,
port: typeof address === "object" && address ? address.port : this.options.port,
path: this.options.path,
};
}
getState(): UxpConnectionState {
if (this.socket?.readyState === WebSocket.OPEN && this.hello && this.connectedAt) {
return {
status: "connected",
connected: true,
protocolVersion: this.hello.protocolVersion,
capabilities: this.hello,
connectedAt: this.connectedAt,
};
}
return {
status: this.httpServer ? "listening" : "stopped",
connected: false,
};
}
async request(
command: string,
args: Record<string, unknown> = {},
requestOptions: UxpRequestOptions = {},
): Promise<unknown> {
const socket = this.socket;
const hello = this.hello;
if (!socket || socket.readyState !== WebSocket.OPEN || !hello) {
throw new UxpBridgeError("UXP_NOT_CONNECTED", "Premiere UXP bridge is not connected");
}
if (hello.commands[command]?.supported !== true) {
throw new UxpBridgeError(
"UXP_COMMAND_UNSUPPORTED",
`Connected Premiere host does not support UXP command '${command}'`,
);
}
const minimumTimeoutMs = requestOptions.minimumTimeoutMs ?? 0;
if (!Number.isInteger(minimumTimeoutMs) || minimumTimeoutMs < 0) {
throw new Error("UXP minimum request timeout must be a non-negative integer");
}
const requestTimeoutMs = Math.max(this.options.requestTimeoutMs, minimumTimeoutMs);
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(new UxpBridgeError("UXP_TIMEOUT", `UXP command '${command}' timed out`));
}, requestTimeoutMs);
this.pending.set(requestId, { command, resolve, reject, timer });
socket.send(JSON.stringify({
protocolVersion: hello.protocolVersion,
type: "command",
requestId,
command,
args,
}), (error) => {
if (!error) return;
const pending = this.pending.get(requestId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(requestId);
pending.reject(new UxpBridgeError("UXP_SEND_FAILED", error.message));
});
});
}
async stop(): Promise<void> {
this.clearConnection(new UxpBridgeError("UXP_STOPPED", "UXP bridge stopped"));
const wsServer = this.wsServer;
const httpServer = this.httpServer;
this.wsServer = null;
this.httpServer = null;
if (wsServer) {
for (const client of wsServer.clients) client.terminate();
wsServer.close();
}
if (httpServer) {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
}
private acceptConnection(client: WebSocket): void {
if (this.socket) {
this.clearConnection(
new UxpBridgeError("UXP_RECONNECTED", "Premiere UXP bridge reconnected"),
);
}
this.socket = client;
this.hello = null;
this.connectedAt = null;
this.handshakeTimer = setTimeout(() => {
client.close(1008, "Versioned hello required");
}, this.options.handshakeTimeoutMs);
client.on("message", (data) => this.handleMessage(client, data.toString()));
client.on("close", () => {
if (client !== this.socket) return;
this.clearConnection(
new UxpBridgeError("UXP_DISCONNECTED", "Premiere UXP bridge disconnected"),
);
this.emit("disconnected");
});
client.on("error", (error) => this.emit("clientError", error));
}
private handleMessage(client: WebSocket, raw: string): void {
let message: any;
try {
message = JSON.parse(raw);
} catch {
client.close(1007, "Invalid JSON");
return;
}
if (!this.hello) {
const hello = message?.type === "hello" ? message.payload : null;
if (
!hello ||
hello.backend !== "uxp" ||
!SUPPORTED_PROTOCOLS.has(message.protocolVersion) ||
hello.protocolVersion !== message.protocolVersion ||
!hello.commands ||
typeof hello.commands !== "object" ||
Array.isArray(hello.commands)
) {
client.close(1008, "Unsupported UXP handshake");
return;
}
if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
this.hello = hello as UxpHello;
this.connectedAt = new Date().toISOString();
this.emit("connected", this.getState());
return;
}
if (message?.protocolVersion !== this.hello.protocolVersion) {
client.close(1008, "Protocol version changed");
return;
}
if (message?.type === "event") {
this.emit("event", message.payload);
return;
}
if (message?.type !== "result" || typeof message.requestId !== "string") return;
const pending = this.pending.get(message.requestId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(message.requestId);
if (message.payload?.ok === true) {
pending.resolve(message.payload.result);
} else {
const error = message.payload?.error;
pending.reject(new UxpBridgeError(
error?.code ?? "UXP_COMMAND_FAILED",
error?.message ?? `UXP command '${pending.command}' failed`,
));
}
}
private clearConnection(error: Error): void {
if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
const socket = this.socket;
this.socket = null;
this.hello = null;
this.connectedAt = null;
if (socket?.readyState === WebSocket.OPEN) socket.close();
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}