app
Premiere Pro Bridge
Public Made by Adomby adom
Let the AI edit video in Adobe Premiere Pro on your desktop (import, cut, trim, razor, effects, audio, previews, export) with no mouse or keyboard. Check adom-bridge bridge_list for 'premiere'; install with bridge_install + adom-wiki pkg install adom/premiere-bridge, then start with premiere_status.
main
anastasia
Premiere Pro Bridge 0.1.0: Rust bridge, UXP plugin, CEP helper, skills, macOS manifest
32fb583
10d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
//! Adom Bridge bridge for Adobe Premiere Pro.
//!
//! Two halves:
//! * this Rust server — Adom Bridge spawns it, POSTs `{command, args}` verbs at it,
//! and polls `/health` for the status chip;
//! * a UXP plugin running INSIDE Premiere (see `plugin/`) — it dials back into this
//! server over a loopback WebSocket (`/ws`) and executes verbs against the
//! `premierepro` scripting API. No mouse, no keyboard, no window automation.
//!
//! A verb arrives here, gets forwarded to the plugin as `{type:"call", id, verb, args}`,
//! and the plugin's `{type:"result", id, ok, result|error}` is wrapped with the
//! `_hint` / `_next` / `related` / `pitfalls` the calling AI actually reads.
mod ab;
mod catalog;
mod host;
mod plugin_pkg;
use axum::{
body::Bytes,
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Query, State,
},
http::{header, HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
routing::{any, get},
Json, Router,
};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::{
collections::HashMap,
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tokio::sync::{mpsc, oneshot, Mutex};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PREFIX: &str = "premiere_";
/// Protocol version spoken between this server and the UXP plugin. Bump on any
/// breaking change to the call/result envelope; the plugin reports its own in `hello`.
pub const PROTOCOL: u64 = 1;
pub struct PluginConn {
runtime: String,
tx: mpsc::UnboundedSender<String>,
hello: Value,
connected_at: Instant,
conn_id: u64,
}
pub struct AppState {
pub port: u16,
pub token: String,
pub data_dir: PathBuf,
pub started: Instant,
/// Live in-app connections keyed by runtime: "uxp" (primary) and "cep" (helper).
plugins: Mutex<HashMap<String, PluginConn>>,
/// In-flight calls: id -> (connection that owns it, waiter).
pending: Mutex<HashMap<u64, (u64, oneshot::Sender<Value>)>>,
next_id: AtomicU64,
/// Plugin-pushed events (selection changed, export finished...), newest last.
events: Mutex<Vec<Value>>,
event_seq: AtomicU64,
}
pub type Shared = Arc<AppState>;
impl AppState {
pub async fn runtime_connected(&self, runtime: &str) -> bool {
self.plugins.lock().await.contains_key(runtime)
}
/// The primary (UXP) plugin; "connected" in user-facing terms means this one.
pub async fn plugin_connected(&self) -> bool {
self.runtime_connected("uxp").await
}
pub async fn plugin_hello(&self) -> Option<(Value, u64)> {
self.runtime_hello("uxp").await
}
pub async fn runtime_hello(&self, runtime: &str) -> Option<(Value, u64)> {
self.plugins
.lock()
.await
.get(runtime)
.map(|p| (p.hello.clone(), p.connected_at.elapsed().as_secs()))
}
/// Send one verb to an in-app runtime ("uxp" or "cep") and wait for its result.
pub async fn call_plugin(&self, verb: &str, args: Value, timeout: Duration) -> Result<Value, CallError> {
self.call_runtime("uxp", verb, args, timeout).await
}
pub async fn call_runtime(&self, runtime: &str, verb: &str, args: Value, timeout: Duration) -> Result<Value, CallError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
{
let guard = self.plugins.lock().await;
let Some(conn) = guard.get(runtime) else {
return Err(CallError::NotConnected);
};
self.pending.lock().await.insert(id, (conn.conn_id, tx));
let msg = json!({"type": "call", "id": id, "verb": verb, "args": args}).to_string();
if conn.tx.send(msg).is_err() {
self.pending.lock().await.remove(&id);
return Err(CallError::NotConnected);
}
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(_)) => Err(CallError::Disconnected),
Err(_) => {
self.pending.lock().await.remove(&id);
Err(CallError::Timeout(timeout.as_secs()))
}
}
}
pub async fn recent_events(&self, since: u64) -> (Vec<Value>, u64) {
let ev = self.events.lock().await;
let out = ev
.iter()
.filter(|e| e["seq"].as_u64().unwrap_or(0) > since)
.cloned()
.collect();
(out, self.event_seq.load(Ordering::Relaxed))
}
}
#[derive(Debug)]
pub enum CallError {
NotConnected,
Disconnected,
Timeout(u64),
}
// ───────────────────────────── entry ─────────────────────────────
#[tokio::main]
async fn main() {
let mut port: u16 = 0;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--port" {
port = args.next().and_then(|p| p.parse().ok()).unwrap_or(0);
} else if let Some(p) = a.strip_prefix("--port=") {
port = p.parse().unwrap_or(0);
} else if a == "--version" {
println!("premiere-bridge {VERSION}");
return;
}
}
let bind_host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());
let listener = tokio::net::TcpListener::bind((bind_host.as_str(), port))
.await
.unwrap_or_else(|e| panic!("bind {bind_host}:{port}: {e}"));
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
let data_dir = host::data_dir();
let _ = std::fs::create_dir_all(&data_dir);
let token = host::load_or_create_token(&data_dir);
let state: Shared = Arc::new(AppState {
port,
token,
data_dir,
started: Instant::now(),
plugins: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
events: Mutex::new(Vec::new()),
event_seq: AtomicU64::new(0),
});
// The plugin finds us by reading this file (port changes are picked up on its next
// reconnect attempt), so write it before accepting anything.
host::write_connection_file(&state);
let app = Router::new()
.route("/health", get(health))
.route("/ws", get(ws_upgrade))
.route("/", any(command))
.route("/command", any(command))
.with_state(state.clone());
eprintln!("[premiere-bridge {VERSION}] listening on {bind_host}:{port} data={}", state.data_dir.display());
axum::serve(listener, app).await.expect("server");
}
// ───────────────────────────── /health ─────────────────────────────
async fn health(State(st): State<Shared>) -> Json<Value> {
let running = host::premiere_running().await;
let hello = st.plugin_hello().await;
let cep = st.runtime_connected("cep").await;
let (led, summary, tooltip) = match (&hello, running) {
(Some((h, _)), _) => (
"green",
"Connected".to_string(),
format!(
"Premiere Pro {} connected via UXP plugin {}. Project: {}",
h["premiereVersion"].as_str().unwrap_or("?"),
h["pluginVersion"].as_str().unwrap_or("?"),
h["project"].as_str().unwrap_or("(none open)")
),
),
(None, true) if cep => (
"yellow",
"Helper only".to_string(),
"Premiere is running and the Adom CEP helper is connected, but the main UXP plugin isn't. Open Window > UXP Plugins > Adom Bridge once (it then auto-restores).".to_string(),
),
(None, true) => (
"yellow",
"Plugin not connected".to_string(),
"Premiere is running but the Adom UXP plugin hasn't dialed in. Run premiere_install_plugin, then open Window > UXP Plugins > Adom Bridge once.".to_string(),
),
(None, false) => (
"yellow",
"Premiere not running".to_string(),
"Bridge is up. Premiere Pro isn't running — premiere_launch starts it.".to_string(),
),
};
Json(json!({
"ok": true,
"name": "premiere",
"version": VERSION,
"led": led,
"summary": summary,
"tooltip": tooltip,
"pluginConnected": hello.is_some(),
"cepHelperConnected": cep,
"premiereRunning": running,
"uptimeSec": st.started.elapsed().as_secs(),
}))
}
// ───────────────────────────── /command ─────────────────────────────
async fn command(State(st): State<Shared>, method: Method, headers: HeaderMap, body: Bytes) -> Response {
// Loopback is not an auth boundary: a web page can POST here. Refuse anything that
// isn't a JSON POST *before* touching the body, and never send ACAO.
if method != Method::POST {
return (StatusCode::METHOD_NOT_ALLOWED, [(header::ALLOW, "POST")], "POST only").into_response();
}
let is_json = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.split(';').next().unwrap_or("").trim().eq_ignore_ascii_case("application/json"))
.unwrap_or(false);
if !is_json {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Expected request with `Content-Type: application/json`",
)
.into_response();
}
let req: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
return Json(json!({"success": false, "error": format!("bad JSON: {e}"), "errorCode": "bad_request"}))
.into_response()
}
};
let verb = req["command"].as_str().unwrap_or("").to_string();
let args = match &req["args"] {
Value::Null => json!({}),
Value::String(s) => serde_json::from_str(s).unwrap_or_else(|_| json!({})),
v => v.clone(),
};
Json(dispatch(&st, &verb, args).await).into_response()
}
/// Verb names are accepted with or without the `premiere_` prefix. Boxed because local
/// composite verbs (preview_frame, launch) dispatch other verbs.
pub fn dispatch<'a>(st: &'a Shared, verb: &'a str, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Value> + Send + 'a>> {
Box::pin(async move {
let bare = verb.strip_prefix(PREFIX).unwrap_or(verb);
let mut out = match host::local_verb(st, bare, &args).await {
Some(v) => v,
None => forward(st, bare, args).await,
};
catalog::decorate(bare, &mut out);
out
})
}
async fn forward(st: &Shared, bare: &str, args: Value) -> Value {
let Some(spec) = catalog::find(bare) else {
return json!({
"success": false,
"errorCode": "unknown_verb",
"error": format!("unknown verb premiere_{bare}"),
"_hint": "Call premiere_describe for the full verb catalog. Anything the catalog doesn't cover can be done with premiere_eval (raw JS against the premierepro UXP API).",
});
};
let timeout = Duration::from_secs(
args["timeoutSec"].as_u64().unwrap_or(spec.timeout_sec).clamp(1, 3600),
);
let args = host::fill_defaults(st, bare, args);
let runtime = match spec.runtime {
catalog::Runtime::Uxp | catalog::Runtime::Local => "uxp",
catalog::Runtime::Cep => "cep",
// True QE razor beats the UXP emulation when the helper is there.
catalog::Runtime::PreferCep => {
if st.runtime_connected("cep").await { "cep" } else { "uxp" }
}
};
match st.call_runtime(runtime, bare, args, timeout).await {
Ok(res) => {
if res["ok"].as_bool().unwrap_or(false) {
let mut out = json!({"success": true});
merge_result(&mut out, res.get("result").cloned().unwrap_or(Value::Null));
if let Some(w) = res.get("warnings") {
out["warnings"] = w.clone();
}
out
} else {
json!({
"success": false,
"error": res["error"].as_str().unwrap_or("plugin error"),
"errorCode": res["errorCode"].as_str().unwrap_or("premiere_error"),
"stack": res.get("stack").cloned().unwrap_or(Value::Null),
})
}
}
Err(CallError::NotConnected) if runtime == "cep" => host::cep_not_connected_response(st).await,
Err(CallError::NotConnected) => host::not_connected_response(st).await,
Err(CallError::Disconnected) => json!({
"success": false,
"errorCode": "plugin_disconnected",
"error": "Premiere's UXP plugin disconnected mid-call (Premiere quit, crashed, or the plugin reloaded).",
"_hint": "Check premiere_status. If Premiere is still up, the plugin reconnects within ~3s — retry once. If the verb mutated the timeline, call premiere_get_sequence before retrying so you don't apply it twice.",
}),
Err(CallError::Timeout(s)) => json!({
"success": false,
"errorCode": "plugin_timeout",
"stillRunning": true,
"statusVerb": "premiere_status",
"error": format!("Premiere didn't answer within {s}s. A modal dialog in Premiere blocks scripting, or the operation is genuinely long."),
"_hint": "Call premiere_status (shows whether Premiere is responsive). For long exports use premiere_export_sequence (it returns immediately and you poll premiere_export_status). You can pass a bigger timeoutSec.",
}),
}
}
/// Plugin results that are objects get flattened into the response; anything else
/// lands under `result`.
fn merge_result(out: &mut Value, result: Value) {
match result {
Value::Object(map) => {
for (k, v) in map {
if k != "success" {
out[k] = v;
}
}
}
Value::Null => {}
other => out["result"] = other,
}
}
// ───────────────────────────── /ws (plugin) ─────────────────────────────
async fn ws_upgrade(
State(st): State<Shared>,
Query(q): Query<HashMap<String, String>>,
ws: WebSocketUpgrade,
) -> Response {
// WebSockets are NOT covered by CORS, so any web page could open one. The token
// (only readable from the user's ~/.adom dir) is what keeps strangers out.
if q.get("token").map(String::as_str) != Some(st.token.as_str()) {
return (StatusCode::UNAUTHORIZED, "bad token").into_response();
}
ws.max_message_size(256 * 1024 * 1024)
.on_upgrade(move |sock| plugin_session(st, sock))
}
static CONN_SEQ: AtomicU64 = AtomicU64::new(1);
async fn plugin_session(st: Shared, sock: WebSocket) {
let (mut sink, mut stream) = sock.split();
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let conn_id = CONN_SEQ.fetch_add(1, Ordering::Relaxed);
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if sink.send(Message::Text(msg)).await.is_err() {
break;
}
}
});
// Keepalive: UXP sockets die silently when Premiere sleeps; a ping every 15s
// surfaces that as a disconnect instead of a hung call.
let ka_tx = tx.clone();
let keepalive = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(15)).await;
if ka_tx.send(json!({"type": "ping"}).to_string()).is_err() {
break;
}
}
});
while let Some(Ok(msg)) = stream.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Binary(b) => String::from_utf8_lossy(&b).into_owned(),
Message::Close(_) => break,
_ => continue,
};
let Ok(v) = serde_json::from_str::<Value>(&text) else { continue };
match v["type"].as_str() {
Some("hello") => {
let runtime = if v["runtime"].as_str() == Some("cep") { "cep" } else { "uxp" }.to_string();
eprintln!("[premiere-bridge] {runtime} connected: {v}");
// A newer connection replaces an older one of the same runtime (plugin
// reloaded, a second panel instance).
st.plugins.lock().await.insert(
runtime.clone(),
PluginConn { runtime, tx: tx.clone(), hello: v.clone(), connected_at: Instant::now(), conn_id },
);
let _ = tx.send(json!({"type": "welcome", "bridgeVersion": VERSION, "protocol": PROTOCOL}).to_string());
}
Some("result") => {
if let Some(id) = v["id"].as_u64() {
if let Some((_, waiter)) = st.pending.lock().await.remove(&id) {
let _ = waiter.send(v);
}
}
}
Some("event") => {
let seq = st.event_seq.fetch_add(1, Ordering::Relaxed) + 1;
let mut ev = v.clone();
ev["seq"] = json!(seq);
let mut events = st.events.lock().await;
events.push(ev);
let len = events.len();
if len > 200 {
events.drain(..len - 200);
}
}
Some("hello_update") => {
if let Some(p) = st.plugins.lock().await.values_mut().find(|p| p.conn_id == conn_id) {
{
if let (Some(dst), Some(src)) = (p.hello.as_object_mut(), v["fields"].as_object()) {
for (k, val) in src {
dst.insert(k.clone(), val.clone());
}
}
}
}
}
_ => {}
}
}
keepalive.abort();
writer.abort();
let mut guard = st.plugins.lock().await;
let gone: Vec<String> = guard.values().filter(|p| p.conn_id == conn_id).map(|p| p.runtime.clone()).collect();
for rt in &gone {
guard.remove(rt);
eprintln!("[premiere-bridge] {rt} disconnected");
}
drop(guard);
// Dropping this connection's waiters wakes its in-flight calls with Disconnected
// instead of letting them sit until their timeout.
if !gone.is_empty() {
st.pending.lock().await.retain(|_, (owner, _)| *owner != conn_id);
}
}