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
+80
View File
@@ -0,0 +1,80 @@
# UXP Spike
A throwaway UXP plugin that answers two questions we cannot answer from the docs. It is not part
of the MCP server and nothing depends on it.
## Why
**1. Is issue #9 fixable?**
Documented ExtendScript has **no frame-export method at all**. `Sequence` exposes only
`exportAsMediaDirect`, `exportAsProject`, and `exportAsFinalCutProXML` — there is no
`exportFramePNG`. That is why `capture_frame` reaches into the undocumented QE DOM, and why
[#9](https://github.com/leancoderkavy/premiere-pro-mcp/issues/9) reports it returning `false` and
writing nothing on **both** PPro 2025 and 2026. It isn't a bug we can fix; it's a hole in the API.
UXP has a supported `Exporter.exportSequenceFrame()`. **Probe A** finds out whether it works.
Frame capture is the only tool that gives an agent *visual* evidence of the timeline. Without it
there is no way to verify a color grade, a transition, or a title actually landed. It's worth a
spike on its own.
**2. Can we drop the temp-file bridge?**
Today the CEP panel polls a temp directory every 200ms for `.jsx` files. UXP has `WebSocket` and
`fetch`, which would be a strict upgrade. But:
- Adobe's UXP docs **never mention `localhost` or `127.0.0.1`** — not once, anywhere.
- UXP WebSockets are **client-only**: a plugin "cannot host or accept incoming connections", so
the MCP server must be the server. (Fine — that's the direction we want anyway.)
- macOS is documented to restrict `http://`. Whether that extends to `ws://` is **unstated**.
- `wss://` with a self-signed cert is **explicitly broken on macOS** per Adobe's known-issues page.
So both obvious loopback options sit in undocumented-or-blocked territory. **Probes B–E** find out
what actually connects. This determines our entire transport, so it's worth knowing before we
commit to a port rather than after.
## Running it
You need Premiere Pro **25.6 or newer** (that's when UXP went GA) and the
[UXP Developer Tool](https://developer.adobe.com/premiere-pro/uxp/plugins/) 2.2+.
**1. Start the spike server.** Zero dependencies, so there is nothing to install:
```bash
node uxp-spike/server.mjs # listens on 127.0.0.1:7777
```
**2. Enable Premiere's developer mode.** Settings → Plugins → *Enable developer mode*, then
**restart Premiere**.
**3. Side-load the plugin.** In UXP Developer Tool: **Add Plugin** → select
`uxp-spike/manifest.json` → **Load & Watch**.
**4. Open a project with a sequence**, put the playhead somewhere with visible picture, and open
**Window → UXP Plugins → MCP UXP Spike**.
**5. Click "Run all probes."**
## Reading the result
The panel prints a JSON verdict and writes it to `/tmp/mcp-uxp-spike-report.json`. The server also
logs every transport that reaches it.
The two lines that matter:
```
Issue #9 (frame capture): FIXED by UXP | still broken
Transport: websocket | http-fetch | file-bridge
```
Probe A does **not** trust `exportSequenceFrame`'s return value — the QE DOM lies about this in
both directions, so success is decided purely by whether a file exists on disk afterwards.
A failing probe is a **result, not an error**. "Loopback WebSocket is blocked" is exactly the kind
of thing worth learning in an afternoon rather than three weeks into a port.
## Please paste the JSON verdict into the tracking issue
If you can run this against a real Premiere, that's genuinely the most useful thing anyone can do
for this project right now. Neither of these questions can be settled from Adobe's documentation.
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script src="index.js"></script>
<style>
body {
font-family: -apple-system, "Segoe UI", sans-serif;
font-size: 12px;
padding: 12px;
}
h2 {
font-size: 13px;
margin: 0 0 4px;
}
p.sub {
margin: 0 0 12px;
opacity: 0.7;
}
.row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.row label {
width: 70px;
opacity: 0.7;
}
.row input {
flex: 1;
}
#results {
margin-top: 12px;
}
.probe {
border-left: 3px solid #888;
padding: 6px 8px;
margin-bottom: 6px;
background: rgba(127, 127, 127, 0.08);
}
.probe.pass {
border-left-color: #2d9d5c;
}
.probe.fail {
border-left-color: #d0402b;
}
.probe.run {
border-left-color: #d8a13a;
}
.probe .name {
font-weight: 600;
}
.probe .detail {
opacity: 0.75;
margin-top: 2px;
word-break: break-all;
white-space: pre-wrap;
}
#summary {
margin-top: 12px;
padding: 8px;
background: rgba(127, 127, 127, 0.12);
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<h2>MCP UXP Spike</h2>
<p class="sub">
Answers two questions: does UXP fix frame capture (issue #9), and can a UXP panel reach a
local MCP server without the temp-file bridge?
</p>
<div class="row">
<label>Port</label>
<input id="port" type="text" value="7777" />
</div>
<div class="row">
<label>Out dir</label>
<input id="outdir" type="text" value="/tmp/" />
</div>
<sp-button id="run" variant="cta">Run all probes</sp-button>
<div id="results"></div>
<div id="summary">Not run yet.</div>
</body>
</html>
+304
View File
@@ -0,0 +1,304 @@
/*
* MCP UXP Spike
*
* Two questions, answered empirically against a running Premiere Pro:
*
* 1. Does UXP fix frame capture?
* Documented ExtendScript has NO frame-export method at all — Sequence only exposes
* exportAsMediaDirect / exportAsProject / exportAsFinalCutProXML. That is why our
* capture_frame reaches into the undocumented QE DOM, and why it returns false and
* writes nothing on both PPro 2025 and 2026 (issue #9). UXP has a supported
* Exporter.exportSequenceFrame(). Probe A finds out whether it actually works.
*
* 2. Can a UXP panel talk to a local MCP server directly?
* Today we shuttle commands through temp files and a 200ms poller. UXP has WebSocket
* and fetch, which would be a strict upgrade — but Adobe's docs never once mention
* localhost or 127.0.0.1 in network.domains, macOS is documented to restrict http://,
* and wss:// with a self-signed cert is explicitly broken on macOS. So loopback sits
* in undocumented-or-blocked territory. Probes B-E find out what actually connects.
*
* Every probe reports rather than throws. A failure is a result, not an error.
*/
const { entrypoints } = require("uxp");
const ppro = require("premierepro");
const PROBES = [
{ id: "frame-export", name: "A. Exporter.exportSequenceFrame()", run: probeFrameExport },
{ id: "ws-localhost", name: "B. WebSocket ws://localhost", run: (c) => probeWebSocket(c, "localhost") },
{ id: "ws-loopback-ip", name: "C. WebSocket ws://127.0.0.1", run: (c) => probeWebSocket(c, "127.0.0.1") },
{ id: "fetch-http", name: "D. fetch() http://127.0.0.1", run: probeFetch },
{ id: "fs-write", name: "E. Filesystem write (bridge fallback)", run: probeFileSystem },
];
const results = {};
entrypoints.setup({
plugin: { create() {}, destroy() {} },
panels: {
spikePanel: {
create() {},
show() {},
// Adobe's docs warn that hide()/destroy() "are not working as expected yet" in
// Premiere, so nothing important is torn down here.
},
},
});
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("run").addEventListener("click", runAll);
});
async function runAll() {
const ctx = {
port: Number(document.getElementById("port").value) || 7777,
outDir: document.getElementById("outdir").value || "/tmp/",
};
document.getElementById("results").innerHTML = "";
document.getElementById("summary").textContent = "Running...";
for (const probe of PROBES) {
render(probe.id, probe.name, { state: "run", detail: "running..." });
let outcome;
try {
outcome = await probe.run(ctx);
} catch (e) {
// A probe that throws is still a result — record it, don't abort the run.
outcome = { ok: false, detail: "threw: " + errText(e) };
}
results[probe.id] = outcome;
render(probe.id, probe.name, { state: outcome.ok ? "pass" : "fail", detail: outcome.detail });
}
summarize(ctx);
}
// --- Probe A: does UXP actually fix issue #9? ---------------------------------
async function probeFrameExport(ctx) {
const project = await ppro.Project.getActiveProject();
if (!project) return { ok: false, detail: "No active project — open one and re-run." };
const sequence = await project.getActiveSequence();
if (!sequence) return { ok: false, detail: "No active sequence — open one and re-run." };
// getPlayerPosition() -> TickTime; getFrameSize() -> RectF, which despite the name has
// only width/height (no x/y).
const time = await sequence.getPlayerPosition();
const rect = await sequence.getFrameSize();
const filename = "mcp-uxp-spike-frame.png";
const returned = await ppro.Exporter.exportSequenceFrame(
sequence,
time,
filename,
ctx.outDir,
rect.width,
rect.height
);
// The QE DOM lies about this — it returns false on builds where it works and true on
// builds where it doesn't. So the return value is recorded but never trusted; the
// filesystem is the only thing that decides.
const fullPath = joinPath(ctx.outDir, filename);
const onDisk = await fileExists(fullPath);
return {
ok: onDisk,
detail:
"returned " + JSON.stringify(returned) +
"\nfile on disk: " + (onDisk ? "YES — " + fullPath : "NO (" + fullPath + ")") +
"\nsequence: " + rect.width + "x" + rect.height +
" @ " + time.seconds + "s" +
(onDisk
? "\n=> UXP fixes issue #9. This is the supported frame-capture path."
: "\n=> No file written. Check the out dir exists and is writable."),
};
}
// --- Probes B/C: can the panel open a socket to a local MCP server? -----------
function probeWebSocket(ctx, host) {
const url = "ws://" + host + ":" + ctx.port;
return new Promise((resolve) => {
let socket;
let settled = false;
const finish = (ok, detail) => {
if (settled) return;
settled = true;
try { if (socket) socket.close(); } catch (e) { /* already gone */ }
resolve({ ok, detail });
};
// No connection attempt should hang the panel.
const timer = setTimeout(
() => finish(false, url + "\ntimed out after 5s — no open, no error. Treat as blocked."),
5000
);
try {
socket = new WebSocket(url);
} catch (e) {
clearTimeout(timer);
return finish(false, url + "\nconstructor threw: " + errText(e));
}
socket.onopen = () => {
try {
socket.send(JSON.stringify({ probe: "hello", host: host }));
} catch (e) {
clearTimeout(timer);
finish(false, url + "\nopened but send() threw: " + errText(e));
}
};
// Only a round-trip proves the transport. An open event alone doesn't.
socket.onmessage = (event) => {
clearTimeout(timer);
finish(
true,
url + "\nround-trip OK. Server echoed: " + String(event.data) +
"\n=> Loopback WebSocket works. The temp-file bridge can go."
);
};
socket.onerror = (err) => {
clearTimeout(timer);
finish(
false,
url + "\nerror: " + (errText(err) || "(no detail — UXP often gives none)") +
"\nIs the spike server running? node uxp-spike/server.mjs"
);
};
socket.onclose = (ev) => {
if (settled) return;
clearTimeout(timer);
finish(false, url + "\nclosed before any message (code " + (ev && ev.code) + ")");
};
});
}
// --- Probe D: fetch() as a fallback transport ---------------------------------
async function probeFetch(ctx) {
// macOS is documented to restrict http://. If that restriction extends to loopback,
// this fails and the answer matters as much as a pass.
const url = "http://127.0.0.1:" + ctx.port + "/probe";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ probe: "fetch" }),
});
const body = await res.text();
return {
ok: res.ok,
detail: url + "\nHTTP " + res.status + "\nbody: " + body,
};
}
// --- Probe E: the fallback we already know works ------------------------------
async function probeFileSystem(ctx) {
const fs = require("fs");
const probePath = joinPath(ctx.outDir, "mcp-uxp-spike-fs.json");
const payload = JSON.stringify({ probe: "fs", at: new Date().toISOString() });
await fs.writeFile(probePath, payload, { encoding: "utf-8" });
const readBack = await fs.readFile(probePath, { encoding: "utf-8" });
return {
ok: readBack === payload,
detail:
probePath +
"\nwrite+read round-trip: " + (readBack === payload ? "OK" : "MISMATCH") +
"\n=> If B/C/D all fail, this is the transport we keep.",
};
}
// --- helpers ------------------------------------------------------------------
async function fileExists(path) {
try {
const fs = require("fs");
await fs.lstat(path);
return true;
} catch (e) {
return false;
}
}
function joinPath(dir, name) {
return dir.charAt(dir.length - 1) === "/" ? dir + name : dir + "/" + name;
}
function errText(e) {
if (!e) return "";
return e.message || e.type || String(e);
}
function render(id, name, { state, detail }) {
let el = document.getElementById("probe-" + id);
if (!el) {
el = document.createElement("div");
el.id = "probe-" + id;
document.getElementById("results").appendChild(el);
}
el.className = "probe " + state;
el.innerHTML = "";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = (state === "pass" ? "PASS " : state === "fail" ? "FAIL " : "... ") + name;
const detailEl = document.createElement("div");
detailEl.className = "detail";
detailEl.textContent = detail;
el.appendChild(nameEl);
el.appendChild(detailEl);
}
async function summarize(ctx) {
const verdict = {
ranAt: new Date().toISOString(),
host: "premierepro",
probes: {},
};
for (const p of PROBES) {
verdict.probes[p.id] = { ok: !!(results[p.id] && results[p.id].ok), detail: results[p.id].detail };
}
const frameOk = verdict.probes["frame-export"].ok;
const socketOk = verdict.probes["ws-localhost"].ok || verdict.probes["ws-loopback-ip"].ok;
const fetchOk = verdict.probes["fetch-http"].ok;
verdict.conclusions = {
fixesIssue9: frameOk,
canDropFileBridge: socketOk || fetchOk,
recommendedTransport: socketOk ? "websocket" : fetchOk ? "http-fetch" : "file-bridge",
};
const lines = [
"Issue #9 (frame capture): " + (frameOk ? "FIXED by UXP" : "still broken — see probe A"),
"Transport: " + verdict.conclusions.recommendedTransport,
"",
"Paste this into the spike issue:",
JSON.stringify(verdict, null, 2),
];
document.getElementById("summary").textContent = lines.join("\n");
// Best effort — the whole point of probe E is that this still works when nothing else does.
try {
const fs = require("fs");
await fs.writeFile(joinPath(ctx.outDir, "mcp-uxp-spike-report.json"), JSON.stringify(verdict, null, 2), {
encoding: "utf-8",
});
} catch (e) {
/* the panel already shows it */
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"manifestVersion": 5,
"id": "com.mcp.premiere.uxp.spike",
"name": "MCP UXP Spike",
"version": "0.1.0",
"main": "index.html",
"host": {
"app": "premierepro",
"minVersion": "25.6.0"
},
"entrypoints": [
{
"type": "panel",
"id": "spikePanel",
"label": { "default": "MCP UXP Spike" },
"minimumSize": { "width": 380, "height": 480 },
"preferredDockedSize": { "width": 420, "height": 640 },
"preferredFloatingSize": { "width": 480, "height": 700 }
}
],
"requiredPermissions": {
"localFileSystem": "fullAccess",
"network": {
"domains": "all"
}
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
* Spike server for the UXP probes.
*
* Serves HTTP and WebSocket on the same port so the person testing runs one command.
* Deliberately zero-dependency: a spike that needs `npm install` first is a spike people
* don't run. The WebSocket bits are a minimal RFC 6455 text-frame implementation — enough
* to prove a round-trip, and nothing more.
*
* node uxp-spike/server.mjs [port]
*/
import { createServer } from "node:http";
import { createHash } from "node:crypto";
const PORT = Number(process.argv[2]) || 7777;
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455
const seen = { http: false, ws: false };
const server = createServer((req, res) => {
if (req.method === "POST" && req.url === "/probe") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
seen.http = true;
console.log(` [http] POST /probe <- ${body}`);
report("fetch() over http://127.0.0.1 reached the server");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, echo: safeParse(body) }));
});
return;
}
res.writeHead(404).end();
});
server.on("upgrade", (req, socket) => {
const key = req.headers["sec-websocket-key"];
if (!key) return socket.destroy();
const accept = createHash("sha1").update(key + GUID).digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
);
console.log(` [ws] client connected (Host: ${req.headers.host})`);
socket.on("data", (buf) => {
const msg = decodeTextFrame(buf);
if (msg === null) return; // close/ping/binary — not worth handling in a spike
seen.ws = true;
console.log(` [ws] <- ${msg}`);
report(`WebSocket round-trip works from the UXP panel (Host: ${req.headers.host})`);
socket.write(encodeTextFrame(JSON.stringify({ ok: true, echo: safeParse(msg) })));
});
socket.on("error", () => socket.destroy());
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`\nUXP spike server listening on 127.0.0.1:${PORT}`);
console.log(" WebSocket : ws://localhost:%d and ws://127.0.0.1:%d", PORT, PORT);
console.log(" HTTP : POST http://127.0.0.1:%d/probe", PORT);
console.log("\nNow hit 'Run all probes' in the MCP UXP Spike panel in Premiere.\n");
});
function report(what) {
console.log(`\n *** ${what} ***`);
console.log(
` transports reached so far: ${[seen.ws && "websocket", seen.http && "http"].filter(Boolean).join(", ") || "none"}\n`
);
}
function safeParse(s) {
try {
return JSON.parse(s);
} catch {
return s;
}
}
/** Decode a single masked client text frame. Returns null for anything else. */
function decodeTextFrame(buf) {
if (buf.length < 2) return null;
const opcode = buf[0] & 0x0f;
if (opcode !== 0x1) return null; // text frames only
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let offset = 2;
if (len === 126) {
len = buf.readUInt16BE(2);
offset = 4;
} else if (len === 127) {
len = Number(buf.readBigUInt64BE(2));
offset = 10;
}
if (!masked) return buf.subarray(offset, offset + len).toString("utf8");
const mask = buf.subarray(offset, offset + 4);
const payload = buf.subarray(offset + 4, offset + 4 + len);
const out = Buffer.allocUnsafe(payload.length);
for (let i = 0; i < payload.length; i++) out[i] = payload[i] ^ mask[i % 4];
return out.toString("utf8");
}
/** Encode an unmasked server text frame. */
function encodeTextFrame(text) {
const payload = Buffer.from(text, "utf8");
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.from([0x81, len]);
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}