@synthetic-ai/premiere-mcp 2.3.1 → 2.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,689 +1,793 @@
1
- /* MCP Bridge - CEP Plugin Main Script
2
- * Polls a temp directory for command files (.jsx), executes them
3
- * in Premiere Pro's ExtendScript engine, and writes results back.
4
- *
5
- * Improvements over the original:
6
- * - isRunning flag guards the poll loop so concurrent executions can't pile up.
7
- * - executeScript wraps cs.evalScript with a 25-second timeout to prevent hangs.
8
- * - Status indicator updates on every state change (idle / running / error).
9
- * - Reconnect logic: if Premiere Pro is unresponsive, the bridge backs off and retries. */
10
-
11
- var cs = new CSInterface();
12
- var bridgeRunning = false;
13
- var isRunning = false; // true while a script is actively executing (prevents concurrent runs)
14
- var pollInterval = null;
15
- var commandCount = 0;
16
- var errorCount = 0;
17
- var consecutiveErrors = 0;
18
- var tempDir = "";
19
- var POLL_MS = 200;
20
- var EXEC_TIMEOUT_MS = 25000; // 25-second hard timeout for ExtendScript execution
21
- var PANEL_VERSION = "1.1.6"; // stamped by scripts/package.mjs on every build — see step 4b2
22
-
23
- // ── Auto-update config ────────────────────────────────────────────────────
24
- // The panel checks this URL at startup and offers in-app updates when a
25
- // newer version is published. Files come back through /api/v1/panel/file
26
- // gated by the user's license key. Self-installs only the hot-swappable
27
- // JS/JSX/HTML files — manifest changes still need a fresh .zxp.
28
- //
29
- // Ported from Synthetic AE MCP's cep-plugin/main.js (checkForUpdates /
30
- // showUpdateBanner / applyUpdate). One deliberate difference: AE MCP's Node
31
- // server SPAWNS AfterFX.exe, so AE_MCP_LICENSE (set in the MCP client config)
32
- // is inherited by the child process and read via $.getenv() inside
33
- // ExtendScript. Premiere MCP's Node server does NOT spawn Premiere Pro — it
34
- // talks to the already-running CEP panel as an HTTP client — so there is no
35
- // env-var-inheritance path. Instead the license key is pasted into this
36
- // panel's own Settings field and persisted to a local license.json (see
37
- // readLicenseKeyLocal / saveLicenseKey below).
38
- var UPDATE_CHECK_URL = "https://synthetic.com.ar/api/v1/panel/version";
39
- var UPDATE_FILE_URL = "https://synthetic.com.ar/api/v1/panel/file";
40
- var UPDATE_PRODUCT = "premiere-mcp";
41
- var pendingUpdate = null;
42
- var lastDismissedVersion = null;
43
-
44
- // ---- Logging ----
45
- function log(msg, cls) {
46
- var el = document.getElementById("log");
47
- var entry = document.createElement("div");
48
- entry.className = "log-entry " + (cls || "");
49
- var ts = new Date().toLocaleTimeString();
50
- entry.textContent = "[" + ts + "] " + msg;
51
- el.appendChild(entry);
52
- el.scrollTop = el.scrollHeight;
53
- // Keep max 100 entries
54
- while (el.children.length > 100) el.removeChild(el.firstChild);
55
- }
56
-
57
- // ---- Status ----
58
- function setStatus(state, text) {
59
- var dot = document.getElementById("statusDot");
60
- dot.className = "status-dot " + state;
61
- document.getElementById("statusText").textContent = text;
62
-
63
- // Mirror the same state onto the small dot inside the product block, so the
64
- // "is the panel OK?" cue is visible up-top without scanning the status row.
65
- // Map: connected -> ok, warning -> warn, error -> err, waiting/* -> grey.
66
- var ps = document.getElementById("productStatus");
67
- if (ps) {
68
- ps.className = "product-status" +
69
- (state === "connected" ? " ok" :
70
- state === "warning" ? " warn" :
71
- state === "error" ? " err" : "");
72
- ps.title = text || "Panel ready";
73
- }
74
- }
75
-
76
- // ---- File I/O via Node.js (CEP has access to Node) ----
77
- var fs = require("fs");
78
- var path = require("path");
79
- var os = require("os");
80
- tempDir = path.join(os.tmpdir(), "premiere-mcp-bridge");
81
-
82
- function ensureDir(dir) {
83
- try {
84
- if (!fs.existsSync(dir)) {
85
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
86
- }
87
- } catch (e) {
88
- log("Error creating dir: " + e.message, "err");
89
- }
90
- }
91
-
92
- function listCommandFiles() {
93
- try {
94
- if (!fs.existsSync(tempDir)) return [];
95
- var files = fs.readdirSync(tempDir);
96
- return files
97
- .filter(function (f) { return f.indexOf("cmd_") === 0 && f.indexOf(".jsx") > 0; })
98
- .sort(); // process in order
99
- } catch (e) {
100
- return [];
101
- }
102
- }
103
-
104
- function readFile(filePath) {
105
- try {
106
- return fs.readFileSync(filePath, "utf-8");
107
- } catch (e) {
108
- return null;
109
- }
110
- }
111
-
112
- function writeFile(filePath, content) {
113
- try {
114
- // Atomic write: write to a temp file the MCP server's poller ignores
115
- // (it waits for the exact res_<id>.json name), then rename into place.
116
- // Prevents the server from reading a half-written response file.
117
- var tmpPath = filePath + ".part";
118
- fs.writeFileSync(tmpPath, content, "utf-8");
119
- fs.renameSync(tmpPath, filePath);
120
- return true;
121
- } catch (e) {
122
- log("Error writing " + filePath + ": " + e.message, "err");
123
- return false;
124
- }
125
- }
126
-
127
- function deleteFile(filePath) {
128
- try {
129
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
130
- } catch (e) {}
131
- }
132
-
133
- // cs.getSystemPath() returns a file:// URI, not a plain OS path — observed
134
- // on Windows as "file:\C:\Users\..." (single backslash, not the POSIX
135
- // "file:///C:/..."). Passing that straight to fs/path calls tries to
136
- // create/read a path actually named "file:\C:\Users\...", which fails with
137
- // ENOENT since its own parent doesn't exist. Strip whatever "file:" prefix
138
- // shows up and URL-decode any %xx escapes before treating it as a path.
139
- function systemPathToFsPath(p) {
140
- var m = /^file:[\\/]*/i.exec(p || "");
141
- if (m) p = p.slice(m[0].length);
142
- try { p = decodeURIComponent(p); } catch (e) {}
143
- return p;
144
- }
145
-
146
- // ---- License key storage (local file, no env-var inheritance path) ------
147
- // Premiere Pro is already running when this panel loads — the MCP server
148
- // never spawns it — so there's no child-process env var to read the license
149
- // key from (unlike AE MCP's AE_MCP_LICENSE). Instead the user pastes the key
150
- // into the Settings field below and it's persisted next to the extension.
151
- function getLicensePath() {
152
- try {
153
- return path.join(systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION)), "license.json");
154
- } catch (e) {
155
- return null;
156
- }
157
- }
158
-
159
- function readLicenseKeyLocal() {
160
- try {
161
- var p = getLicensePath();
162
- if (!p || !fs.existsSync(p)) return "";
163
- var raw = fs.readFileSync(p, "utf-8");
164
- var parsed = JSON.parse(raw);
165
- return (parsed && parsed.licenseKey) ? String(parsed.licenseKey).trim() : "";
166
- } catch (e) {
167
- return "";
168
- }
169
- }
170
-
171
- function writeLicenseKeyLocal(key) {
172
- var p = getLicensePath();
173
- if (!p) return false;
174
- try {
175
- fs.writeFileSync(p, JSON.stringify({ licenseKey: key }, null, 2), "utf-8");
176
- return true;
177
- } catch (e) {
178
- log("Error saving license key: " + e.message, "err");
179
- return false;
180
- }
181
- }
182
-
183
- // Settings UI: load saved key into the input + status line on panel init.
184
- function loadLicenseUI() {
185
- try {
186
- var key = readLicenseKeyLocal();
187
- var input = document.getElementById("licenseKeyInput");
188
- if (input) input.value = key;
189
- var statusEl = document.getElementById("licenseStatus");
190
- if (statusEl) statusEl.textContent = key ? "License key saved." : "No license key saved yet — required for updates.";
191
- } catch (e) {}
192
- }
193
-
194
- // Settings UI: Save button handler.
195
- function saveLicenseKey() {
196
- var input = document.getElementById("licenseKeyInput");
197
- var key = input ? input.value.trim() : "";
198
- var statusEl = document.getElementById("licenseStatus");
199
- if (!key) {
200
- if (statusEl) statusEl.textContent = "Enter a license key before saving.";
201
- return;
202
- }
203
- if (writeLicenseKeyLocal(key)) {
204
- log("License key saved.", "ok");
205
- if (statusEl) statusEl.textContent = "License key saved.";
206
- } else if (statusEl) {
207
- statusEl.textContent = "Failed to save license key (see log).";
208
- }
209
- }
210
-
211
- // Settings is just the license field for now — no need for AE Agent's full
212
- // Subscription/API Keys/Data section system. Hidden by default so the key
213
- // isn't sitting in plain view on the main panel; click the ⚙ to reveal it.
214
- function toggleSettings() {
215
- var panel = document.getElementById("settingsPanel");
216
- if (!panel) return;
217
- panel.style.display = panel.style.display === "none" ? "block" : "none";
218
- }
219
-
220
- // ---- Script Execution with timeout ----
221
- function executeScript(script, callback) {
222
- // Script is already wrapped in an IIFE by the MCP server's buildScript(),
223
- // so we pass it directly to avoid double-wrapping.
224
- var timedOut = false;
225
- var settled = false;
226
-
227
- var timeoutHandle = setTimeout(function () {
228
- if (settled) return;
229
- timedOut = true;
230
- settled = true;
231
- log("Execution timed out after " + (EXEC_TIMEOUT_MS / 1000) + "s", "err");
232
- consecutiveErrors++;
233
- updateStatusFromErrors();
234
- callback(JSON.stringify({ success: false, error: "ExtendScript execution timed out after " + (EXEC_TIMEOUT_MS / 1000) + " seconds. Premiere Pro may be busy or unresponsive." }));
235
- }, EXEC_TIMEOUT_MS);
236
-
237
- cs.evalScript(script, function (result) {
238
- if (settled) return; // already timed out
239
- settled = true;
240
- clearTimeout(timeoutHandle);
241
- consecutiveErrors = 0; // reset on success
242
- callback(result);
243
- });
244
- }
245
-
246
- // ---- Update status dot based on error state ----
247
- function updateStatusFromErrors() {
248
- if (!bridgeRunning) return;
249
- if (consecutiveErrors >= 3) {
250
- setStatus("error", "Running — Premiere Pro unresponsive (" + consecutiveErrors + " errors)");
251
- } else if (consecutiveErrors > 0) {
252
- setStatus("warning", "Running — " + consecutiveErrors + " consecutive error(s)");
253
- } else {
254
- setStatus("connected", "Running — " + commandCount + " commands processed");
255
- }
256
- }
257
-
258
- // ---- Command Processing ----
259
- function processCommands() {
260
- // Prevent concurrent executions: if a script is still running, skip this poll tick.
261
- if (isRunning) return;
262
-
263
- var cmdFiles = listCommandFiles();
264
- if (cmdFiles.length === 0) return;
265
-
266
- // Process one command at a time (FIFO). The next tick picks up the next file.
267
- isRunning = true;
268
- processOneCommand(cmdFiles[0]);
269
- }
270
-
271
- function processOneCommand(cmdFileName) {
272
- var cmdFilePath = path.join(tempDir, cmdFileName);
273
- var script = readFile(cmdFilePath);
274
- if (!script) {
275
- log("Failed to read: " + cmdFileName, "err");
276
- deleteFile(cmdFilePath);
277
- isRunning = false; // release the lock — AE MCP's sibling file already does this;
278
- // without it a single transient read failure (e.g. a race with
279
- // the writer) wedges the bridge forever: isRunning stays true,
280
- // so every future processCommands() tick bails at its first
281
- // check and the panel looks "Running" while doing nothing.
282
- // Reproduced live 2026-09-23.
283
- return;
284
- }
285
-
286
- // Derive response filename: cmd_12345.jsx -> res_12345.json
287
- var id = cmdFileName.replace("cmd_", "").replace(".jsx", "");
288
- var resFilePath = path.join(tempDir, "res_" + id + ".json");
289
-
290
- log("Executing: " + cmdFileName + " (" + script.length + " chars)", "cmd");
291
-
292
- // Delete the command file immediately to avoid re-processing
293
- deleteFile(cmdFilePath);
294
-
295
- executeScript(script, function (result) {
296
- isRunning = false; // release the lock so the next command can be processed
297
- commandCount++;
298
- document.getElementById("cmdCount").textContent = commandCount;
299
-
300
- var response;
301
- try {
302
- // ExtendScript returns a string; try to parse it as JSON
303
- if (result && result !== "undefined" && result !== "null") {
304
- // Check if it's already valid JSON
305
- var parsed = JSON.parse(result);
306
- response = JSON.stringify(parsed);
307
- // If the parsed result contains an error field, count it
308
- if (parsed && parsed.success === false) {
309
- errorCount++;
310
- consecutiveErrors++;
311
- log("Result: Error — " + (parsed.error || "unknown"), "err");
312
- } else {
313
- consecutiveErrors = 0;
314
- log("Result: OK", "ok");
315
- }
316
- } else {
317
- response = JSON.stringify({ success: true, data: result || null });
318
- consecutiveErrors = 0;
319
- log("Result: OK (null/undefined)", "ok");
320
- }
321
- } catch (e) {
322
- // If result isn't JSON, wrap it
323
- if (result && result.indexOf("Error") === 0) {
324
- response = JSON.stringify({ success: false, error: result });
325
- errorCount++;
326
- consecutiveErrors++;
327
- log("Result: " + result, "err");
328
- } else {
329
- response = JSON.stringify({ success: true, data: result });
330
- consecutiveErrors = 0;
331
- log("Result: OK (raw)", "ok");
332
- }
333
- }
334
-
335
- updateStatusFromErrors();
336
- writeFile(resFilePath, response);
337
- });
338
- }
339
-
340
- // ---- Auto-update --------------------------------------------------------
341
- // Ported verbatim (logic-wise) from Synthetic AE MCP's cep-plugin/main.js.
342
- // Compare two semver strings (x.y.z). Returns 1 if a > b, -1 if a < b, 0 if =.
343
- // Hand-rolled because ExtendScript / CEP-Node don't ship semver.
344
- function semverCmp(a, b) {
345
- var pa = String(a || "0.0.0").split(".").map(function (n) { return parseInt(n, 10) || 0; });
346
- var pb = String(b || "0.0.0").split(".").map(function (n) { return parseInt(n, 10) || 0; });
347
- for (var i = 0; i < 3; i++) {
348
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
349
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
350
- }
351
- return 0;
352
- }
353
-
354
- // One-shot https GET that buffers the response into a string. CEP ships
355
- // the Node 'https' module already.
356
- function httpsGetJson(url, cb) {
357
- try {
358
- var https = require("https");
359
- https.get(url, function (res) {
360
- var chunks = [];
361
- // Follow one redirect. Our own /api/v1/panel/file endpoint 302s to R2
362
- // (Supabase signed URLs went through 302 too), but Next's
363
- // NextResponse.redirect() defaults to 307 — handle the whole 3xx
364
- // range so this doesn't silently break again on a framework default.
365
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
366
- return httpsGetJson(res.headers.location, cb);
367
- }
368
- res.on("data", function (c) { chunks.push(c); });
369
- res.on("end", function () {
370
- var body = Buffer.concat(chunks).toString("utf-8");
371
- if (res.statusCode !== 200) {
372
- return cb(new Error("HTTP " + res.statusCode + ": " + body.slice(0, 200)));
373
- }
374
- try { cb(null, JSON.parse(body)); }
375
- catch (e) { cb(new Error("Bad JSON: " + e.message)); }
376
- });
377
- }).on("error", function (err) { cb(err); });
378
- } catch (e) { cb(e); }
379
- }
380
-
381
- // Download a single file as a Buffer. Follows one redirect.
382
- function httpsDownload(url, cb) {
383
- try {
384
- var https = require("https");
385
- https.get(url, function (res) {
386
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
387
- return httpsDownload(res.headers.location, cb);
388
- }
389
- if (res.statusCode !== 200) {
390
- return cb(new Error("HTTP " + res.statusCode));
391
- }
392
- var chunks = [];
393
- res.on("data", function (c) { chunks.push(c); });
394
- res.on("end", function () { cb(null, Buffer.concat(chunks)); });
395
- }).on("error", function (err) { cb(err); });
396
- } catch (e) { cb(e); }
397
- }
398
-
399
- // SHA256 of a Buffer using Node's crypto. Used to verify integrity of
400
- // downloaded files before overwriting the panel's own code.
401
- function sha256(buf) {
402
- var crypto = require("crypto");
403
- return crypto.createHash("sha256").update(buf).digest("hex");
404
- }
405
-
406
- // On panel startup. Fetches the manifest, compares versions, surfaces a
407
- // banner if newer. Never throws — if anything goes wrong we silently skip
408
- // the check so the panel still starts cleanly even offline.
409
- function checkForUpdates() {
410
- try {
411
- var url = UPDATE_CHECK_URL + "?product=" + encodeURIComponent(UPDATE_PRODUCT) +
412
- "&from=" + encodeURIComponent(PANEL_VERSION);
413
- httpsGetJson(url, function (err, manifest) {
414
- if (err || !manifest || !manifest.version) return;
415
- if (semverCmp(manifest.version, PANEL_VERSION) <= 0) return;
416
- if (lastDismissedVersion === manifest.version) return;
417
- pendingUpdate = manifest;
418
- showUpdateBanner(manifest);
419
- });
420
- } catch (_) {}
421
- }
422
-
423
- function showUpdateBanner(m) {
424
- var banner = document.getElementById("updateBanner");
425
- if (!banner) return;
426
- document.getElementById("updateVersionTag").textContent = "v" + PANEL_VERSION + " -> v" + m.version;
427
- var notes = Array.isArray(m.releaseNotes) ? m.releaseNotes.slice(0, 3).join("\n") : "";
428
- document.getElementById("updateNotes").textContent = notes;
429
- banner.style.display = "block";
430
- }
431
-
432
- function dismissUpdate() {
433
- if (pendingUpdate) lastDismissedVersion = pendingUpdate.version;
434
- pendingUpdate = null;
435
- var banner = document.getElementById("updateBanner");
436
- if (banner) banner.style.display = "none";
437
- }
438
-
439
- function openInstaller() {
440
- try {
441
- if (window.cep && cep.util && cep.util.openURLInDefaultBrowser) {
442
- cep.util.openURLInDefaultBrowser("https://synthetic.com.ar/dashboard/downloads");
443
- }
444
- } catch (_) {}
445
- }
446
-
447
- // Apply the update: for each file in the manifest, download via the
448
- // license-gated endpoint, verify SHA256, back up the current file to
449
- // .bak, write the new one. If ANY file fails verification, roll back all
450
- // of them — never leave the panel in a half-updated state.
451
- function applyUpdate() {
452
- if (!pendingUpdate || !Array.isArray(pendingUpdate.files)) {
453
- log("No update to apply", "err");
454
- return;
455
- }
456
- var btn = document.getElementById("btnUpdateNow");
457
- if (btn) { btn.disabled = true; btn.textContent = "Updating..."; }
458
-
459
- var licenseKey = readLicenseKeyLocal();
460
- if (!licenseKey) {
461
- log("Update failed: no license key saved. Open Settings and paste your Premiere MCP license key.", "err");
462
- var statusEl = document.getElementById("licenseStatus");
463
- if (statusEl) statusEl.textContent = "License key required before updating.";
464
- if (btn) { btn.disabled = false; btn.textContent = "Update now"; }
465
- return;
466
- }
467
-
468
- // CEP can ask its own extension folder via cs.getSystemPath.
469
- var extDir = systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION));
470
- var files = pendingUpdate.files;
471
- var version = pendingUpdate.version;
472
- var staged = []; // {targetPath, backupPath, buf}
473
- var i = 0;
474
-
475
- function fetchNext() {
476
- if (i >= files.length) { commit(); return; }
477
- var f = files[i++];
478
- var url = UPDATE_FILE_URL +
479
- "?product=" + encodeURIComponent(UPDATE_PRODUCT) +
480
- "&name=" + encodeURIComponent(f.name) +
481
- "&licenseKey=" + encodeURIComponent(licenseKey) +
482
- "&version=" + encodeURIComponent(version);
483
- log("Downloading " + f.name + "...");
484
- httpsDownload(url, function (err, buf) {
485
- if (err) return abort("Download failed for " + f.name + ": " + err.message);
486
- // Optional SHA256 check — only enforced if the manifest provides one.
487
- if (f.sha256 && f.sha256.length === 64) {
488
- var got = sha256(buf);
489
- if (got !== f.sha256) return abort("SHA256 mismatch for " + f.name + " (got " + got.slice(0, 12) + "..., expected " + f.sha256.slice(0, 12) + "...)");
490
- }
491
- // Optional size sanity check.
492
- if (f.size && Math.abs(buf.length - f.size) > 1024) {
493
- log("Size differs from manifest for " + f.name + " (" + buf.length + " vs " + f.size + ") — continuing", "");
494
- }
495
- var target = path.join(extDir, f.name);
496
- // The "CSXS" subfolder gets resolved separately; everything in our
497
- // panel lives at the extension root, so a flat join is correct.
498
- staged.push({ target: target, backup: target + ".bak", buf: buf });
499
- fetchNext();
500
- });
501
- }
502
-
503
- function commit() {
504
- try {
505
- // Phase 1: backup. If any backup write fails, abort before touching real files.
506
- for (var k = 0; k < staged.length; k++) {
507
- var s = staged[k];
508
- if (fs.existsSync(s.target)) {
509
- // Best effort — rename can fail if source is locked; copy as fallback.
510
- try { fs.copyFileSync(s.target, s.backup); }
511
- catch (eCopy) { return abort("Backup failed for " + s.target + ": " + eCopy.message); }
512
- }
513
- }
514
- // Phase 2: write new files.
515
- for (var j = 0; j < staged.length; j++) {
516
- var ss = staged[j];
517
- var tmpPath = ss.target + ".new";
518
- fs.writeFileSync(tmpPath, ss.buf);
519
- // Replace via rename so the swap is atomic per-file.
520
- if (fs.existsSync(ss.target)) fs.unlinkSync(ss.target);
521
- fs.renameSync(tmpPath, ss.target);
522
- }
523
- log("Update v" + version + " applied. RESTART PREMIERE PRO to load the new panel.", "ok");
524
- setStatus("warning", "Update applied — restart Premiere Pro");
525
- var b = document.getElementById("updateBanner");
526
- if (b) {
527
- b.style.background = "rgba(40,180,120,0.10)";
528
- b.style.borderColor = "#28b478";
529
- document.getElementById("updateNotes").innerHTML =
530
- "<strong style='color:#9af3c4'>Update applied.</strong> Close and reopen Premiere Pro to activate v" + version + ".";
531
- var bt = document.getElementById("btnUpdateNow");
532
- if (bt) { bt.style.display = "none"; }
533
- var bt2 = document.getElementById("btnUpdateLater");
534
- if (bt2) bt2.textContent = "OK";
535
- var bt3 = document.getElementById("btnUpdateInstaller");
536
- if (bt3) bt3.style.display = "none";
537
- }
538
- } catch (eCommit) {
539
- // Rollback: restore any .bak we wrote.
540
- for (var r = 0; r < staged.length; r++) {
541
- try {
542
- var rs = staged[r];
543
- if (fs.existsSync(rs.backup)) {
544
- if (fs.existsSync(rs.target)) fs.unlinkSync(rs.target);
545
- fs.renameSync(rs.backup, rs.target);
546
- }
547
- } catch (_) {}
548
- }
549
- abort("Commit failed (rolled back): " + eCommit.message);
550
- }
551
- }
552
-
553
- function abort(msg) {
554
- log("Update aborted: " + msg, "err");
555
- if (btn) { btn.disabled = false; btn.textContent = "Update now"; }
556
- setStatus("error", "Update failed");
557
- return;
558
- }
559
-
560
- fetchNext();
561
- }
562
-
563
- // ---- Bridge Control ----
564
- function startBridge() {
565
- tempDir = document.getElementById("tempDir").value.trim();
566
- if (!tempDir) {
567
- log("Please set a temp directory", "err");
568
- return;
569
- }
570
-
571
- ensureDir(tempDir);
572
- bridgeRunning = true;
573
- setStatus("connected", "Running — polling " + tempDir);
574
- log("Bridge started. Temp dir: " + tempDir);
575
-
576
- document.getElementById("btnStart").disabled = true;
577
- document.getElementById("btnStop").disabled = false;
578
-
579
- // Verify Premiere Pro connection
580
- cs.evalScript("app.version", function (version) {
581
- if (version && version !== "undefined") {
582
- log("Premiere Pro: " + version, "ok");
583
- } else {
584
- log("Warning: Could not detect Premiere Pro version", "err");
585
- }
586
- });
587
-
588
- pollInterval = setInterval(function () {
589
- if (bridgeRunning) processCommands();
590
- }, POLL_MS);
591
- }
592
-
593
- function stopBridge() {
594
- bridgeRunning = false;
595
- if (pollInterval) clearInterval(pollInterval);
596
- pollInterval = null;
597
-
598
- setStatus("", "Stopped");
599
- log("Bridge stopped");
600
-
601
- document.getElementById("btnStart").disabled = false;
602
- document.getElementById("btnStop").disabled = true;
603
- }
604
-
605
- function saveTempDir() {
606
- tempDir = document.getElementById("tempDir").value.trim();
607
- log("Temp directory saved: " + tempDir);
608
- // Persist via localStorage
609
- try {
610
- localStorage.setItem("mcp_bridge_temp_dir", tempDir);
611
- } catch (e) {}
612
- }
613
-
614
- // ---- Init ----
615
- (function init() {
616
- // ORDERING RULE (ported from AE MCP): schedule the auto-update check
617
- // FIRST, before any statement that could throw, so a future regression
618
- // elsewhere in init() can't strand users on a buggy version with no way
619
- // to receive the fix from the panel itself.
620
- try {
621
- setTimeout(checkForUpdates, 500);
622
- } catch (e) {
623
- try { console.error("[panel init] scheduling auto-update failed:", e); } catch (_) {}
624
- }
625
-
626
- // Set the default temp dir in the input field
627
- document.getElementById("tempDir").value = tempDir;
628
-
629
- // Header badge shipped hardcoded to "v2.0.0" (leftover from an npm
630
- // package rename, never a real panel content version — the actual panel
631
- // had stayed at 1.1.1-1.1.7 the whole time) and never updated since.
632
- // Same bug already fixed in AE MCP's and AutoEdit Pro's panels.
633
- try { document.getElementById("synthHeaderVersion").textContent = "v" + PANEL_VERSION; } catch (e) {}
634
-
635
- // Populate the Settings license field from local storage.
636
- try { loadLicenseUI(); } catch (e) {}
637
-
638
- // Load premiere.jsx library (extended function library from AdobePremiereProMCP).
639
- // Same file:// URI cleanup as getLicensePath()/applyUpdate() — wasn't the
640
- // cause of the "EvalScript error." bug (that was premiere.jsx's own
641
- // obfuscation, see scripts/package.mjs), but a raw file:// URI here is
642
- // still wrong on principle, so clean it the same way everywhere.
643
- var premiereJsxPath = systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION)).replace(/\\/g, "/");
644
- cs.evalScript('$.evalFile("' + premiereJsxPath + '/premiere.jsx")', function(result) {
645
- log("premiere.jsx loaded: " + (result === "true" || result === "undefined" ? "OK" : result));
646
- });
647
-
648
- // Restore saved temp dir
649
- try {
650
- var saved = localStorage.getItem("mcp_bridge_temp_dir");
651
- if (saved) {
652
- tempDir = saved;
653
- document.getElementById("tempDir").value = tempDir;
654
- }
655
- } catch (e) {}
656
-
657
- // Wire any element with [data-href] to open the URL in the user's default
658
- // browser (CEP intercepts <a href> navigation; we have to bridge through
659
- // cep.util.openURLInDefaultBrowser to escape the WebView).
660
- function openExternal(url) {
661
- try {
662
- if (window.cep && cep.util && cep.util.openURLInDefaultBrowser) {
663
- cep.util.openURLInDefaultBrowser(url);
664
- } else if (window.__adobe_cep__ && window.__adobe_cep__.openURLInDefaultBrowser) {
665
- window.__adobe_cep__.openURLInDefaultBrowser(url);
666
- } else {
667
- window.open(url, "_blank");
668
- }
669
- } catch (e) { /* swallow — non-fatal */ }
670
- }
671
- var links = document.querySelectorAll("[data-href]");
672
- for (var i = 0; i < links.length; i++) {
673
- (function (el) {
674
- el.addEventListener("click", function (ev) {
675
- ev.preventDefault();
676
- openExternal(el.getAttribute("data-href"));
677
- });
678
- })(links[i]);
679
- }
680
-
681
- log("Synthetic Premiere MCP loaded");
682
- setStatus("waiting", "Ready — click Start Bridge");
683
-
684
- // Auto-start if temp dir exists
685
- if (fs.existsSync(tempDir)) {
686
- log("Temp directory exists, auto-starting...");
687
- setTimeout(startBridge, 500);
688
- }
689
- })();
1
+ /* MCP Bridge - CEP Plugin Main Script
2
+ * Polls a temp directory for command files (.jsx), executes them
3
+ * in Premiere Pro's ExtendScript engine, and writes results back.
4
+ *
5
+ * Improvements over the original:
6
+ * - isRunning flag guards the poll loop so concurrent executions can't pile up.
7
+ * - executeScript wraps cs.evalScript with a 25-second timeout to prevent hangs.
8
+ * - Status indicator updates on every state change (idle / running / error).
9
+ * - Reconnect logic: if Premiere Pro is unresponsive, the bridge backs off and retries. */
10
+
11
+ var cs = new CSInterface();
12
+ var bridgeRunning = false;
13
+ var isRunning = false; // true while a script is actively executing (prevents concurrent runs)
14
+ var pollInterval = null;
15
+ var commandCount = 0;
16
+ var errorCount = 0;
17
+ var consecutiveErrors = 0;
18
+ var tempDir = "";
19
+ var POLL_MS = 200;
20
+ var EXEC_TIMEOUT_MS = 25000; // 25-second hard timeout for ExtendScript execution
21
+ var PANEL_VERSION = "1.1.6"; // stamped by scripts/package.mjs on every build — see step 4b2
22
+
23
+ // ── Auto-update config ────────────────────────────────────────────────────
24
+ // The panel checks this URL at startup and offers in-app updates when a
25
+ // newer version is published. Files come back through /api/v1/panel/file
26
+ // gated by the user's license key. Self-installs only the hot-swappable
27
+ // JS/JSX/HTML files — manifest changes still need a fresh .zxp.
28
+ //
29
+ // Ported from Synthetic AE MCP's cep-plugin/main.js (checkForUpdates /
30
+ // showUpdateBanner / applyUpdate). One deliberate difference: AE MCP's Node
31
+ // server SPAWNS AfterFX.exe, so AE_MCP_LICENSE (set in the MCP client config)
32
+ // is inherited by the child process and read via $.getenv() inside
33
+ // ExtendScript. Premiere MCP's Node server does NOT spawn Premiere Pro — it
34
+ // talks to the already-running CEP panel as an HTTP client — so there is no
35
+ // env-var-inheritance path. Instead the license key is pasted into this
36
+ // panel's own Settings field and persisted to a local license.json (see
37
+ // readLicenseKeyLocal / saveLicenseKey below).
38
+ var UPDATE_CHECK_URL = "https://synthetic.com.ar/api/v1/panel/version";
39
+ var UPDATE_FILE_URL = "https://synthetic.com.ar/api/v1/panel/file";
40
+ var UPDATE_PRODUCT = "premiere-mcp";
41
+ var pendingUpdate = null;
42
+ var lastDismissedVersion = null;
43
+
44
+ // ---- i18n ----------------------------------------------------------------
45
+ // UI strings come from i18n/ (vendored from synthetic-idiomas by
46
+ // scripts/sync-i18n.mjs): [data-i18n*] attributes in index.html + t() here.
47
+ // The language picked in Settings is saved to
48
+ // <USER_DATA>/com.mcp.premiere.bridge/settings.json — the same file the AI
49
+ // Chat panel uses and the MCP server reads when it starts. This panel's UI
50
+ // switches immediately; the AI tool descriptions switch when the MCP server
51
+ // restarts (MCP clients only read them on connect).
52
+ var I18N_SETTINGS_FOLDER = "com.mcp.premiere.bridge";
53
+ var i18n = initI18n();
54
+ var lastStatus = { state: "", key: "ui.bridge.status.stopped", vars: null };
55
+
56
+ function initI18n() {
57
+ var none = {
58
+ language: "en",
59
+ t: function (key) { return key; },
60
+ apply: function () {},
61
+ onChange: function () {},
62
+ renderSelector: function () { return null; }
63
+ };
64
+ if (typeof SyntheticPanelI18n === "undefined") return none; // i18n/ missing: HTML stays English
65
+ try {
66
+ // The minimal CSInterface shim doesn't fill cs.hostEnvironment; the
67
+ // host app's UI language is the default until the user picks one.
68
+ // (panel.cjs does its own file:// URI → path cleanup for USER_DATA.)
69
+ if (!cs.hostEnvironment && cs.getHostEnvironment) cs.hostEnvironment = cs.getHostEnvironment();
70
+ return SyntheticPanelI18n.init({ product: "premiere-mcp", settingsFolder: I18N_SETTINGS_FOLDER, cs: cs });
71
+ } catch (e) {
72
+ try { console.error("[panel init] i18n failed:", e); } catch (_) {}
73
+ try { log("i18n init failed: " + e.message, "err"); } catch (_) {}
74
+ return none;
75
+ }
76
+ }
77
+
78
+ function t(key, vars) {
79
+ return i18n.t(key, vars);
80
+ }
81
+
82
+ // Set an element's text from a key and remember key + vars on it, so a
83
+ // language switch re-renders it (see applyI18n).
84
+ function setText(el, key, vars) {
85
+ if (!el) return;
86
+ el.setAttribute("data-i18n", key);
87
+ if (vars) el.setAttribute("data-i18n-vars", JSON.stringify(vars));
88
+ else el.removeAttribute("data-i18n-vars");
89
+ el.textContent = t(key, vars);
90
+ }
91
+
92
+ // i18n.apply() translates [data-i18n], [data-i18n-title], [data-i18n-placeholder]
93
+ // and [data-i18n-aria-label]; elements with {placeholders} also carry
94
+ // data-i18n-vars and are filled here.
95
+ function applyI18n() {
96
+ i18n.apply(document);
97
+ var nodes = document.querySelectorAll("[data-i18n-vars]");
98
+ for (var i = 0; i < nodes.length; i++) {
99
+ var vars = null;
100
+ try { vars = JSON.parse(nodes[i].getAttribute("data-i18n-vars")); } catch (e) {}
101
+ nodes[i].textContent = t(nodes[i].getAttribute("data-i18n"), vars);
102
+ }
103
+ }
104
+
105
+ // Settings: language selector next to the license key.
106
+ function renderLanguageSettings() {
107
+ var box = document.getElementById("languageSettings");
108
+ if (!box) return;
109
+ i18n.renderSelector(box, { restartNote: true });
110
+ i18n.onChange(function (lang, saved) {
111
+ applyI18n();
112
+ setStatus(lastStatus.state, lastStatus.key, lastStatus.vars);
113
+ setText(document.getElementById("languageStatus"), saved ? "ui.i18n.saved" : "ui.i18n.saveFailed");
114
+ });
115
+ }
116
+
117
+ // ---- Logging ----
118
+ function log(msg, cls) {
119
+ var el = document.getElementById("log");
120
+ var entry = document.createElement("div");
121
+ entry.className = "log-entry " + (cls || "");
122
+ var ts = new Date().toLocaleTimeString();
123
+ entry.textContent = "[" + ts + "] " + msg;
124
+ el.appendChild(entry);
125
+ el.scrollTop = el.scrollHeight;
126
+ // Keep max 100 entries
127
+ while (el.children.length > 100) el.removeChild(el.firstChild);
128
+ }
129
+
130
+ // ---- Status ----
131
+ // key/vars: i18n key of the status line (kept so a language switch re-renders it).
132
+ function setStatus(state, key, vars) {
133
+ lastStatus = { state: state, key: key, vars: vars || null };
134
+ var text = t(key, vars);
135
+ var dot = document.getElementById("statusDot");
136
+ dot.className = "status-dot " + state;
137
+ setText(document.getElementById("statusText"), key, vars);
138
+
139
+ // Mirror the same state onto the small dot inside the product block, so the
140
+ // "is the panel OK?" cue is visible up-top without scanning the status row.
141
+ // Map: connected -> ok, warning -> warn, error -> err, waiting/* -> grey.
142
+ var ps = document.getElementById("productStatus");
143
+ if (ps) {
144
+ ps.className = "product-status" +
145
+ (state === "connected" ? " ok" :
146
+ state === "warning" ? " warn" :
147
+ state === "error" ? " err" : "");
148
+ ps.title = text || t("ui.bridge.product.statusReady");
149
+ }
150
+ }
151
+
152
+ // ---- File I/O via Node.js (CEP has access to Node) ----
153
+ var fs = require("fs");
154
+ var path = require("path");
155
+ var os = require("os");
156
+ tempDir = path.join(os.tmpdir(), "premiere-mcp-bridge");
157
+
158
+ function ensureDir(dir) {
159
+ try {
160
+ if (!fs.existsSync(dir)) {
161
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
162
+ }
163
+ } catch (e) {
164
+ log(t("ui.bridge.log.createDirFailed", { error: e.message }), "err");
165
+ }
166
+ }
167
+
168
+ function listCommandFiles() {
169
+ try {
170
+ if (!fs.existsSync(tempDir)) return [];
171
+ var files = fs.readdirSync(tempDir);
172
+ return files
173
+ .filter(function (f) { return f.indexOf("cmd_") === 0 && f.indexOf(".jsx") > 0; })
174
+ .sort(); // process in order
175
+ } catch (e) {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ function readFile(filePath) {
181
+ try {
182
+ return fs.readFileSync(filePath, "utf-8");
183
+ } catch (e) {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function writeFile(filePath, content) {
189
+ try {
190
+ // Atomic write: write to a temp file the MCP server's poller ignores
191
+ // (it waits for the exact res_<id>.json name), then rename into place.
192
+ // Prevents the server from reading a half-written response file.
193
+ var tmpPath = filePath + ".part";
194
+ fs.writeFileSync(tmpPath, content, "utf-8");
195
+ fs.renameSync(tmpPath, filePath);
196
+ return true;
197
+ } catch (e) {
198
+ log(t("ui.bridge.log.writeFailed", { file: filePath, error: e.message }), "err");
199
+ return false;
200
+ }
201
+ }
202
+
203
+ function deleteFile(filePath) {
204
+ try {
205
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
206
+ } catch (e) {}
207
+ }
208
+
209
+ // cs.getSystemPath() returns a file:// URI, not a plain OS path — observed
210
+ // on Windows as "file:\C:\Users\..." (single backslash, not the POSIX
211
+ // "file:///C:/..."). Passing that straight to fs/path calls tries to
212
+ // create/read a path actually named "file:\C:\Users\...", which fails with
213
+ // ENOENT since its own parent doesn't exist. Strip whatever "file:" prefix
214
+ // shows up and URL-decode any %xx escapes before treating it as a path.
215
+ function systemPathToFsPath(p) {
216
+ var m = /^file:[\\/]*/i.exec(p || "");
217
+ if (m) p = p.slice(m[0].length);
218
+ try { p = decodeURIComponent(p); } catch (e) {}
219
+ return p;
220
+ }
221
+
222
+ // ---- License key storage (local file, no env-var inheritance path) ------
223
+ // Premiere Pro is already running when this panel loads — the MCP server
224
+ // never spawns it — so there's no child-process env var to read the license
225
+ // key from (unlike AE MCP's AE_MCP_LICENSE). Instead the user pastes the key
226
+ // into the Settings field below and it's persisted next to the extension.
227
+ function getLicensePath() {
228
+ try {
229
+ return path.join(systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION)), "license.json");
230
+ } catch (e) {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function readLicenseKeyLocal() {
236
+ try {
237
+ var p = getLicensePath();
238
+ if (!p || !fs.existsSync(p)) return "";
239
+ var raw = fs.readFileSync(p, "utf-8");
240
+ var parsed = JSON.parse(raw);
241
+ return (parsed && parsed.licenseKey) ? String(parsed.licenseKey).trim() : "";
242
+ } catch (e) {
243
+ return "";
244
+ }
245
+ }
246
+
247
+ function writeLicenseKeyLocal(key) {
248
+ var p = getLicensePath();
249
+ if (!p) return false;
250
+ try {
251
+ fs.writeFileSync(p, JSON.stringify({ licenseKey: key }, null, 2), "utf-8");
252
+ return true;
253
+ } catch (e) {
254
+ log(t("ui.bridge.log.licenseSaveError", { error: e.message }), "err");
255
+ return false;
256
+ }
257
+ }
258
+
259
+ // Settings UI: load saved key into the input + status line on panel init.
260
+ function loadLicenseUI() {
261
+ try {
262
+ var key = readLicenseKeyLocal();
263
+ var input = document.getElementById("licenseKeyInput");
264
+ if (input) input.value = key;
265
+ var statusEl = document.getElementById("licenseStatus");
266
+ setText(statusEl, key ? "ui.bridge.license.saved" : "ui.bridge.license.none");
267
+ } catch (e) {}
268
+ }
269
+
270
+ // Settings UI: Save button handler.
271
+ function saveLicenseKey() {
272
+ var input = document.getElementById("licenseKeyInput");
273
+ var key = input ? input.value.trim() : "";
274
+ var statusEl = document.getElementById("licenseStatus");
275
+ if (!key) {
276
+ setText(statusEl, "ui.bridge.license.empty");
277
+ return;
278
+ }
279
+ if (writeLicenseKeyLocal(key)) {
280
+ log(t("ui.bridge.license.saved"), "ok");
281
+ setText(statusEl, "ui.bridge.license.saved");
282
+ } else {
283
+ setText(statusEl, "ui.bridge.license.saveFailed");
284
+ }
285
+ }
286
+
287
+ // Settings is just the license field for now — no need for AE Agent's full
288
+ // Subscription/API Keys/Data section system. Hidden by default so the key
289
+ // isn't sitting in plain view on the main panel; click the ⚙ to reveal it.
290
+ function toggleSettings() {
291
+ var panel = document.getElementById("settingsPanel");
292
+ if (!panel) return;
293
+ panel.style.display = panel.style.display === "none" ? "block" : "none";
294
+ }
295
+
296
+ // ---- Script Execution with timeout ----
297
+ function executeScript(script, callback) {
298
+ // Script is already wrapped in an IIFE by the MCP server's buildScript(),
299
+ // so we pass it directly to avoid double-wrapping.
300
+ var timedOut = false;
301
+ var settled = false;
302
+
303
+ var timeoutHandle = setTimeout(function () {
304
+ if (settled) return;
305
+ timedOut = true;
306
+ settled = true;
307
+ log(t("ui.bridge.log.execTimeout", { seconds: EXEC_TIMEOUT_MS / 1000 }), "err");
308
+ consecutiveErrors++;
309
+ updateStatusFromErrors();
310
+ callback(JSON.stringify({ success: false, error: t("ui.bridge.error.execTimeout", { seconds: EXEC_TIMEOUT_MS / 1000 }) }));
311
+ }, EXEC_TIMEOUT_MS);
312
+
313
+ cs.evalScript(script, function (result) {
314
+ if (settled) return; // already timed out
315
+ settled = true;
316
+ clearTimeout(timeoutHandle);
317
+ consecutiveErrors = 0; // reset on success
318
+ callback(result);
319
+ });
320
+ }
321
+
322
+ // ---- Update status dot based on error state ----
323
+ function updateStatusFromErrors() {
324
+ if (!bridgeRunning) return;
325
+ if (consecutiveErrors >= 3) {
326
+ setStatus("error", "ui.bridge.status.unresponsive", { count: consecutiveErrors });
327
+ } else if (consecutiveErrors > 0) {
328
+ setStatus("warning", "ui.bridge.status.errors", { count: consecutiveErrors });
329
+ } else {
330
+ setStatus("connected", "ui.bridge.status.running", { count: commandCount });
331
+ }
332
+ }
333
+
334
+ // ---- Command Processing ----
335
+ function processCommands() {
336
+ // Prevent concurrent executions: if a script is still running, skip this poll tick.
337
+ if (isRunning) return;
338
+
339
+ var cmdFiles = listCommandFiles();
340
+ if (cmdFiles.length === 0) return;
341
+
342
+ // Process one command at a time (FIFO). The next tick picks up the next file.
343
+ isRunning = true;
344
+ processOneCommand(cmdFiles[0]);
345
+ }
346
+
347
+ function processOneCommand(cmdFileName) {
348
+ var cmdFilePath = path.join(tempDir, cmdFileName);
349
+ var script = readFile(cmdFilePath);
350
+ if (!script) {
351
+ log(t("ui.bridge.log.readFailed", { file: cmdFileName }), "err");
352
+ deleteFile(cmdFilePath);
353
+ isRunning = false; // release the lock — AE MCP's sibling file already does this;
354
+ // without it a single transient read failure (e.g. a race with
355
+ // the writer) wedges the bridge forever: isRunning stays true,
356
+ // so every future processCommands() tick bails at its first
357
+ // check and the panel looks "Running" while doing nothing.
358
+ // Reproduced live 2026-09-23.
359
+ return;
360
+ }
361
+
362
+ // Derive response filename: cmd_12345.jsx -> res_12345.json
363
+ var id = cmdFileName.replace("cmd_", "").replace(".jsx", "");
364
+ var resFilePath = path.join(tempDir, "res_" + id + ".json");
365
+
366
+ log(t("ui.bridge.log.executing", { file: cmdFileName, chars: script.length }), "cmd");
367
+
368
+ // Delete the command file immediately to avoid re-processing
369
+ deleteFile(cmdFilePath);
370
+
371
+ executeScript(script, function (result) {
372
+ isRunning = false; // release the lock so the next command can be processed
373
+ commandCount++;
374
+ document.getElementById("cmdCount").textContent = commandCount;
375
+
376
+ var response;
377
+ try {
378
+ // ExtendScript returns a string; try to parse it as JSON
379
+ if (result && result !== "undefined" && result !== "null") {
380
+ // Check if it's already valid JSON
381
+ var parsed = JSON.parse(result);
382
+ response = JSON.stringify(parsed);
383
+ // If the parsed result contains an error field, count it
384
+ if (parsed && parsed.success === false) {
385
+ errorCount++;
386
+ consecutiveErrors++;
387
+ log(t("ui.bridge.log.resultError", { error: parsed.error || t("ui.bridge.log.unknownError") }), "err");
388
+ } else {
389
+ consecutiveErrors = 0;
390
+ log(t("ui.bridge.log.resultOk"), "ok");
391
+ }
392
+ } else {
393
+ response = JSON.stringify({ success: true, data: result || null });
394
+ consecutiveErrors = 0;
395
+ log(t("ui.bridge.log.resultOkEmpty"), "ok");
396
+ }
397
+ } catch (e) {
398
+ // If result isn't JSON, wrap it
399
+ if (result && result.indexOf("Error") === 0) {
400
+ response = JSON.stringify({ success: false, error: result });
401
+ errorCount++;
402
+ consecutiveErrors++;
403
+ log(t("ui.bridge.log.resultRaw", { result: result }), "err");
404
+ } else {
405
+ response = JSON.stringify({ success: true, data: result });
406
+ consecutiveErrors = 0;
407
+ log(t("ui.bridge.log.resultOkRaw"), "ok");
408
+ }
409
+ }
410
+
411
+ updateStatusFromErrors();
412
+ writeFile(resFilePath, response);
413
+ });
414
+ }
415
+
416
+ // ---- Auto-update --------------------------------------------------------
417
+ // Ported verbatim (logic-wise) from Synthetic AE MCP's cep-plugin/main.js.
418
+ // Compare two semver strings (x.y.z). Returns 1 if a > b, -1 if a < b, 0 if =.
419
+ // Hand-rolled because ExtendScript / CEP-Node don't ship semver.
420
+ function semverCmp(a, b) {
421
+ var pa = String(a || "0.0.0").split(".").map(function (n) { return parseInt(n, 10) || 0; });
422
+ var pb = String(b || "0.0.0").split(".").map(function (n) { return parseInt(n, 10) || 0; });
423
+ for (var i = 0; i < 3; i++) {
424
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
425
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
426
+ }
427
+ return 0;
428
+ }
429
+
430
+ // One-shot https GET that buffers the response into a string. CEP ships
431
+ // the Node 'https' module already.
432
+ function httpsGetJson(url, cb) {
433
+ try {
434
+ var https = require("https");
435
+ https.get(url, function (res) {
436
+ var chunks = [];
437
+ // Follow one redirect. Our own /api/v1/panel/file endpoint 302s to R2
438
+ // (Supabase signed URLs went through 302 too), but Next's
439
+ // NextResponse.redirect() defaults to 307 — handle the whole 3xx
440
+ // range so this doesn't silently break again on a framework default.
441
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
442
+ return httpsGetJson(res.headers.location, cb);
443
+ }
444
+ res.on("data", function (c) { chunks.push(c); });
445
+ res.on("end", function () {
446
+ var body = Buffer.concat(chunks).toString("utf-8");
447
+ if (res.statusCode !== 200) {
448
+ return cb(new Error("HTTP " + res.statusCode + ": " + body.slice(0, 200)));
449
+ }
450
+ try { cb(null, JSON.parse(body)); }
451
+ catch (e) { cb(new Error("Bad JSON: " + e.message)); }
452
+ });
453
+ }).on("error", function (err) { cb(err); });
454
+ } catch (e) { cb(e); }
455
+ }
456
+
457
+ // Download a single file as a Buffer. Follows one redirect.
458
+ function httpsDownload(url, cb) {
459
+ try {
460
+ var https = require("https");
461
+ https.get(url, function (res) {
462
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
463
+ return httpsDownload(res.headers.location, cb);
464
+ }
465
+ if (res.statusCode !== 200) {
466
+ return cb(new Error("HTTP " + res.statusCode));
467
+ }
468
+ var chunks = [];
469
+ res.on("data", function (c) { chunks.push(c); });
470
+ res.on("end", function () { cb(null, Buffer.concat(chunks)); });
471
+ }).on("error", function (err) { cb(err); });
472
+ } catch (e) { cb(e); }
473
+ }
474
+
475
+ // SHA256 of a Buffer using Node's crypto. Used to verify integrity of
476
+ // downloaded files before overwriting the panel's own code.
477
+ function sha256(buf) {
478
+ var crypto = require("crypto");
479
+ return crypto.createHash("sha256").update(buf).digest("hex");
480
+ }
481
+
482
+ // On panel startup. Fetches the manifest, compares versions, surfaces a
483
+ // banner if newer. Never throws — if anything goes wrong we silently skip
484
+ // the check so the panel still starts cleanly even offline.
485
+ function checkForUpdates() {
486
+ try {
487
+ var url = UPDATE_CHECK_URL + "?product=" + encodeURIComponent(UPDATE_PRODUCT) +
488
+ "&from=" + encodeURIComponent(PANEL_VERSION);
489
+ httpsGetJson(url, function (err, manifest) {
490
+ if (err || !manifest || !manifest.version) return;
491
+ if (semverCmp(manifest.version, PANEL_VERSION) <= 0) return;
492
+ if (lastDismissedVersion === manifest.version) return;
493
+ pendingUpdate = manifest;
494
+ showUpdateBanner(manifest);
495
+ });
496
+ } catch (_) {}
497
+ }
498
+
499
+ function showUpdateBanner(m) {
500
+ var banner = document.getElementById("updateBanner");
501
+ if (!banner) return;
502
+ document.getElementById("updateVersionTag").textContent = "v" + PANEL_VERSION + " -> v" + m.version;
503
+ var notes = Array.isArray(m.releaseNotes) ? m.releaseNotes.slice(0, 3).join("\n") : "";
504
+ document.getElementById("updateNotes").textContent = notes;
505
+ banner.style.display = "block";
506
+ }
507
+
508
+ function dismissUpdate() {
509
+ if (pendingUpdate) lastDismissedVersion = pendingUpdate.version;
510
+ pendingUpdate = null;
511
+ var banner = document.getElementById("updateBanner");
512
+ if (banner) banner.style.display = "none";
513
+ }
514
+
515
+ function openInstaller() {
516
+ try {
517
+ if (window.cep && cep.util && cep.util.openURLInDefaultBrowser) {
518
+ cep.util.openURLInDefaultBrowser("https://synthetic.com.ar/dashboard/downloads");
519
+ }
520
+ } catch (_) {}
521
+ }
522
+
523
+ // Apply the update: for each file in the manifest, download via the
524
+ // license-gated endpoint, verify SHA256, back up the current file to
525
+ // .bak, write the new one. If ANY file fails verification, roll back all
526
+ // of them — never leave the panel in a half-updated state.
527
+ function applyUpdate() {
528
+ if (!pendingUpdate || !Array.isArray(pendingUpdate.files)) {
529
+ log(t("ui.bridge.log.noUpdate"), "err");
530
+ return;
531
+ }
532
+ var btn = document.getElementById("btnUpdateNow");
533
+ if (btn) { btn.disabled = true; setText(btn, "ui.bridge.update.updating"); }
534
+
535
+ var licenseKey = readLicenseKeyLocal();
536
+ if (!licenseKey) {
537
+ log(t("ui.bridge.log.updateNoLicense"), "err");
538
+ setText(document.getElementById("licenseStatus"), "ui.bridge.license.requiredForUpdate");
539
+ if (btn) { btn.disabled = false; setText(btn, "ui.bridge.update.now"); }
540
+ return;
541
+ }
542
+
543
+ // CEP can ask its own extension folder via cs.getSystemPath.
544
+ var extDir = systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION));
545
+ var files = pendingUpdate.files;
546
+ var version = pendingUpdate.version;
547
+ var staged = []; // {targetPath, backupPath, buf}
548
+ var i = 0;
549
+
550
+ function fetchNext() {
551
+ if (i >= files.length) { commit(); return; }
552
+ var f = files[i++];
553
+ var url = UPDATE_FILE_URL +
554
+ "?product=" + encodeURIComponent(UPDATE_PRODUCT) +
555
+ "&name=" + encodeURIComponent(f.name) +
556
+ "&licenseKey=" + encodeURIComponent(licenseKey) +
557
+ "&version=" + encodeURIComponent(version);
558
+ log(t("ui.bridge.log.downloading", { file: f.name }));
559
+ httpsDownload(url, function (err, buf) {
560
+ if (err) return abort(t("ui.bridge.update.downloadFailed", { file: f.name, error: err.message }));
561
+ // Optional SHA256 check — only enforced if the manifest provides one.
562
+ if (f.sha256 && f.sha256.length === 64) {
563
+ var got = sha256(buf);
564
+ if (got !== f.sha256) return abort(t("ui.bridge.update.shaMismatch", { file: f.name, got: got.slice(0, 12), expected: f.sha256.slice(0, 12) }));
565
+ }
566
+ // Optional size sanity check.
567
+ if (f.size && Math.abs(buf.length - f.size) > 1024) {
568
+ log(t("ui.bridge.log.sizeDiffers", { file: f.name, actual: buf.length, expected: f.size }), "");
569
+ }
570
+ var target = path.join(extDir, f.name);
571
+ // The "CSXS" subfolder gets resolved separately; everything in our
572
+ // panel lives at the extension root, so a flat join is correct.
573
+ staged.push({ target: target, backup: target + ".bak", buf: buf });
574
+ fetchNext();
575
+ });
576
+ }
577
+
578
+ function commit() {
579
+ try {
580
+ // Phase 1: backup. If any backup write fails, abort before touching real files.
581
+ for (var k = 0; k < staged.length; k++) {
582
+ var s = staged[k];
583
+ if (fs.existsSync(s.target)) {
584
+ // Best effort — rename can fail if source is locked; copy as fallback.
585
+ try { fs.copyFileSync(s.target, s.backup); }
586
+ catch (eCopy) { return abort(t("ui.bridge.update.backupFailed", { file: s.target, error: eCopy.message })); }
587
+ }
588
+ }
589
+ // Phase 2: write new files.
590
+ for (var j = 0; j < staged.length; j++) {
591
+ var ss = staged[j];
592
+ var tmpPath = ss.target + ".new";
593
+ // Files may live in subfolders (e.g. i18n/): create them on first update.
594
+ fs.mkdirSync(path.dirname(ss.target), { recursive: true });
595
+ fs.writeFileSync(tmpPath, ss.buf);
596
+ // Replace via rename so the swap is atomic per-file.
597
+ if (fs.existsSync(ss.target)) fs.unlinkSync(ss.target);
598
+ fs.renameSync(tmpPath, ss.target);
599
+ }
600
+ log(t("ui.bridge.log.updateApplied", { version: version }), "ok");
601
+ setStatus("warning", "ui.bridge.status.updateApplied");
602
+ var b = document.getElementById("updateBanner");
603
+ if (b) {
604
+ b.style.background = "rgba(40,180,120,0.10)";
605
+ b.style.borderColor = "#28b478";
606
+ // Built as DOM nodes (not innerHTML) so translated text is never parsed as HTML.
607
+ var notesEl = document.getElementById("updateNotes");
608
+ notesEl.textContent = "";
609
+ var strong = document.createElement("strong");
610
+ strong.style.color = "#9af3c4";
611
+ setText(strong, "ui.bridge.update.appliedTitle");
612
+ var body = document.createElement("span");
613
+ setText(body, "ui.bridge.update.appliedBody", { version: version });
614
+ notesEl.appendChild(strong);
615
+ notesEl.appendChild(document.createTextNode(" "));
616
+ notesEl.appendChild(body);
617
+ var bt = document.getElementById("btnUpdateNow");
618
+ if (bt) { bt.style.display = "none"; }
619
+ var bt2 = document.getElementById("btnUpdateLater");
620
+ if (bt2) setText(bt2, "ui.bridge.update.ok");
621
+ var bt3 = document.getElementById("btnUpdateInstaller");
622
+ if (bt3) bt3.style.display = "none";
623
+ }
624
+ } catch (eCommit) {
625
+ // Rollback: restore any .bak we wrote.
626
+ for (var r = 0; r < staged.length; r++) {
627
+ try {
628
+ var rs = staged[r];
629
+ if (fs.existsSync(rs.backup)) {
630
+ if (fs.existsSync(rs.target)) fs.unlinkSync(rs.target);
631
+ fs.renameSync(rs.backup, rs.target);
632
+ }
633
+ } catch (_) {}
634
+ }
635
+ abort(t("ui.bridge.update.commitFailed", { error: eCommit.message }));
636
+ }
637
+ }
638
+
639
+ function abort(msg) {
640
+ log(t("ui.bridge.log.updateAborted", { reason: msg }), "err");
641
+ if (btn) { btn.disabled = false; setText(btn, "ui.bridge.update.now"); }
642
+ setStatus("error", "ui.bridge.status.updateFailed");
643
+ return;
644
+ }
645
+
646
+ fetchNext();
647
+ }
648
+
649
+ // ---- Bridge Control ----
650
+ function startBridge() {
651
+ tempDir = document.getElementById("tempDir").value.trim();
652
+ if (!tempDir) {
653
+ log(t("ui.bridge.log.tempDirMissing"), "err");
654
+ return;
655
+ }
656
+
657
+ ensureDir(tempDir);
658
+ bridgeRunning = true;
659
+ setStatus("connected", "ui.bridge.status.polling", { dir: tempDir });
660
+ log(t("ui.bridge.log.started", { dir: tempDir }));
661
+
662
+ document.getElementById("btnStart").disabled = true;
663
+ document.getElementById("btnStop").disabled = false;
664
+
665
+ // Verify Premiere Pro connection
666
+ cs.evalScript("app.version", function (version) {
667
+ if (version && version !== "undefined") {
668
+ log("Premiere Pro: " + version, "ok");
669
+ } else {
670
+ log(t("ui.bridge.log.versionUnknown"), "err");
671
+ }
672
+ });
673
+
674
+ pollInterval = setInterval(function () {
675
+ if (bridgeRunning) processCommands();
676
+ }, POLL_MS);
677
+ }
678
+
679
+ function stopBridge() {
680
+ bridgeRunning = false;
681
+ if (pollInterval) clearInterval(pollInterval);
682
+ pollInterval = null;
683
+
684
+ setStatus("", "ui.bridge.status.stopped");
685
+ log(t("ui.bridge.log.stopped"));
686
+
687
+ document.getElementById("btnStart").disabled = false;
688
+ document.getElementById("btnStop").disabled = true;
689
+ }
690
+
691
+ // "Reload panel" button — location.reload() (even after stopBridge() first,
692
+ // tried in v2.3.3) does not reliably re-run init() in this CEP host: found
693
+ // live 29-sep-2026, twice, on a fully clean install + full Premiere Pro
694
+ // restart in between — badge stuck at the raw HTML placeholder, temp dir
695
+ // field empty, "Please set a temp directory" logged, no auto-start. Rather
696
+ // than ship a third guess at an in-place reload, stop trying to fake one:
697
+ // tell the user to restart Premiere Pro, which is the one path confirmed
698
+ // to always leave the panel in a correct state.
699
+ function reloadPanel() {
700
+ try { stopBridge(); } catch (e) {}
701
+ log("Please restart Premiere Pro completely to reload the panel (in-place reload isn't reliable in this CEP host).", "err");
702
+ }
703
+
704
+ function saveTempDir() {
705
+ tempDir = document.getElementById("tempDir").value.trim();
706
+ log(t("ui.bridge.log.tempDirSaved", { dir: tempDir }));
707
+ // Persist via localStorage
708
+ try {
709
+ localStorage.setItem("mcp_bridge_temp_dir", tempDir);
710
+ } catch (e) {}
711
+ }
712
+
713
+ // ---- Init ----
714
+ (function init() {
715
+ // ORDERING RULE (ported from AE MCP): schedule the auto-update check
716
+ // FIRST, before any statement that could throw, so a future regression
717
+ // elsewhere in init() can't strand users on a buggy version with no way
718
+ // to receive the fix from the panel itself.
719
+ try {
720
+ setTimeout(checkForUpdates, 500);
721
+ } catch (e) {
722
+ try { console.error("[panel init] scheduling auto-update failed:", e); } catch (_) {}
723
+ }
724
+
725
+ // Translate the static UI first (see ---- i18n ---- above), then add the
726
+ // language selector to Settings. Both are non-fatal.
727
+ try { applyI18n(); } catch (e) { try { log("applyI18n failed: " + e.message, "err"); } catch (_) {} }
728
+ try { renderLanguageSettings(); } catch (e) { try { log("renderLanguageSettings failed: " + e.message, "err"); } catch (_) {} }
729
+
730
+ // Set the default temp dir in the input field
731
+ document.getElementById("tempDir").value = tempDir;
732
+
733
+ // Header badge shipped hardcoded to "v2.0.0" (leftover from an npm
734
+ // package rename, never a real panel content version — the actual panel
735
+ // had stayed at 1.1.1-1.1.7 the whole time) and never updated since.
736
+ // Same bug already fixed in AE MCP's and AutoEdit Pro's panels.
737
+ try { document.getElementById("synthHeaderVersion").textContent = "v" + PANEL_VERSION; } catch (e) {}
738
+
739
+ // Populate the Settings license field from local storage.
740
+ try { loadLicenseUI(); } catch (e) {}
741
+
742
+ // Load premiere.jsx library (extended function library from AdobePremiereProMCP).
743
+ // Same file:// URI cleanup as getLicensePath()/applyUpdate() — wasn't the
744
+ // cause of the "EvalScript error." bug (that was premiere.jsx's own
745
+ // obfuscation, see scripts/package.mjs), but a raw file:// URI here is
746
+ // still wrong on principle, so clean it the same way everywhere.
747
+ var premiereJsxPath = systemPathToFsPath(cs.getSystemPath(SystemPath.EXTENSION)).replace(/\\/g, "/");
748
+ cs.evalScript('$.evalFile("' + premiereJsxPath + '/premiere.jsx")', function(result) {
749
+ log(t("ui.bridge.log.libraryLoaded", { result: (result === "true" || result === "undefined" ? "OK" : result) }));
750
+ });
751
+
752
+ // Restore saved temp dir
753
+ try {
754
+ var saved = localStorage.getItem("mcp_bridge_temp_dir");
755
+ if (saved) {
756
+ tempDir = saved;
757
+ document.getElementById("tempDir").value = tempDir;
758
+ }
759
+ } catch (e) {}
760
+
761
+ // Wire any element with [data-href] to open the URL in the user's default
762
+ // browser (CEP intercepts <a href> navigation; we have to bridge through
763
+ // cep.util.openURLInDefaultBrowser to escape the WebView).
764
+ function openExternal(url) {
765
+ try {
766
+ if (window.cep && cep.util && cep.util.openURLInDefaultBrowser) {
767
+ cep.util.openURLInDefaultBrowser(url);
768
+ } else if (window.__adobe_cep__ && window.__adobe_cep__.openURLInDefaultBrowser) {
769
+ window.__adobe_cep__.openURLInDefaultBrowser(url);
770
+ } else {
771
+ window.open(url, "_blank");
772
+ }
773
+ } catch (e) { /* swallow — non-fatal */ }
774
+ }
775
+ var links = document.querySelectorAll("[data-href]");
776
+ for (var i = 0; i < links.length; i++) {
777
+ (function (el) {
778
+ el.addEventListener("click", function (ev) {
779
+ ev.preventDefault();
780
+ openExternal(el.getAttribute("data-href"));
781
+ });
782
+ })(links[i]);
783
+ }
784
+
785
+ log(t("ui.bridge.log.loaded", { name: "Synthetic Premiere MCP" }));
786
+ setStatus("waiting", "ui.bridge.status.ready");
787
+
788
+ // Auto-start if temp dir exists
789
+ if (fs.existsSync(tempDir)) {
790
+ log(t("ui.bridge.log.autoStart"));
791
+ setTimeout(startBridge, 500);
792
+ }
793
+ })();