- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
1841 lines
65 KiB
Plaintext
Executable File
1841 lines
65 KiB
Plaintext
Executable File
/* MCP for Adobe Premiere Pro - CEP Plugin Main Script
|
|
* Polls a temp directory for command files (.jsx), executes them
|
|
* in Premiere Pro's ExtendScript engine, and writes results back. */
|
|
|
|
var cs = new CSInterface();
|
|
var bridgeRunning = false;
|
|
var pollInterval = null;
|
|
var commandCount = 0;
|
|
var tempDir = "";
|
|
var POLL_MS = 200;
|
|
var HEARTBEAT_MS = 1000;
|
|
var heartbeatInterval = null;
|
|
|
|
// ---- Logging ----
|
|
function log(msg, cls) {
|
|
var el = document.getElementById("log");
|
|
var entry = document.createElement("div");
|
|
entry.className = "log-entry " + (cls || "");
|
|
var ts = new Date().toLocaleTimeString();
|
|
entry.textContent = "[" + ts + "] " + msg;
|
|
el.appendChild(entry);
|
|
el.scrollTop = el.scrollHeight;
|
|
// Keep max 100 entries
|
|
while (el.children.length > 100) el.removeChild(el.firstChild);
|
|
}
|
|
|
|
// ---- Tabs ----
|
|
function switchTab(name) {
|
|
var tabs = [
|
|
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
|
|
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
|
|
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
|
|
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
|
|
];
|
|
tabs.forEach(function (t) {
|
|
var active = t.key === name;
|
|
var panel = document.getElementById(t.panel);
|
|
var btn = document.getElementById(t.btn);
|
|
if (!panel || !btn) return;
|
|
panel.hidden = !active;
|
|
btn.classList.toggle("active", active);
|
|
btn.setAttribute("aria-selected", String(active));
|
|
});
|
|
if (name === "models") renderModelList();
|
|
if (name === "silence") syncTranscribeModelSelect();
|
|
if (name === "settings") renderEditorPersonalities();
|
|
}
|
|
|
|
// ---- Status ----
|
|
function setStatus(state, text) {
|
|
var dot = document.getElementById("statusDot");
|
|
dot.className = "status-dot " + state;
|
|
var statusText = document.getElementById("statusText");
|
|
statusText.textContent = text;
|
|
statusText.setAttribute("data-state", state || "stopped");
|
|
var detail = document.getElementById("statusDetail");
|
|
if (detail) {
|
|
if (state === "connected") detail.textContent = "Premiere Pro link is active";
|
|
else if (state === "waiting") detail.textContent = "Ready for an AI assistant connection";
|
|
else if (state === "error") detail.textContent = "Bridge needs attention";
|
|
else detail.textContent = "Waiting for Premiere Pro";
|
|
}
|
|
}
|
|
|
|
function setConnectionCheck(id, state, detail) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.setAttribute("data-state", state);
|
|
var text = el.getElementsByTagName("small")[0];
|
|
if (text) text.textContent = detail;
|
|
}
|
|
|
|
// This reads only boolean Premiere state. Do not put project names, paths, or
|
|
// media information in the panel: the MCP safe-check uses the same boundary.
|
|
function refreshConnectionCenter() {
|
|
if (!bridgeRunning) {
|
|
setConnectionCheck("checkConnector", "waiting", "Start the connector first");
|
|
setConnectionCheck("checkProject", "waiting", "Waiting for the connector");
|
|
setConnectionCheck("checkSequence", "waiting", "Waiting for the connector");
|
|
return;
|
|
}
|
|
setConnectionCheck("checkConnector", "ready", "Running in Premiere Pro");
|
|
setConnectionCheck("checkProject", "waiting", "Checking…");
|
|
setConnectionCheck("checkSequence", "waiting", "Checking…");
|
|
cs.evalScript(
|
|
'(function(){var p=app&&app.project;return "mcpstate:"+(p&&typeof p.name!=="undefined"?"1":"0")+","+(p&&p.activeSequence?"1":"0");}())',
|
|
function (raw) {
|
|
var match = /^mcpstate:([01]),([01])$/.exec(String(raw || ""));
|
|
if (!match) {
|
|
setConnectionCheck("checkProject", "needs-attention", "Could not read Premiere state");
|
|
setConnectionCheck("checkSequence", "needs-attention", "Could not read Premiere state");
|
|
return;
|
|
}
|
|
var projectOpen = match[1] === "1";
|
|
var sequenceOpen = match[2] === "1";
|
|
setConnectionCheck("checkProject", projectOpen ? "ready" : "needs-attention", projectOpen ? "Project open" : "Open a project in Premiere Pro");
|
|
setConnectionCheck("checkSequence", sequenceOpen ? "ready" : "needs-attention", sequenceOpen ? "Active sequence open" : "Open a sequence in Premiere Pro");
|
|
}
|
|
);
|
|
}
|
|
|
|
// ---- File I/O via Node.js (CEP has access to Node) ----
|
|
// --enable-nodejs puts `require` in the global scope on most hosts, but on some it
|
|
// lands on cep_node instead. Try both, and fail loudly rather than letting fs come
|
|
// back undefined and surface later as "Cannot read properties of undefined".
|
|
function nodeRequire(moduleName) {
|
|
if (typeof require !== "undefined") return require(moduleName);
|
|
|
|
var cepNode = typeof cep_node !== "undefined" ? cep_node : typeof window !== "undefined" ? window.cep_node : null;
|
|
if (cepNode && typeof cepNode.require === "function") return cepNode.require(moduleName);
|
|
|
|
throw new Error(
|
|
'Node.js is not available in this CEP panel, so "' + moduleName + '" could not be loaded. ' +
|
|
"Check that CSXS/manifest.xml has <Parameter>--enable-nodejs</Parameter>, then fully quit and reopen Premiere Pro."
|
|
);
|
|
}
|
|
|
|
var fs = nodeRequire("fs");
|
|
var path = nodeRequire("path");
|
|
var os = nodeRequire("os");
|
|
var https = nodeRequire("https");
|
|
function defaultBridgeDirectory() {
|
|
try {
|
|
var nodeProcess = nodeRequire("process");
|
|
var configured = nodeProcess && nodeProcess.env && nodeProcess.env.PREMIERE_TEMP_DIR;
|
|
if (typeof configured === "string" && configured.trim()) return configured.trim();
|
|
} catch (e) {
|
|
// The panel still has a safe OS temporary-directory fallback.
|
|
}
|
|
return path.join(os.tmpdir(), "premiere-mcp-bridge");
|
|
}
|
|
tempDir = defaultBridgeDirectory();
|
|
var latestUpdate = null;
|
|
var UPDATE_STATUS_STORAGE_KEY = "mcp_bridge_desktop_update_status_path";
|
|
var MAX_UPDATE_RESPONSE_BYTES = 64 * 1024;
|
|
|
|
function getPerUserGlobalInstall() {
|
|
try {
|
|
var nodeProcess = nodeRequire("process");
|
|
var appData = nodeProcess && nodeProcess.env && nodeProcess.env.APPDATA;
|
|
if (typeof appData !== "string" || !appData.trim()) return null;
|
|
var npmDirectory = path.resolve(appData, "npm");
|
|
var commandPath = path.resolve(npmDirectory, "premiere-pro-mcp.cmd");
|
|
var packagePath = path.resolve(npmDirectory, "node_modules", "premiere-pro-mcp", "package.json");
|
|
var relative = path.relative(npmDirectory, commandPath);
|
|
var packageRelative = path.relative(npmDirectory, packagePath);
|
|
if (
|
|
!relative ||
|
|
!packageRelative ||
|
|
relative.indexOf(".." + path.sep) === 0 ||
|
|
packageRelative.indexOf(".." + path.sep) === 0 ||
|
|
path.isAbsolute(relative) ||
|
|
path.isAbsolute(packageRelative) ||
|
|
!fs.existsSync(commandPath) ||
|
|
!fs.existsSync(packagePath)
|
|
) return null;
|
|
var packageMetadata = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
|
|
var serverVersion = MCPBridgeUpdater.normalizeVersion(packageMetadata && packageMetadata.version);
|
|
if (!serverVersion) return null;
|
|
return { commandPath: commandPath, serverVersion: serverVersion };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getPerUserGlobalCommand() {
|
|
var install = getPerUserGlobalInstall();
|
|
return install ? install.commandPath : null;
|
|
}
|
|
|
|
function saveUpdateStatusPath(statusPath) {
|
|
try {
|
|
localStorage.setItem(UPDATE_STATUS_STORAGE_KEY, statusPath);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function readScheduledUpdateStatus() {
|
|
var statusPath = "";
|
|
try {
|
|
statusPath = localStorage.getItem(UPDATE_STATUS_STORAGE_KEY) || "";
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (!statusPath || !path.isAbsolute(statusPath) || !fs.existsSync(statusPath)) return null;
|
|
try {
|
|
var status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
var validStates = ["waiting_for_premiere", "updating", "complete", "failed"];
|
|
if (
|
|
!status ||
|
|
status.schemaVersion !== "premiere-pro-mcp.desktop-update.v1" ||
|
|
validStates.indexOf(status.state) === -1
|
|
) return null;
|
|
return status;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
try {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
}
|
|
} catch (e) {
|
|
log("Error creating dir: " + e.message, "err");
|
|
}
|
|
}
|
|
|
|
function listCommandFiles() {
|
|
try {
|
|
if (!fs.existsSync(tempDir)) return [];
|
|
var files = fs.readdirSync(tempDir);
|
|
return files
|
|
.filter(function (f) { return f.indexOf("cmd_") === 0 && f.slice(-4) === ".jsx"; })
|
|
.sort(); // process in order
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function readFile(filePath) {
|
|
try {
|
|
return fs.readFileSync(filePath, "utf-8");
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeFile(filePath, content) {
|
|
try {
|
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
return true;
|
|
} catch (e) {
|
|
log("Error writing " + filePath + ": " + e.message, "err");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Publish responses atomically so the MCP process never sees a partially-written
|
|
// JSON file. The staging suffix is not a response filename the server will read.
|
|
function writeResponseFile(filePath, content) {
|
|
var stagedPath = filePath + ".staged";
|
|
try {
|
|
fs.writeFileSync(stagedPath, content, "utf-8");
|
|
fs.renameSync(stagedPath, filePath);
|
|
return true;
|
|
} catch (e) {
|
|
deleteFile(stagedPath);
|
|
log("Error publishing " + filePath + ": " + e.message, "err");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function deleteFile(filePath) {
|
|
try {
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
} catch (e) {}
|
|
}
|
|
|
|
// The heartbeat carries only protocol state. It is published by rename so a
|
|
// server never observes partial JSON, and an older server can ignore it.
|
|
function writeBridgeHeartbeat() {
|
|
if (!tempDir) return;
|
|
var heartbeatPath = path.join(tempDir, "bridge-heartbeat.json");
|
|
var stagedPath = heartbeatPath + "." + ENGINE_ID + ".staged";
|
|
try {
|
|
fs.writeFileSync(stagedPath, JSON.stringify({
|
|
protocolVersion: 1,
|
|
state: bridgeRunning ? "running" : "waiting"
|
|
}), "utf-8");
|
|
fs.renameSync(stagedPath, heartbeatPath);
|
|
} catch (e) {
|
|
deleteFile(stagedPath);
|
|
}
|
|
}
|
|
|
|
function startBridgeHeartbeat() {
|
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
writeBridgeHeartbeat();
|
|
heartbeatInterval = setInterval(writeBridgeHeartbeat, HEARTBEAT_MS);
|
|
}
|
|
|
|
function stopBridgeHeartbeat() {
|
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
heartbeatInterval = null;
|
|
// Keep the last heartbeat in place. Its age lets newer servers diagnose a
|
|
// stopped connector, while concurrent visible/headless panels stay isolated.
|
|
}
|
|
|
|
// ---- Script Execution ----
|
|
function executeScript(script, callback) {
|
|
// Script is already wrapped in an IIFE by the MCP server's buildScript(),
|
|
// so we pass it directly to avoid double-wrapping.
|
|
cs.evalScript(script, function (result) {
|
|
callback(result);
|
|
});
|
|
}
|
|
|
|
// ---- Command Processing ----
|
|
function processCommands() {
|
|
if (commandInFlight) return;
|
|
var cmdFiles = listCommandFiles();
|
|
// Premiere's scripting engine is stateful. Starting every discovered command
|
|
// at once lets overlapping edits race each other and overload the host. The
|
|
// atomic claim below still prevents duplicate work across the visible and
|
|
// headless panels, while this panel dispatches strictly one command at a time.
|
|
if (cmdFiles.length > 0) processOneCommand(cmdFiles[0]);
|
|
}
|
|
|
|
// Both the visible panel and the headless auto-start instance run this file.
|
|
// A rename is atomic on the same volume, so whichever engine renames first owns
|
|
// the command; the loser's rename throws and it skips the file.
|
|
var ENGINE_ID = Math.random().toString(36).slice(2, 8);
|
|
var commandInFlight = false;
|
|
|
|
function processOneCommand(cmdFileName) {
|
|
var cmdFilePath = path.join(tempDir, cmdFileName);
|
|
var claimPath = cmdFilePath + "." + ENGINE_ID + ".claimed";
|
|
try {
|
|
fs.renameSync(cmdFilePath, claimPath);
|
|
} catch (e) {
|
|
return; // another engine claimed this command
|
|
}
|
|
|
|
var script = readFile(claimPath);
|
|
deleteFile(claimPath);
|
|
if (!script) {
|
|
log("Failed to read: " + cmdFileName, "err");
|
|
return;
|
|
}
|
|
|
|
commandInFlight = true;
|
|
|
|
// Derive response filename: cmd_12345.jsx -> res_12345.json
|
|
var id = cmdFileName.replace("cmd_", "").replace(".jsx", "");
|
|
var resFilePath = path.join(tempDir, "res_" + id + ".json");
|
|
|
|
log("Executing: " + cmdFileName + " (" + script.length + " chars)", "cmd");
|
|
|
|
// While evalScript is in flight, heartbeat a busy file so the MCP server can
|
|
// tell "script still running (modal dialog?)" apart from "plugin not running".
|
|
// Only starts after 2s, so fast commands never touch the extra file.
|
|
var busyFilePath = path.join(tempDir, "busy_" + id + ".json");
|
|
var startedAt = new Date().getTime();
|
|
var busyTimer = setInterval(function () {
|
|
writeFile(busyFilePath, '{"id":"' + id + '","elapsedMs":' + (new Date().getTime() - startedAt) + "}");
|
|
}, 2000);
|
|
|
|
executeScript(script, function (result) {
|
|
clearInterval(busyTimer);
|
|
deleteFile(busyFilePath);
|
|
commandCount++;
|
|
document.getElementById("cmdCount").textContent = commandCount;
|
|
|
|
var response;
|
|
try {
|
|
// ExtendScript returns a string; try to parse it as JSON
|
|
if (result && result !== "undefined" && result !== "null") {
|
|
// Check if it's already valid JSON
|
|
var parsed = JSON.parse(result);
|
|
response = JSON.stringify(parsed);
|
|
log("Result: OK", "ok");
|
|
} else {
|
|
// An empty result means evalScript gave us nothing back. That is a bridge
|
|
// failure, not a successful command with no data — reporting it as "OK" is
|
|
// what made this so hard to diagnose. Say so.
|
|
response = JSON.stringify({
|
|
success: false,
|
|
error:
|
|
"The bridge received an empty result from evalScript (got " +
|
|
(typeof result) +
|
|
"). The script may not have run. If every command does this, the CEP panel is stale — " +
|
|
"close and reopen it (a reload is not enough), or reinstall the extension.",
|
|
});
|
|
log("Result: EMPTY — evalScript returned nothing (see response file)", "err");
|
|
}
|
|
} catch (e) {
|
|
// If result isn't JSON, wrap it
|
|
if (result && result.indexOf("Error") === 0) {
|
|
response = JSON.stringify({ success: false, error: result });
|
|
log("Result: " + result, "err");
|
|
} else {
|
|
response = JSON.stringify({ success: true, data: result });
|
|
log("Result: OK (raw)", "ok");
|
|
}
|
|
}
|
|
|
|
writeResponseFile(resFilePath, response);
|
|
commandInFlight = false;
|
|
// Continue without waiting for the next poll interval, preserving FIFO
|
|
// ordering while minimizing queue handoff latency.
|
|
if (bridgeRunning) processCommands();
|
|
});
|
|
}
|
|
|
|
// ---- Bridge Control ----
|
|
function startBridge() {
|
|
tempDir = document.getElementById("tempDir").value.trim();
|
|
if (!tempDir) {
|
|
log("Please set a temp directory", "err");
|
|
document.getElementById("tempDir").focus();
|
|
return;
|
|
}
|
|
|
|
ensureDir(tempDir);
|
|
bridgeRunning = true;
|
|
startBridgeHeartbeat();
|
|
setStatus("waiting", "Connector running");
|
|
log("Connector started and ready for safe checks.", "ok");
|
|
|
|
document.getElementById("btnStart").disabled = true;
|
|
document.getElementById("btnStop").disabled = false;
|
|
|
|
refreshConnectionCenter();
|
|
|
|
pollInterval = setInterval(function () {
|
|
if (bridgeRunning) processCommands();
|
|
}, POLL_MS);
|
|
}
|
|
|
|
function stopBridge() {
|
|
bridgeRunning = false;
|
|
writeBridgeHeartbeat();
|
|
stopBridgeHeartbeat();
|
|
if (pollInterval) clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
|
|
setStatus("", "Stopped");
|
|
refreshConnectionCenter();
|
|
log("Bridge stopped");
|
|
|
|
document.getElementById("btnStart").disabled = false;
|
|
document.getElementById("btnStop").disabled = true;
|
|
}
|
|
|
|
function saveTempDir() {
|
|
tempDir = document.getElementById("tempDir").value.trim();
|
|
log("Temp directory saved: " + tempDir);
|
|
// Persist via localStorage
|
|
try {
|
|
localStorage.setItem("mcp_bridge_temp_dir", tempDir);
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---- Connector Updates ----
|
|
function setUpdateUI(title, detail, buttonText, disabled) {
|
|
document.getElementById("updateTitle").textContent = title;
|
|
document.getElementById("updateDetail").textContent = detail;
|
|
var button = document.getElementById("btnUpdate");
|
|
button.textContent = buttonText;
|
|
button.disabled = !!disabled;
|
|
}
|
|
|
|
function updateInstructionUrl() {
|
|
return MCPBridgeUpdater.RELEASES_URL;
|
|
}
|
|
|
|
function openTrustedUpdateInstructions() {
|
|
var url = updateInstructionUrl();
|
|
if (!MCPBridgeUpdater.isTrustedDownloadUrl(url)) {
|
|
showUpdateCheckError("The update instructions link was not trusted.");
|
|
return;
|
|
}
|
|
try {
|
|
var childProcess = nodeRequire("child_process");
|
|
var command =
|
|
os.platform() === "win32"
|
|
? ["cmd.exe", ["/d", "/s", "/c", "start", "", url]]
|
|
: ["open", [url]];
|
|
var child = childProcess.spawn(command[0], command[1], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
child.unref();
|
|
} catch (e) {
|
|
showUpdateCheckError("Could not open the update instructions. Try again.");
|
|
}
|
|
}
|
|
|
|
function restoreScheduledUpdateStatus() {
|
|
var status = readScheduledUpdateStatus();
|
|
if (!status) return false;
|
|
|
|
if (status.state === "complete") {
|
|
setUpdateUI(
|
|
"Update complete",
|
|
"Restart your MCP client, then use Verify Premiere connection before editing.",
|
|
"Check again",
|
|
false
|
|
);
|
|
return true;
|
|
}
|
|
if (status.state === "failed") {
|
|
setUpdateUI(
|
|
"Update needs attention",
|
|
"Nothing was changed in your projects. Check the update command or retry after Premiere closes.",
|
|
"Check again",
|
|
false
|
|
);
|
|
return true;
|
|
}
|
|
|
|
setUpdateUI(
|
|
"Update scheduled",
|
|
status.state === "updating"
|
|
? "The global MCP server and connector are being updated. Keep Premiere closed."
|
|
: "Quit Premiere Pro. The updater will begin after it fully closes.",
|
|
"Scheduled",
|
|
true
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function checkForUpdates() {
|
|
latestUpdate = null;
|
|
var globalInstall = os.platform() === "win32" ? getPerUserGlobalInstall() : null;
|
|
var responseTooLarge = false;
|
|
setUpdateUI(
|
|
"Version " + MCPBridgeUpdater.CURRENT_VERSION,
|
|
"Checking for updates…",
|
|
"Checking…",
|
|
true
|
|
);
|
|
|
|
var request = https.get(
|
|
MCPBridgeUpdater.LATEST_PACKAGE_API,
|
|
{
|
|
headers: {
|
|
Accept: "application/vnd.npm.install-v1+json",
|
|
"User-Agent": "premiere-pro-mcp-connector/" + MCPBridgeUpdater.CURRENT_VERSION,
|
|
},
|
|
},
|
|
function (response) {
|
|
var body = "";
|
|
response.setEncoding("utf8");
|
|
response.on("data", function (chunk) {
|
|
if (body.length + chunk.length > MAX_UPDATE_RESPONSE_BYTES) {
|
|
responseTooLarge = true;
|
|
request.destroy(new Error("npm registry update record was unexpectedly large."));
|
|
return;
|
|
}
|
|
body += chunk;
|
|
});
|
|
response.on("end", function () {
|
|
if (responseTooLarge) return;
|
|
if (response.statusCode !== 200) {
|
|
showUpdateCheckError("Could not check npm (HTTP " + response.statusCode + ").");
|
|
return;
|
|
}
|
|
try {
|
|
var update = MCPBridgeUpdater.updateStateFromPackageRecord(
|
|
MCPBridgeUpdater.CURRENT_VERSION,
|
|
JSON.parse(body)
|
|
);
|
|
var serverUpdateAvailable = Boolean(
|
|
globalInstall &&
|
|
MCPBridgeUpdater.compareVersions(update.latestVersion, globalInstall.serverVersion) > 0
|
|
);
|
|
var needsUpdate = update.updateAvailable || serverUpdateAvailable;
|
|
|
|
if (needsUpdate) {
|
|
latestUpdate = {
|
|
version: update.latestVersion,
|
|
};
|
|
if (os.platform() === "win32" && globalInstall) {
|
|
var versionSummary =
|
|
"Server " + globalInstall.serverVersion + ", connector " + MCPBridgeUpdater.CURRENT_VERSION + ". ";
|
|
setUpdateUI(
|
|
"Version " + update.latestVersion + " is available",
|
|
versionSummary + "Update both together after you close Premiere.",
|
|
"Update after quit",
|
|
false
|
|
);
|
|
} else if (os.platform() === "win32") {
|
|
setUpdateUI(
|
|
"Version " + update.latestVersion + " is available",
|
|
"A global npm install was not found. This panel will not modify a source checkout.",
|
|
"Open instructions",
|
|
false
|
|
);
|
|
} else {
|
|
setUpdateUI(
|
|
"Version " + update.latestVersion + " is available",
|
|
"Open the matching release, then update your local server using the documented install path.",
|
|
"Open instructions",
|
|
false
|
|
);
|
|
}
|
|
} else {
|
|
var currentDetail = globalInstall
|
|
? "Server " + globalInstall.serverVersion + " and connector " + MCPBridgeUpdater.CURRENT_VERSION + " are current."
|
|
: "Your connector release is current. This check does not alter your projects or MCP client configuration.";
|
|
setUpdateUI(
|
|
"Version " + MCPBridgeUpdater.CURRENT_VERSION,
|
|
currentDetail,
|
|
"Check again",
|
|
false
|
|
);
|
|
}
|
|
} catch (e) {
|
|
showUpdateCheckError("npm returned an unreadable package record.");
|
|
}
|
|
});
|
|
}
|
|
);
|
|
request.setTimeout(10000, function () {
|
|
request.destroy(new Error("Update check timed out"));
|
|
});
|
|
request.on("error", function () {
|
|
showUpdateCheckError(
|
|
responseTooLarge ? "npm returned an unexpectedly large package record." : "Unable to check while offline."
|
|
);
|
|
});
|
|
}
|
|
|
|
function showUpdateCheckError(message) {
|
|
setUpdateUI(
|
|
"Version " + MCPBridgeUpdater.CURRENT_VERSION,
|
|
message,
|
|
"Check again",
|
|
false
|
|
);
|
|
}
|
|
|
|
function handleUpdateClick() {
|
|
if (!latestUpdate) {
|
|
checkForUpdates();
|
|
return;
|
|
}
|
|
|
|
if (os.platform() !== "win32") {
|
|
openTrustedUpdateInstructions();
|
|
return;
|
|
}
|
|
|
|
var cliPath = getPerUserGlobalCommand();
|
|
if (!cliPath) {
|
|
openTrustedUpdateInstructions();
|
|
return;
|
|
}
|
|
|
|
var confirmation =
|
|
"Update Premiere MCP to " + latestUpdate.version + " after Premiere Pro fully closes?\n\n" +
|
|
"This updates only the per-user global MCP server and its connector. " +
|
|
"It does not change your projects or MCP client configuration, and it will not force Premiere to close.";
|
|
if (typeof window.confirm === "function" && !window.confirm(confirmation)) return;
|
|
|
|
try {
|
|
var childProcess = nodeRequire("child_process");
|
|
var nodeCrypto = nodeRequire("crypto");
|
|
var scheduled = MCPBridgeUpdater.scheduleWindowsGlobalUpdate({
|
|
cliPath: cliPath,
|
|
runtime: {
|
|
fs: fs,
|
|
path: path,
|
|
os: os,
|
|
childProcess: childProcess,
|
|
crypto: nodeCrypto,
|
|
},
|
|
});
|
|
saveUpdateStatusPath(scheduled.statusPath);
|
|
setUpdateUI(
|
|
"Update scheduled",
|
|
"Quit Premiere Pro. The updater will refresh the global server and connector after it fully closes.",
|
|
"Scheduled",
|
|
true
|
|
);
|
|
} catch (e) {
|
|
showUpdateCheckError("Could not schedule the local update. No files were changed.");
|
|
}
|
|
}
|
|
|
|
// ---- Silence Cut tab ----
|
|
// Fixed local machine paths for the Whisper pipeline (this rig's setup).
|
|
var SILENCE_PYTHON_BIN = "/Volumes/Merongo/SISTEMAS/venvs/whisperx-transcricao/bin/python";
|
|
var SILENCE_TRANSCRIBE_SCRIPT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/transcribe_cli.py";
|
|
var SILENCE_CUTTER_SCRIPT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/silence_cutter.py";
|
|
var DEFAULT_MODELS_PATH = "/Volumes/Merongo/SISTEMAS/WHISPERX/whisper/models";
|
|
var SILENCE_OUTPUT_ROOT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/output";
|
|
var SILENCE_DOWNLOAD_SCRIPT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/download_model_cli.py";
|
|
var SILENCE_DIARIZE_SCRIPT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/diarize_cli.py";
|
|
var VOICE_FEATURES_SCRIPT = "/Volumes/Merongo/SISTEMAS/WHISPERX/transcricao/voice_features_cli.py";
|
|
var SILENCE_APPLY_SCRIPT_FALLBACK = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/scripts/apply-silence-cuts.mjs";
|
|
var EDITORIAL_ACTIONS_APPLY_SCRIPT_FALLBACK = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/scripts/apply-editorial-actions.mjs";
|
|
// Catálogo dos tamanhos oficiais do faster-whisper hospedados pela Systran no
|
|
// Hugging Face (org consistente — outras variantes como distil-* e large-v3-turbo
|
|
// vivem em orgs diferentes com nomes de pasta inconsistentes, por isso ficam de fora
|
|
// por ora). Ordenado do mais rápido/impreciso ao mais lento/preciso.
|
|
var MODEL_CATALOG = [
|
|
{ value: "tiny", label: "Tiny", approxMB: 75 },
|
|
{ value: "base", label: "Base", approxMB: 145 },
|
|
{ value: "small", label: "Small", approxMB: 483 },
|
|
{ value: "medium", label: "Medium", approxMB: 1500 },
|
|
{ value: "large-v1", label: "Large v1", approxMB: 3100 },
|
|
{ value: "large-v2", label: "Large v2", approxMB: 3100 },
|
|
{ value: "large-v3", label: "Large v3", approxMB: 3100 },
|
|
];
|
|
var DEFAULT_MODEL = "small";
|
|
|
|
function formatMB(mb) {
|
|
if (mb >= 1000) return (mb / 1000).toFixed(1).replace(/\.0$/, "") + " GB";
|
|
return Math.round(mb) + " MB";
|
|
}
|
|
|
|
function folderSizeMB(folder) {
|
|
var total = 0;
|
|
function walk(dir) {
|
|
var entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var full = path.join(dir, entries[i].name);
|
|
if (entries[i].isSymbolicLink()) continue;
|
|
if (entries[i].isDirectory()) walk(full);
|
|
else {
|
|
try { total += fs.statSync(full).size; } catch (e) {}
|
|
}
|
|
}
|
|
}
|
|
walk(folder);
|
|
return total / (1024 * 1024);
|
|
}
|
|
var LANGUAGE_CATALOG = [
|
|
{ value: "pt", label: "Português" },
|
|
{ value: "en", label: "English" },
|
|
{ value: "es", label: "Español" },
|
|
];
|
|
var DEFAULT_LANGUAGE = "pt";
|
|
|
|
var silenceState = {
|
|
sequenceName: null,
|
|
mediaPath: null,
|
|
fileBaseName: null,
|
|
outputDir: null,
|
|
transcriptPath: null,
|
|
transcribeModel: null,
|
|
cachedTranscriptPath: null,
|
|
planPath: null,
|
|
plan: null,
|
|
};
|
|
|
|
// ---- Central transcript cache ----
|
|
// Evita rodar o Whisper de novo para um vídeo já transcrito antes: indexa
|
|
// transcrições por caminho absoluto + tamanho + data de modificação do
|
|
// arquivo de mídia, para invalidar sozinho se o arquivo original mudar.
|
|
var TRANSCRIPT_CACHE_DIR = path.join(os.homedir(), ".premiere-mcp", "transcricoes");
|
|
var TRANSCRIPT_INDEX_FILE = path.join(TRANSCRIPT_CACHE_DIR, "index.json");
|
|
|
|
function loadTranscriptIndex() {
|
|
try {
|
|
if (!fs.existsSync(TRANSCRIPT_INDEX_FILE)) return {};
|
|
var parsed = JSON.parse(fs.readFileSync(TRANSCRIPT_INDEX_FILE, "utf-8"));
|
|
return (parsed && typeof parsed === "object") ? parsed : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveTranscriptIndex(index) {
|
|
try {
|
|
ensureDir(TRANSCRIPT_CACHE_DIR);
|
|
fs.writeFileSync(TRANSCRIPT_INDEX_FILE, JSON.stringify(index, null, 2));
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function transcriptCacheKey(mediaPath) {
|
|
var nodeCrypto = nodeRequire("crypto");
|
|
return nodeCrypto.createHash("sha1").update(String(mediaPath)).digest("hex");
|
|
}
|
|
|
|
// Uma entrada de índice é sempre uma lista (várias transcrições podem existir
|
|
// para o mesmo vídeo: modelos diferentes, com/sem diarização, refeitas etc.).
|
|
function entryListFor(index, key) {
|
|
var entry = index[key];
|
|
if (!entry) return [];
|
|
return Array.isArray(entry) ? entry : [entry]; // compat com formato antigo (objeto único)
|
|
}
|
|
|
|
function findCachedTranscripts(mediaPath) {
|
|
try {
|
|
var stat = fs.statSync(mediaPath);
|
|
var index = loadTranscriptIndex();
|
|
var key = transcriptCacheKey(mediaPath);
|
|
var list = entryListFor(index, key);
|
|
return list.filter(function (entry) {
|
|
return entry.size === stat.size && entry.mtimeMs === stat.mtimeMs && fs.existsSync(entry.transcriptPath);
|
|
});
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function transcriptCacheFileName(mediaPath, model, when) {
|
|
var base = slugify(path.basename(mediaPath));
|
|
var pad = function (n) { return String(n).padStart(2, "0"); };
|
|
var dateStr = when.getFullYear() + pad(when.getMonth() + 1) + pad(when.getDate());
|
|
var timeStr = pad(when.getHours()) + pad(when.getMinutes()) + pad(when.getSeconds());
|
|
return base + "-" + dateStr + "-" + timeStr + "-" + slugify(model || "modelo") + ".json";
|
|
}
|
|
|
|
function saveTranscriptToCache(mediaPath, transcriptPath, model) {
|
|
try {
|
|
var stat = fs.statSync(mediaPath);
|
|
ensureDir(TRANSCRIPT_CACHE_DIR);
|
|
var key = transcriptCacheKey(mediaPath);
|
|
var index = loadTranscriptIndex();
|
|
var list = entryListFor(index, key);
|
|
var when = new Date();
|
|
var fileName = transcriptCacheFileName(mediaPath, model, when);
|
|
var cachedPath = path.join(TRANSCRIPT_CACHE_DIR, fileName);
|
|
fs.copyFileSync(transcriptPath, cachedPath);
|
|
list.push({
|
|
mediaPath: mediaPath,
|
|
size: stat.size,
|
|
mtimeMs: stat.mtimeMs,
|
|
model: model || null,
|
|
transcriptPath: cachedPath,
|
|
savedAt: when.toISOString(),
|
|
});
|
|
index[key] = list;
|
|
saveTranscriptIndex(index);
|
|
return cachedPath;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function silenceLog(msg, cls) {
|
|
var el = document.getElementById("silenceLog");
|
|
var entry = document.createElement("div");
|
|
entry.className = "log-entry " + (cls || "");
|
|
var ts = new Date().toLocaleTimeString();
|
|
entry.textContent = "[" + ts + "] " + msg;
|
|
el.appendChild(entry);
|
|
el.scrollTop = el.scrollHeight;
|
|
while (el.children.length > 100) el.removeChild(el.firstChild);
|
|
}
|
|
|
|
function slugify(name) {
|
|
return String(name || "video")
|
|
.toLowerCase()
|
|
.replace(/\.[^/.]+$/, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "") || "video";
|
|
}
|
|
|
|
function silenceDetectClip() {
|
|
document.getElementById("silenceClipInfo").textContent = "Detectando…";
|
|
cs.evalScript(
|
|
"(function(){" +
|
|
"try{" +
|
|
"var seq=app.project.activeSequence;" +
|
|
"if(!seq) return JSON.stringify({ok:false,error:'Nenhuma sequência ativa.'});" +
|
|
"var vt=seq.videoTracks[0];" +
|
|
"if(!vt||vt.clips.numItems===0) return JSON.stringify({ok:false,error:'Vídeo 1 está vazio na sequência ativa.'});" +
|
|
"var clip=vt.clips[0];" +
|
|
"var projItem=clip.projectItem;" +
|
|
"var mediaPath=projItem&&projItem.getMediaPath?projItem.getMediaPath():'';" +
|
|
"return JSON.stringify({ok:true,sequenceName:seq.name,mediaPath:mediaPath,clipName:clip.name});" +
|
|
"}catch(e){return JSON.stringify({ok:false,error:String(e)});}" +
|
|
"}())",
|
|
function (raw) {
|
|
var result;
|
|
try {
|
|
result = JSON.parse(raw);
|
|
} catch (e) {
|
|
result = { ok: false, error: "Resposta inesperada do Premiere." };
|
|
}
|
|
var info = document.getElementById("silenceClipInfo");
|
|
if (!result.ok || !result.mediaPath) {
|
|
info.textContent = "Erro: " + (result.error || "caminho de mídia vazio.");
|
|
document.getElementById("btnTranscribe").disabled = true;
|
|
return;
|
|
}
|
|
silenceState.sequenceName = result.sequenceName;
|
|
silenceState.mediaPath = result.mediaPath;
|
|
silenceState.fileBaseName = slugify(result.clipName || result.sequenceName);
|
|
silenceState.outputDir = path.join(SILENCE_OUTPUT_ROOT, silenceState.fileBaseName);
|
|
info.textContent =
|
|
'Sequência "' + result.sequenceName + '" — clipe: ' + result.mediaPath;
|
|
document.getElementById("btnTranscribe").disabled = false;
|
|
document.getElementById("btnDiarize").disabled = true;
|
|
document.getElementById("silenceDiarizeInfo").textContent = "";
|
|
document.getElementById("btnVoiceFeatures").disabled = true;
|
|
document.getElementById("silenceVoiceFeaturesInfo").textContent = "";
|
|
silenceLog('Clipe detectado: ' + result.mediaPath);
|
|
renderCachedTranscripts();
|
|
}
|
|
);
|
|
}
|
|
|
|
function renderCachedTranscripts() {
|
|
var section = document.getElementById("silenceCachedSection");
|
|
var select = document.getElementById("silenceCachedSelect");
|
|
var entries = silenceState.mediaPath ? findCachedTranscripts(silenceState.mediaPath) : [];
|
|
if (!entries.length) {
|
|
section.hidden = true;
|
|
select.innerHTML = "";
|
|
return;
|
|
}
|
|
entries.sort(function (a, b) { return new Date(b.savedAt) - new Date(a.savedAt); });
|
|
select.innerHTML = "";
|
|
entries.forEach(function (entry, i) {
|
|
var opt = document.createElement("option");
|
|
opt.value = String(i);
|
|
opt.textContent = new Date(entry.savedAt).toLocaleString() + " — modelo " + (entry.model || "?");
|
|
select.appendChild(opt);
|
|
});
|
|
select.dataset.entries = JSON.stringify(entries);
|
|
section.hidden = false;
|
|
silenceLog(entries.length + " transcrição(ões) salva(s) encontrada(s) para este vídeo.", "ok");
|
|
}
|
|
|
|
function silenceUseCachedTranscript() {
|
|
var select = document.getElementById("silenceCachedSelect");
|
|
var entries = JSON.parse(select.dataset.entries || "[]");
|
|
var entry = entries[Number(select.value)];
|
|
if (!entry) return;
|
|
silenceState.transcriptPath = entry.transcriptPath;
|
|
silenceState.transcribeModel = entry.model;
|
|
silenceState.cachedTranscriptPath = entry.transcriptPath;
|
|
document.getElementById("silenceCacheInfo").textContent =
|
|
"Usando transcrição salva de " + new Date(entry.savedAt).toLocaleString() + ".";
|
|
document.getElementById("btnBuildPlan").disabled = false;
|
|
document.getElementById("btnDiarize").disabled = false;
|
|
document.getElementById("btnVoiceFeatures").disabled = false;
|
|
silenceLog("Transcrição salva selecionada — pronto para gerar o plano de cortes.", "ok");
|
|
}
|
|
|
|
// Premiere is launched by Finder/LaunchServices, not a login shell, so the CEP
|
|
// panel's process PATH lacks Homebrew/nvm dirs where `node` actually lives.
|
|
// Resolve an absolute path the same way SILENCE_PYTHON_BIN does, instead of
|
|
// relying on spawn("node", ...) to find it via PATH.
|
|
// cs.getSystemPath() sometimes returns a "file:/..." URI (percent-encoded,
|
|
// occasionally missing a leading slash) instead of a plain fs path. Normalize
|
|
// it before handing it to path.join/fs/spawn.
|
|
function toFsPath(maybeUri) {
|
|
var s = String(maybeUri || "");
|
|
if (s.indexOf("file:") === 0) {
|
|
s = s.replace(/^file:\/{0,3}/, "/");
|
|
try { s = decodeURIComponent(s); } catch (e) {}
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function resolveNodeBin() {
|
|
var candidates = os.platform() === "win32"
|
|
? ["node.exe"]
|
|
: ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node", "node"];
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
var candidate = candidates[i];
|
|
if (candidate.indexOf("/") !== 0 || fs.existsSync(candidate)) return candidate;
|
|
}
|
|
return "node";
|
|
}
|
|
|
|
function runStreamed(bin, args, onLine, onDone, options) {
|
|
var childProcess = nodeRequire("child_process");
|
|
var child = childProcess.spawn(bin, args, options || {});
|
|
var buffer = "";
|
|
function flushLines(chunk) {
|
|
buffer += chunk;
|
|
var parts = buffer.split("\n");
|
|
buffer = parts.pop();
|
|
for (var i = 0; i < parts.length; i++) {
|
|
if (parts[i].trim()) onLine(parts[i]);
|
|
}
|
|
}
|
|
child.stdout.on("data", function (d) { flushLines(d.toString()); });
|
|
child.stderr.on("data", function (d) { flushLines(d.toString()); });
|
|
child.on("error", function (err) { onDone(1, "spawn error: " + err.message); });
|
|
child.on("close", function (code) {
|
|
if (buffer.trim()) onLine(buffer);
|
|
onDone(code, null);
|
|
});
|
|
}
|
|
|
|
function silenceTranscribe() {
|
|
if (!silenceState.mediaPath) return;
|
|
document.getElementById("silenceCacheInfo").textContent = "";
|
|
|
|
var model = document.getElementById("silenceModel").value;
|
|
document.getElementById("btnTranscribe").disabled = true;
|
|
ensureDir(silenceState.outputDir);
|
|
var out = path.join(silenceState.outputDir, "transcricao.json");
|
|
silenceLog("Transcrevendo com modelo " + model + "… isso pode levar alguns minutos.");
|
|
var lastLine = "";
|
|
runStreamed(
|
|
SILENCE_PYTHON_BIN,
|
|
[
|
|
SILENCE_TRANSCRIBE_SCRIPT,
|
|
"--video", silenceState.mediaPath,
|
|
"--out", out,
|
|
"--model", model,
|
|
"--language", loadLanguage(),
|
|
"--models-path", effectiveModelsPath(),
|
|
],
|
|
function (line) { lastLine = line; silenceLog(line); },
|
|
function (code) {
|
|
document.getElementById("btnTranscribe").disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha na transcrição (código " + code + ").", "err");
|
|
return;
|
|
}
|
|
var parsed = null;
|
|
try { parsed = JSON.parse(lastLine); } catch (e) {}
|
|
if (!parsed || parsed.ok === false) {
|
|
silenceLog("Transcrição terminou mas resposta é inválida: " + lastLine, "err");
|
|
return;
|
|
}
|
|
silenceState.transcriptPath = out;
|
|
silenceState.transcribeModel = model;
|
|
silenceLog("Transcrição concluída: " + parsed.segments + " segmentos, " + Math.round(parsed.duration) + "s.", "ok");
|
|
applyEditorPersonalityToTranscript(out);
|
|
silenceState.cachedTranscriptPath = saveTranscriptToCache(silenceState.mediaPath, out, model);
|
|
if (silenceState.cachedTranscriptPath) {
|
|
silenceLog("Transcrição salva na pasta central para reaproveitar depois.", "ok");
|
|
}
|
|
document.getElementById("btnBuildPlan").disabled = false;
|
|
document.getElementById("btnDiarize").disabled = false;
|
|
document.getElementById("btnVoiceFeatures").disabled = false;
|
|
if (document.getElementById("silenceDiarize").checked) {
|
|
silenceDiarizeSpeakers();
|
|
}
|
|
},
|
|
hfSpawnOptions()
|
|
);
|
|
}
|
|
|
|
function silenceDiarizeSpeakers() {
|
|
if (!silenceState.transcriptPath) return;
|
|
if (!silenceState.mediaPath) {
|
|
silenceLog("Detecte o clipe/sequência primeiro (seção 1) para saber qual vídeo diarizar.", "err");
|
|
return;
|
|
}
|
|
if (!loadHfToken()) {
|
|
silenceLog("Diarização pulada: configure um HF_TOKEN na aba Configurações.", "err");
|
|
return;
|
|
}
|
|
var btn = document.getElementById("btnDiarize");
|
|
var info = document.getElementById("silenceDiarizeInfo");
|
|
if (btn) btn.disabled = true;
|
|
if (info) info.textContent = "";
|
|
silenceLog("Detectando locutores…");
|
|
runStreamed(
|
|
SILENCE_PYTHON_BIN,
|
|
[
|
|
SILENCE_DIARIZE_SCRIPT,
|
|
"--video", silenceState.mediaPath,
|
|
"--transcript", silenceState.transcriptPath,
|
|
"--out", silenceState.transcriptPath,
|
|
"--models-path", effectiveModelsPath(),
|
|
],
|
|
function (line) { silenceLog(line); },
|
|
function (code, lastErr) {
|
|
if (btn) btn.disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha na diarização (código " + code + (lastErr ? ": " + lastErr : "") + ").", "err");
|
|
return;
|
|
}
|
|
var parsed = null;
|
|
try { parsed = JSON.parse(fs.readFileSync(silenceState.transcriptPath, "utf-8")).diarization; } catch (e) {}
|
|
var msg = parsed ? parsed.num_speakers + " locutor(es) detectado(s): " + parsed.speakers.join(", ") : "Locutores detectados.";
|
|
silenceLog(msg, "ok");
|
|
if (info) info.textContent = msg;
|
|
syncTranscriptToCache();
|
|
},
|
|
hfSpawnOptions()
|
|
);
|
|
}
|
|
|
|
// Depois de mutar silenceState.transcriptPath em lugar (diarização, métricas de
|
|
// voz), propaga a mudança pra cópia central de cache, se forem arquivos diferentes.
|
|
function syncTranscriptToCache() {
|
|
if (silenceState.cachedTranscriptPath && silenceState.cachedTranscriptPath !== silenceState.transcriptPath) {
|
|
try {
|
|
fs.copyFileSync(silenceState.transcriptPath, silenceState.cachedTranscriptPath);
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
function silenceComputeVoiceFeatures() {
|
|
if (!silenceState.transcriptPath) return;
|
|
if (!silenceState.mediaPath) {
|
|
silenceLog("Detecte o clipe/sequência primeiro (seção 1) para saber qual vídeo analisar.", "err");
|
|
return;
|
|
}
|
|
var btn = document.getElementById("btnVoiceFeatures");
|
|
var info = document.getElementById("silenceVoiceFeaturesInfo");
|
|
if (btn) btn.disabled = true;
|
|
if (info) info.textContent = "";
|
|
silenceLog("Calculando pitch/energia/velocidade de fala/pausas…");
|
|
runStreamed(
|
|
SILENCE_PYTHON_BIN,
|
|
[
|
|
VOICE_FEATURES_SCRIPT,
|
|
"--video", silenceState.mediaPath,
|
|
"--transcript", silenceState.transcriptPath,
|
|
"--out", silenceState.transcriptPath,
|
|
],
|
|
function (line) { silenceLog(line); },
|
|
function (code, lastErr) {
|
|
if (btn) btn.disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha ao calcular métricas de voz (código " + code + (lastErr ? ": " + lastErr : "") + ").", "err");
|
|
return;
|
|
}
|
|
var msg = "Métricas de voz calculadas (pitch, energia, velocidade, pausas) para cada trecho.";
|
|
silenceLog(msg, "ok");
|
|
if (info) info.textContent = msg;
|
|
syncTranscriptToCache();
|
|
}
|
|
);
|
|
}
|
|
|
|
function silenceBuildPlan() {
|
|
if (!silenceState.transcriptPath) return;
|
|
document.getElementById("btnBuildPlan").disabled = true;
|
|
var out = path.join(silenceState.outputDir, "plano-cortes.json");
|
|
silenceLog("Gerando plano de cortes…");
|
|
var lastLine = "";
|
|
runStreamed(
|
|
SILENCE_PYTHON_BIN,
|
|
[SILENCE_CUTTER_SCRIPT, "--transcript", silenceState.transcriptPath, "--out", out, "--min-silence", "0.8", "--margin", "0.3"],
|
|
function (line) { lastLine = line; silenceLog(line); },
|
|
function (code) {
|
|
document.getElementById("btnBuildPlan").disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha ao gerar o plano (código " + code + ").", "err");
|
|
return;
|
|
}
|
|
var parsed = null;
|
|
try { parsed = JSON.parse(lastLine); } catch (e) {}
|
|
if (!parsed || parsed.ok === false) {
|
|
silenceLog("Plano gerado mas resposta é inválida: " + lastLine, "err");
|
|
return;
|
|
}
|
|
silenceState.planPath = out;
|
|
silenceState.plan = parsed;
|
|
var info = document.getElementById("silencePlanInfo");
|
|
info.textContent =
|
|
parsed.num_cuts + " cortes de silêncio · remove " + parsed.total_cut_seconds.toFixed(1) +
|
|
"s · duração final ≈ " + parsed.resulting_duration_seconds.toFixed(1) + "s";
|
|
silenceLog("Plano pronto: " + info.textContent, "ok");
|
|
document.getElementById("btnApplyCuts").disabled = false;
|
|
}
|
|
);
|
|
}
|
|
|
|
// O script de cortes importa ../dist/tools/*.js, então precisa rodar de dentro
|
|
// do repositório — não da cópia instalada em ~/Library/.../CEP/extensions.
|
|
// Ordem: repoRoot gravado pelo install-cep.sh > relativo ao painel (modo symlink) > fallback fixo.
|
|
function resolveScript(scriptFileName, fallbackPath) {
|
|
var candidates = [];
|
|
var repoRoot = String(loadConfig().repoRoot || "").trim();
|
|
if (repoRoot) candidates.push(path.join(repoRoot, "scripts", scriptFileName));
|
|
try {
|
|
var extensionRoot = toFsPath(cs.getSystemPath("extension"));
|
|
candidates.push(path.join(extensionRoot, "..", "scripts", scriptFileName));
|
|
} catch (e) {}
|
|
candidates.push(fallbackPath);
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
try { if (fs.existsSync(candidates[i])) return candidates[i]; } catch (e) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveApplyScript() {
|
|
return resolveScript("apply-silence-cuts.mjs", SILENCE_APPLY_SCRIPT_FALLBACK);
|
|
}
|
|
|
|
function resolveEditorialActionsScript() {
|
|
return resolveScript("apply-editorial-actions.mjs", EDITORIAL_ACTIONS_APPLY_SCRIPT_FALLBACK);
|
|
}
|
|
|
|
function silenceApplyCuts() {
|
|
if (!silenceState.planPath || !silenceState.plan) return;
|
|
var summary =
|
|
silenceState.plan.num_cuts + " cortes, removendo " + silenceState.plan.total_cut_seconds.toFixed(1) +
|
|
"s (duração final ≈ " + silenceState.plan.resulting_duration_seconds.toFixed(1) + "s).\n\n" +
|
|
"Um backup da sequência atual será criado antes de aplicar. Confirmar?";
|
|
if (typeof window.confirm === "function" && !window.confirm(summary)) return;
|
|
|
|
document.getElementById("btnApplyCuts").disabled = true;
|
|
silenceLog("Criando backup da sequência…");
|
|
cs.evalScript(
|
|
"(function(){try{app.project.activeSequence.clone();return 'ok';}catch(e){return 'error:'+String(e);}}())",
|
|
function (raw) {
|
|
if (String(raw).indexOf("ok") !== 0) {
|
|
silenceLog("Não foi possível criar o backup automático (" + raw + "). Aplicando mesmo assim.", "err");
|
|
} else {
|
|
silenceLog("Backup criado.", "ok");
|
|
}
|
|
|
|
var applyScript = resolveApplyScript();
|
|
if (!applyScript) {
|
|
document.getElementById("btnApplyCuts").disabled = false;
|
|
silenceLog(
|
|
"Script de cortes não encontrado. Rode scripts/install-cep.sh para gravar o repoRoot em " +
|
|
CONFIG_FILE + ".",
|
|
"err"
|
|
);
|
|
return;
|
|
}
|
|
var nodeBin = resolveNodeBin();
|
|
|
|
silenceLog("Aplicando cortes na timeline…");
|
|
runStreamed(
|
|
nodeBin,
|
|
[applyScript, "--plan", silenceState.planPath, "--sequence", silenceState.sequenceName],
|
|
function (line) { silenceLog(line); },
|
|
function (code) {
|
|
document.getElementById("btnApplyCuts").disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha ao aplicar os cortes (código " + code + ").", "err");
|
|
return;
|
|
}
|
|
silenceLog("Cortes aplicados com sucesso!", "ok");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
// ---- Importar plano de edição (skill selecao-trechos: cut/zoom/text/marker) ----
|
|
function applyEditorialActions() {
|
|
if (!silenceState.sequenceName) {
|
|
silenceLog("Detecte o clipe/sequência primeiro (seção 1).", "err");
|
|
return;
|
|
}
|
|
var planPath = document.getElementById("editorialPlanPath").value.trim();
|
|
if (!planPath) {
|
|
silenceLog("Informe o caminho do arquivo do plano (JSON de actions).", "err");
|
|
return;
|
|
}
|
|
if (!fs.existsSync(planPath)) {
|
|
silenceLog("Arquivo não encontrado: " + planPath, "err");
|
|
return;
|
|
}
|
|
|
|
var plan;
|
|
try {
|
|
plan = JSON.parse(fs.readFileSync(planPath, "utf-8"));
|
|
} catch (e) {
|
|
silenceLog("Não foi possível ler o JSON do plano: " + e.message, "err");
|
|
return;
|
|
}
|
|
var numActions = Array.isArray(plan.actions) ? plan.actions.length : 0;
|
|
if (!numActions) {
|
|
silenceLog("O plano não tem nenhuma action.", "err");
|
|
return;
|
|
}
|
|
|
|
var summary =
|
|
numActions + " ação(ões) no plano (cortes, zooms, textos e/ou marcadores).\n\n" +
|
|
"Um backup da sequência atual será criado antes de aplicar. Confirmar?";
|
|
if (typeof window.confirm === "function" && !window.confirm(summary)) return;
|
|
|
|
document.getElementById("btnApplyEditorialActions").disabled = true;
|
|
silenceLog("Criando backup da sequência…");
|
|
cs.evalScript(
|
|
"(function(){try{app.project.activeSequence.clone();return 'ok';}catch(e){return 'error:'+String(e);}}())",
|
|
function (raw) {
|
|
if (String(raw).indexOf("ok") !== 0) {
|
|
silenceLog("Não foi possível criar o backup automático (" + raw + "). Aplicando mesmo assim.", "err");
|
|
} else {
|
|
silenceLog("Backup criado.", "ok");
|
|
}
|
|
|
|
var applyScript = resolveEditorialActionsScript();
|
|
if (!applyScript) {
|
|
document.getElementById("btnApplyEditorialActions").disabled = false;
|
|
silenceLog(
|
|
"Script apply-editorial-actions.mjs não encontrado. Rode scripts/install-cep.sh para gravar o repoRoot em " +
|
|
CONFIG_FILE + ".",
|
|
"err"
|
|
);
|
|
return;
|
|
}
|
|
var nodeBin = resolveNodeBin();
|
|
|
|
silenceLog("Aplicando plano de edição na timeline…");
|
|
runStreamed(
|
|
nodeBin,
|
|
[applyScript, "--plan", planPath, "--sequence", silenceState.sequenceName],
|
|
function (line) { silenceLog(line); },
|
|
function (code) {
|
|
document.getElementById("btnApplyEditorialActions").disabled = false;
|
|
if (code !== 0) {
|
|
silenceLog("Falha ao aplicar o plano (código " + code + ").", "err");
|
|
return;
|
|
}
|
|
silenceLog("Plano de edição aplicado com sucesso!", "ok");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
// ---- Settings / secrets storage ----
|
|
var CONFIG_DIR = path.join(os.homedir(), ".premiere-mcp");
|
|
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
|
|
function loadConfig() {
|
|
try {
|
|
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
var parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
return (parsed && typeof parsed === "object") ? parsed : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveConfig(patch) {
|
|
try {
|
|
ensureDir(CONFIG_DIR);
|
|
var next = Object.assign({}, loadConfig(), patch);
|
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch (e) {}
|
|
return true;
|
|
} catch (e) {
|
|
log("Error saving config: " + e.message, "err");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---- Configuração do Editor: personalidades de edição ----
|
|
// Fase 1: nome + texto livre descrevendo o comportamento esperado. A
|
|
// personalidade marcada como ativa vai embutida no JSON de transcrição
|
|
// (settings.editor_personality), pra eu já saber o estilo esperado quando
|
|
// você me trouxer o arquivo. Fases futuras podem trocar/complementar o texto
|
|
// livre por parâmetros estruturados obrigatórios.
|
|
function loadEditorPersonalities() {
|
|
var list = loadConfig().editorPersonalities;
|
|
return Array.isArray(list) ? list : [];
|
|
}
|
|
|
|
function loadActiveEditorPersonalityId() {
|
|
return String(loadConfig().activeEditorPersonalityId || "").trim();
|
|
}
|
|
|
|
function getActiveEditorPersonality() {
|
|
var id = loadActiveEditorPersonalityId();
|
|
if (!id) return null;
|
|
var list = loadEditorPersonalities();
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i].id === id) return list[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setActiveEditorPersonality(id) {
|
|
saveConfig({ activeEditorPersonalityId: id });
|
|
renderEditorPersonalities();
|
|
}
|
|
|
|
// Embute a personalidade de edição ativa (nome + texto livre) como
|
|
// `settings.editor_personality` dentro do JSON de transcrição recém-gerado,
|
|
// pra ir junto quando o arquivo for trazido pra análise.
|
|
function applyEditorPersonalityToTranscript(transcriptPath) {
|
|
var personality = getActiveEditorPersonality();
|
|
if (!personality) return;
|
|
try {
|
|
var transcript = JSON.parse(fs.readFileSync(transcriptPath, "utf-8"));
|
|
transcript.settings = Object.assign({}, transcript.settings, {
|
|
editor_personality: { name: personality.name, text: personality.text },
|
|
});
|
|
fs.writeFileSync(transcriptPath, JSON.stringify(transcript, null, 2), "utf-8");
|
|
silenceLog('Personalidade de edição embutida no JSON: "' + personality.name + '".', "ok");
|
|
} catch (e) {
|
|
silenceLog("Não foi possível embutir a personalidade de edição: " + e.message, "err");
|
|
}
|
|
}
|
|
|
|
var editingPersonalityId = null;
|
|
|
|
function startNewEditorPersonality() {
|
|
editingPersonalityId = null;
|
|
document.getElementById("editorPersonalityName").value = "";
|
|
document.getElementById("editorPersonalityText").value = "";
|
|
document.getElementById("editorPersonalityForm").hidden = false;
|
|
}
|
|
|
|
function editEditorPersonality(id) {
|
|
var list = loadEditorPersonalities();
|
|
var entry = list.filter(function (p) { return p.id === id; })[0];
|
|
if (!entry) return;
|
|
editingPersonalityId = id;
|
|
document.getElementById("editorPersonalityName").value = entry.name;
|
|
document.getElementById("editorPersonalityText").value = entry.text;
|
|
document.getElementById("editorPersonalityForm").hidden = false;
|
|
}
|
|
|
|
function cancelEditorPersonalityForm() {
|
|
editingPersonalityId = null;
|
|
document.getElementById("editorPersonalityForm").hidden = true;
|
|
}
|
|
|
|
function saveEditorPersonalityForm() {
|
|
var name = document.getElementById("editorPersonalityName").value.trim();
|
|
var text = document.getElementById("editorPersonalityText").value.trim();
|
|
if (!name || !text) {
|
|
modelsLog("Preencha nome e descrição da personalidade.", "err");
|
|
return;
|
|
}
|
|
var list = loadEditorPersonalities();
|
|
if (editingPersonalityId) {
|
|
list = list.map(function (p) {
|
|
return p.id === editingPersonalityId ? { id: p.id, name: name, text: text } : p;
|
|
});
|
|
} else {
|
|
var id = "personality-" + Date.now().toString(36);
|
|
list.push({ id: id, name: name, text: text });
|
|
if (!loadActiveEditorPersonalityId()) saveConfig({ activeEditorPersonalityId: id });
|
|
}
|
|
saveConfig({ editorPersonalities: list });
|
|
cancelEditorPersonalityForm();
|
|
renderEditorPersonalities();
|
|
modelsLog("Personalidade de edição salva: " + name, "ok");
|
|
}
|
|
|
|
function deleteEditorPersonality(id) {
|
|
var list = loadEditorPersonalities();
|
|
var entry = list.filter(function (p) { return p.id === id; })[0];
|
|
if (!entry) return;
|
|
if (typeof window.confirm === "function" && !window.confirm('Excluir a personalidade "' + entry.name + '"?')) return;
|
|
list = list.filter(function (p) { return p.id !== id; });
|
|
var patch = { editorPersonalities: list };
|
|
if (loadActiveEditorPersonalityId() === id) patch.activeEditorPersonalityId = "";
|
|
saveConfig(patch);
|
|
renderEditorPersonalities();
|
|
}
|
|
|
|
function renderEditorPersonalities() {
|
|
var container = document.getElementById("editorPersonalityList");
|
|
if (!container) return;
|
|
container.innerHTML = "";
|
|
var activeId = loadActiveEditorPersonalityId();
|
|
var list = loadEditorPersonalities();
|
|
|
|
if (!list.length) {
|
|
var empty = document.createElement("p");
|
|
empty.className = "field-help";
|
|
empty.textContent = "Nenhuma personalidade cadastrada ainda.";
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
list.forEach(function (entry) {
|
|
var isActive = entry.id === activeId;
|
|
var row = document.createElement("div");
|
|
row.className = "model-row";
|
|
|
|
var main = document.createElement("div");
|
|
main.className = "model-main";
|
|
|
|
var name = document.createElement("span");
|
|
name.className = "model-name";
|
|
name.textContent = entry.name;
|
|
main.appendChild(name);
|
|
|
|
var meta = document.createElement("div");
|
|
meta.className = "model-meta";
|
|
var snippet = document.createElement("span");
|
|
snippet.className = "model-badge";
|
|
var text = entry.text.length > 60 ? entry.text.slice(0, 60) + "…" : entry.text;
|
|
snippet.textContent = text;
|
|
meta.appendChild(snippet);
|
|
if (isActive) {
|
|
var badge = document.createElement("span");
|
|
badge.className = "model-badge model-badge-star";
|
|
badge.textContent = "★ Ativa";
|
|
meta.appendChild(badge);
|
|
}
|
|
main.appendChild(meta);
|
|
row.appendChild(main);
|
|
|
|
var actions = document.createElement("div");
|
|
actions.className = "model-actions";
|
|
|
|
var activeBtn = document.createElement("button");
|
|
activeBtn.type = "button";
|
|
if (isActive) {
|
|
activeBtn.className = "button button-primary model-btn-active";
|
|
activeBtn.textContent = "Ativa";
|
|
activeBtn.disabled = true;
|
|
} else {
|
|
activeBtn.className = "button";
|
|
activeBtn.textContent = "Usar esta";
|
|
activeBtn.onclick = function () { setActiveEditorPersonality(entry.id); };
|
|
}
|
|
actions.appendChild(activeBtn);
|
|
|
|
var editBtn = document.createElement("button");
|
|
editBtn.type = "button";
|
|
editBtn.className = "save-link";
|
|
editBtn.textContent = "Editar";
|
|
editBtn.onclick = function () { editEditorPersonality(entry.id); };
|
|
actions.appendChild(editBtn);
|
|
|
|
var deleteBtn = document.createElement("button");
|
|
deleteBtn.type = "button";
|
|
deleteBtn.className = "save-link";
|
|
deleteBtn.textContent = "Excluir";
|
|
deleteBtn.onclick = function () { deleteEditorPersonality(entry.id); };
|
|
actions.appendChild(deleteBtn);
|
|
|
|
row.appendChild(actions);
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function loadHfToken() {
|
|
return String(loadConfig().hfToken || "").trim();
|
|
}
|
|
|
|
function hfSpawnOptions() {
|
|
var token = loadHfToken();
|
|
if (!token) return undefined;
|
|
var nodeProcess = nodeRequire("process");
|
|
return { env: Object.assign({}, nodeProcess.env, { HF_TOKEN: token }) };
|
|
}
|
|
|
|
function loadModelsPath() {
|
|
return String(loadConfig().modelsPath || "").trim();
|
|
}
|
|
|
|
function effectiveModelsPath() {
|
|
return loadModelsPath() || DEFAULT_MODELS_PATH;
|
|
}
|
|
|
|
function saveModelsPath() {
|
|
var value = document.getElementById("modelsPath").value.trim();
|
|
if (!saveConfig({ modelsPath: value })) return;
|
|
modelsLog("Pasta de modelos salva: " + effectiveModelsPath());
|
|
renderModelList();
|
|
}
|
|
|
|
function loadLanguage() {
|
|
return String(loadConfig().language || "").trim() || DEFAULT_LANGUAGE;
|
|
}
|
|
|
|
function saveLanguage() {
|
|
var value = document.getElementById("transcribeLanguage").value;
|
|
saveConfig({ language: value });
|
|
modelsLog("Idioma de transcrição salvo: " + value);
|
|
}
|
|
|
|
function loadDefaultModel() {
|
|
return String(loadConfig().defaultModel || "").trim() || DEFAULT_MODEL;
|
|
}
|
|
|
|
function setDefaultModel(model) {
|
|
saveConfig({ defaultModel: model });
|
|
modelsLog("Modelo padrão definido: " + model, "ok");
|
|
renderModelList();
|
|
syncTranscribeModelSelect();
|
|
}
|
|
|
|
// Mantém o seletor de modelo da aba de Transcrição em sincronia com o catálogo
|
|
// e com o modelo padrão escolhido na aba Modelos Locais.
|
|
function syncTranscribeModelSelect() {
|
|
var select = document.getElementById("silenceModel");
|
|
if (!select) return;
|
|
var current = select.value;
|
|
select.innerHTML = "";
|
|
MODEL_CATALOG.forEach(function (m) {
|
|
var opt = document.createElement("option");
|
|
opt.value = m.value;
|
|
opt.textContent = m.label;
|
|
select.appendChild(opt);
|
|
});
|
|
var wanted = MODEL_CATALOG.some(function (m) { return m.value === current; }) ? current : loadDefaultModel();
|
|
select.value = wanted;
|
|
}
|
|
|
|
// ---- Settings tab: HF token ----
|
|
function saveHfToken() {
|
|
var token = document.getElementById("hfToken").value.trim();
|
|
if (!saveConfig({ hfToken: token })) return;
|
|
log("Hugging Face token salvo");
|
|
setHfTokenStatus(token ? "unknown" : "unset", token ? "Salvo — clique em Validar" : "Não configurado");
|
|
}
|
|
|
|
function toggleHfTokenVisibility() {
|
|
var input = document.getElementById("hfToken");
|
|
input.type = input.type === "password" ? "text" : "password";
|
|
}
|
|
|
|
function setHfTokenStatus(state, text) {
|
|
var el = document.getElementById("hfTokenStatus");
|
|
if (!el) return;
|
|
el.setAttribute("data-state", state);
|
|
document.getElementById("hfTokenStatusText").textContent = text;
|
|
}
|
|
|
|
function validateHfToken() {
|
|
var token = document.getElementById("hfToken").value.trim() || loadHfToken();
|
|
if (!token) {
|
|
setHfTokenStatus("unset", "Não configurado");
|
|
return;
|
|
}
|
|
setHfTokenStatus("checking", "Validando…");
|
|
var req = https.get(
|
|
"https://huggingface.co/api/whoami-v2",
|
|
{ headers: { Authorization: "Bearer " + token, "User-Agent": "premiere-pro-mcp-connector" } },
|
|
function (response) {
|
|
var body = "";
|
|
response.setEncoding("utf8");
|
|
response.on("data", function (chunk) { body += chunk; });
|
|
response.on("end", function () {
|
|
if (response.statusCode === 200) {
|
|
var name = "";
|
|
try { name = JSON.parse(body).name || ""; } catch (e) {}
|
|
setHfTokenStatus("valid", "Token válido" + (name ? " (" + name + ")" : ""));
|
|
} else {
|
|
setHfTokenStatus("invalid", "Token inválido (HTTP " + response.statusCode + ")");
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on("error", function (err) {
|
|
setHfTokenStatus("invalid", "Erro ao validar: " + err.message);
|
|
});
|
|
req.setTimeout(8000, function () {
|
|
req.destroy();
|
|
setHfTokenStatus("invalid", "Tempo esgotado ao validar");
|
|
});
|
|
}
|
|
|
|
// ---- Models tab ----
|
|
// Localiza a pasta de cache HF (formato "models--Systran--faster-whisper-<model>")
|
|
// que contém um model.bin de fato baixado para o modelo pedido.
|
|
function findModelFolder(model) {
|
|
try {
|
|
var modelsPath = effectiveModelsPath();
|
|
if (!fs.existsSync(modelsPath)) return null;
|
|
var entries = fs.readdirSync(modelsPath);
|
|
for (var i = 0; i < entries.length; i++) {
|
|
// ex: "models--Systran--faster-whisper-small" for model "small"
|
|
if (entries[i].indexOf("models--") !== 0) continue;
|
|
if (entries[i].toLowerCase().indexOf(("faster-whisper-" + model).toLowerCase()) === -1) continue;
|
|
var fullDir = path.join(modelsPath, entries[i]);
|
|
var snapshotsDir = path.join(fullDir, "snapshots");
|
|
if (!fs.existsSync(snapshotsDir)) continue;
|
|
var snaps = fs.readdirSync(snapshotsDir);
|
|
for (var j = 0; j < snaps.length; j++) {
|
|
if (fs.existsSync(path.join(snapshotsDir, snaps[j], "model.bin"))) return fullDir;
|
|
}
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isModelDownloaded(model) {
|
|
return !!findModelFolder(model);
|
|
}
|
|
|
|
function deleteModel(model) {
|
|
var folder = findModelFolder(model);
|
|
if (!folder) return;
|
|
var confirmMsg = "Excluir o modelo \"" + model + "\" baixado (" + folder + ")? Isso libera espaço em disco; será baixado de novo se você precisar dele depois.";
|
|
if (typeof window.confirm === "function" && !window.confirm(confirmMsg)) return;
|
|
try {
|
|
fs.rmSync(folder, { recursive: true, force: true });
|
|
modelsLog("Modelo " + model + " excluído.", "ok");
|
|
} catch (e) {
|
|
modelsLog("Falha ao excluir " + model + ": " + e.message, "err");
|
|
}
|
|
renderModelList();
|
|
}
|
|
|
|
function modelsLog(msg, cls) {
|
|
var el = document.getElementById("modelsLog");
|
|
if (!el) return;
|
|
var entry = document.createElement("div");
|
|
entry.className = "log-entry " + (cls || "");
|
|
var ts = new Date().toLocaleTimeString();
|
|
entry.textContent = "[" + ts + "] " + msg;
|
|
el.appendChild(entry);
|
|
el.scrollTop = el.scrollHeight;
|
|
while (el.children.length > 100) el.removeChild(el.firstChild);
|
|
}
|
|
|
|
function renderLanguageOptions() {
|
|
var select = document.getElementById("transcribeLanguage");
|
|
if (!select) return;
|
|
select.innerHTML = "";
|
|
LANGUAGE_CATALOG.forEach(function (lang) {
|
|
var opt = document.createElement("option");
|
|
opt.value = lang.value;
|
|
opt.textContent = lang.label;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function renderModelList() {
|
|
var list = document.getElementById("modelList");
|
|
if (!list) return;
|
|
list.innerHTML = "";
|
|
var defaultModel = loadDefaultModel();
|
|
MODEL_CATALOG.forEach(function (entry) {
|
|
var model = entry.value;
|
|
var folder = findModelFolder(model);
|
|
var downloaded = !!folder;
|
|
var isDefault = model === defaultModel;
|
|
|
|
var row = document.createElement("div");
|
|
row.className = "model-row";
|
|
|
|
var main = document.createElement("div");
|
|
main.className = "model-main";
|
|
|
|
var name = document.createElement("span");
|
|
name.className = "model-name";
|
|
name.textContent = entry.label;
|
|
main.appendChild(name);
|
|
|
|
var meta = document.createElement("div");
|
|
meta.className = "model-meta";
|
|
|
|
var sizeBadge = document.createElement("span");
|
|
sizeBadge.className = "model-badge model-badge-size";
|
|
sizeBadge.textContent = "≈ " + formatMB(downloaded ? folderSizeMB(folder) : entry.approxMB) + (downloaded ? "" : " (aprox.)");
|
|
meta.appendChild(sizeBadge);
|
|
|
|
var engineBadge = document.createElement("span");
|
|
engineBadge.className = "model-badge";
|
|
engineBadge.textContent = "faster-whisper · multilíngue";
|
|
meta.appendChild(engineBadge);
|
|
|
|
if (isDefault) {
|
|
var defaultBadge = document.createElement("span");
|
|
defaultBadge.className = "model-badge model-badge-star";
|
|
defaultBadge.textContent = "★ Padrão";
|
|
meta.appendChild(defaultBadge);
|
|
}
|
|
|
|
main.appendChild(meta);
|
|
row.appendChild(main);
|
|
|
|
var actions = document.createElement("div");
|
|
actions.className = "model-actions";
|
|
|
|
var primaryBtn = document.createElement("button");
|
|
primaryBtn.type = "button";
|
|
if (isDefault) {
|
|
primaryBtn.className = "button button-primary model-btn-active";
|
|
primaryBtn.textContent = "Ativo";
|
|
primaryBtn.disabled = true;
|
|
} else if (downloaded) {
|
|
primaryBtn.className = "button";
|
|
primaryBtn.textContent = "Usar como padrão";
|
|
primaryBtn.onclick = function () { setDefaultModel(model); };
|
|
} else {
|
|
primaryBtn.className = "button button-primary";
|
|
primaryBtn.textContent = "Baixar";
|
|
primaryBtn.onclick = function () { downloadModel(model, primaryBtn); };
|
|
}
|
|
actions.appendChild(primaryBtn);
|
|
|
|
if (downloaded && !isDefault) {
|
|
var deleteBtn = document.createElement("button");
|
|
deleteBtn.type = "button";
|
|
deleteBtn.className = "save-link";
|
|
deleteBtn.textContent = "Excluir";
|
|
deleteBtn.onclick = function () { deleteModel(model); };
|
|
actions.appendChild(deleteBtn);
|
|
}
|
|
|
|
row.appendChild(actions);
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function downloadModel(model, btn) {
|
|
btn.disabled = true;
|
|
modelsLog("Baixando modelo " + model + "… isso pode levar alguns minutos.");
|
|
runStreamed(
|
|
SILENCE_PYTHON_BIN,
|
|
[SILENCE_DOWNLOAD_SCRIPT, "--model", model, "--models-path", effectiveModelsPath()],
|
|
function (line) { modelsLog(line); },
|
|
function (code) {
|
|
if (code !== 0) {
|
|
modelsLog("Falha ao baixar " + model + " (código " + code + ").", "err");
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
modelsLog("Modelo " + model + " baixado com sucesso.", "ok");
|
|
renderModelList();
|
|
},
|
|
hfSpawnOptions()
|
|
);
|
|
}
|
|
|
|
// ---- Init ----
|
|
(function init() {
|
|
// Set the default temp dir in the input field
|
|
document.getElementById("tempDir").value = tempDir;
|
|
|
|
// Restore saved temp dir
|
|
try {
|
|
var saved = localStorage.getItem("mcp_bridge_temp_dir");
|
|
if (saved) {
|
|
tempDir = saved;
|
|
document.getElementById("tempDir").value = tempDir;
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Restore saved HF token (masked) and show its status
|
|
var savedToken = loadHfToken();
|
|
if (savedToken) {
|
|
document.getElementById("hfToken").value = savedToken;
|
|
setHfTokenStatus("unknown", "Salvo — clique em Validar para confirmar");
|
|
} else {
|
|
setHfTokenStatus("unset", "Não configurado");
|
|
}
|
|
document.getElementById("modelsPath").value = effectiveModelsPath();
|
|
renderLanguageOptions();
|
|
document.getElementById("transcribeLanguage").value = loadLanguage();
|
|
renderModelList();
|
|
syncTranscribeModelSelect();
|
|
renderEditorPersonalities();
|
|
|
|
log("MCP for Adobe Premiere Pro CEP connector loaded");
|
|
setStatus("waiting", "Ready — click Start Bridge");
|
|
|
|
// Always auto-start. The headless instance (StartOn ApplicationActivate) has no
|
|
// one to click Start, and macOS periodically purges the temp dir — so create it
|
|
// rather than gating auto-start on its existence.
|
|
ensureDir(tempDir);
|
|
startBridgeHeartbeat();
|
|
log("Auto-starting bridge...");
|
|
setTimeout(startBridge, 500);
|
|
if (!restoreScheduledUpdateStatus()) setTimeout(checkForUpdates, 1200);
|
|
})();
|