- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
321 lines
10 KiB
TypeScript
Executable File
321 lines
10 KiB
TypeScript
Executable File
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();
|
|
}
|
|
}
|