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:
Executable
+48
@@ -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
@@ -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");
|
||||
}
|
||||
Executable
+565
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+630
@@ -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;
|
||||
Executable
+320
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user