main
anastasia Premiere Pro Bridge 0.1.0: Rust bridge, UXP plugin, CEP helper, skills, macOS manifest 32fb583 10d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700
// Adom Bridge — UXP plugin for Adobe Premiere Pro.
//
// Dials OUT to the Rust bridge's loopback WebSocket (UXP can't host a server),
// receives {type:"call", id, verb, args}, runs the verb against the `premierepro`
// API and answers {type:"result", id, ok, result|error}.
//
// Rules this file lives by:
//   * Since Premiere 26.3 every create*Action must be built INSIDE project.lockedAccess,
//     and lockedAccess callbacks are synchronous — so do every async read first, then
//     build actions in one synchronous pass (see tx()).
//   * Anything we hand back must be JSON-safe: times go out as {seconds, ticks, timecode}.

const ppro = require("premierepro");
const fs = require("fs");
const os = require("os");

const PLUGIN_VERSION = "0.1.0";
const PROTOCOL = 1;
const C = ppro.Constants;
const TICKS_PER_SECOND = 254016000000;

// ───────────────────────────── UI ─────────────────────────────

const ui = {
  led: document.getElementById("led"),
  status: document.getElementById("status"),
  last: document.getElementById("last"),
  log: document.getElementById("log"),
};
const logLines = [];
function log(msg) {
  const line = `${new Date().toLocaleTimeString()} ${msg}`;
  logLines.push(line);
  if (logLines.length > 40) logLines.shift();
  if (ui.log) ui.log.textContent = logLines.join("\n");
  console.log("[adom]", msg);
}
function setStatus(state, text) {
  if (ui.led) ui.led.className = "led " + (state === "ok" ? "ok" : "wait");
  if (ui.status) ui.status.textContent = text;
}

// ───────────────────────────── connection ─────────────────────────────

let ws = null;
let backoff = 1000;
let connecting = false;

function connectionPaths() {
  const home = (os.homedir && os.homedir()) || "";
  const sep = home.includes("\\") ? "\\" : "/";
  const p = [home, ".adom", "premiere-bridge", "connection.json"].join(sep);
  return [p, "file:" + p, "plugin:/connection.json"];
}

async function readConnection() {
  for (const p of connectionPaths()) {
    try {
      const txt = await fs.readFile(p, { encoding: "utf-8" });
      const j = JSON.parse(txt);
      if (j && j.port && j.token) return j;
    } catch (_) {
      /* try the next location */
    }
  }
  return null;
}

async function connect() {
  if (connecting || (ws && ws.readyState <= 1)) return;
  connecting = true;
  try {
    const conn = await readConnection();
    if (!conn) {
      setStatus("wait", "Waiting for Adom Bridge (no connection file yet)…");
      return scheduleReconnect();
    }
    const url = `ws://127.0.0.1:${conn.port}/ws?token=${encodeURIComponent(conn.token)}`;
    setStatus("wait", `Connecting to bridge on port ${conn.port}…`);
    const sock = new WebSocket(url);
    ws = sock;
    sock.onopen = async () => {
      backoff = 1000;
      setStatus("ok", `Connected (port ${conn.port})`);
      log("connected");
      send({ type: "hello", runtime: "uxp", ...(await helloInfo()) });
    };
    sock.onmessage = (ev) => onMessage(ev.data);
    sock.onclose = () => {
      if (ws === sock) ws = null;
      setStatus("wait", "Disconnected — retrying…");
      scheduleReconnect();
    };
    sock.onerror = () => {
      /* onclose follows */
    };
  } catch (e) {
    log("connect error: " + e);
    scheduleReconnect();
  } finally {
    connecting = false;
  }
}

function scheduleReconnect() {
  setTimeout(connect, backoff);
  backoff = Math.min(backoff * 2, 5000);
}

function send(obj) {
  if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj, jsonReplacer));
}

async function helloInfo() {
  let project = null;
  let sequence = null;
  try {
    const p = await ppro.Project.getActiveProject();
    if (p) {
      project = p.name;
      const s = await p.getActiveSequence();
      if (s) sequence = s.name;
    }
  } catch (_) {}
  let premiereVersion = "?";
  try {
    const host = require("uxp").host;
    premiereVersion = host.version;
  } catch (_) {}
  return { pluginVersion: PLUGIN_VERSION, protocol: PROTOCOL, premiereVersion, project, sequence, os: os.platform && os.platform() };
}

async function onMessage(data) {
  let msg;
  try {
    msg = JSON.parse(data);
  } catch (_) {
    return;
  }
  if (msg.type === "ping") return send({ type: "pong" });
  if (msg.type !== "call") return;
  const { id, verb, args } = msg;
  const fn = handlers[verb];
  if (ui.last) ui.last.textContent = `Last: ${verb}`;
  if (!fn) {
    return send({ type: "result", id, ok: false, errorCode: "not_in_uxp", error: `UXP plugin has no verb '${verb}'` });
  }
  const warnings = [];
  try {
    const result = await fn(args || {}, { warn: (w) => warnings.push(w) });
    send({ type: "result", id, ok: true, result: result === undefined ? null : result, warnings: warnings.length ? warnings : undefined });
  } catch (e) {
    log(`${verb} failed: ${e && e.message ? e.message : e}`);
    send({
      type: "result",
      id,
      ok: false,
      error: String(e && e.message ? e.message : e),
      errorCode: (e && e.code) || "premiere_error",
      stack: e && e.stack ? String(e.stack).split("\n").slice(0, 6).join("\n") : undefined,
    });
  }
}

function fail(code, message) {
  const e = new Error(message);
  e.code = code;
  return e;
}

function jsonReplacer(_k, v) {
  if (v && typeof v === "object" && typeof v.ticks === "string" && typeof v.seconds === "number" && !("timecode" in v)) {
    return { seconds: v.seconds, ticks: v.ticks };
  }
  if (typeof v === "bigint") return v.toString();
  return v;
}

// ───────────────────────────── helpers: project / sequence ─────────────────────────────

async function project() {
  const p = await ppro.Project.getActiveProject();
  if (!p) throw fail("no_project", "No project is open in Premiere. Use premiere_open_project or premiere_new_project.");
  return p;
}

async function findSequence(p, ref) {
  if (ref === undefined || ref === null || ref === "") {
    const s = await p.getActiveSequence();
    if (!s) throw fail("no_sequence", "No active sequence. Use premiere_create_sequence or premiere_set_active_sequence.");
    return s;
  }
  const seqs = await p.getSequences();
  for (const s of seqs) {
    if (s.guid.toString() === String(ref) || s.name === String(ref)) return s;
  }
  throw fail("sequence_not_found", `No sequence matching '${ref}'. Sequences: ${seqs.map((s) => s.name).join(", ")}`);
}

async function seqCtx(args) {
  const p = await project();
  const seq = await findSequence(p, args.sequence);
  return { p, seq };
}

async function frameRate(seq) {
  const settings = await seq.getSettings();
  return settings.getVideoFrameRate();
}

async function timeDisplay(seq) {
  return await seq.getSequenceVideoTimeDisplayFormat();
}

/** Seconds (number), timecode ("00:00:05:12"), "5s", "120f", {seconds}|{ticks}|{frames}. */
async function toTick(v, seq) {
  if (v === undefined || v === null) return undefined;
  if (typeof v === "object" && v.ticks !== undefined) return ppro.TickTime.createWithTicks(String(v.ticks));
  if (typeof v === "object" && v.seconds !== undefined) return ppro.TickTime.createWithSeconds(Number(v.seconds));
  if (typeof v === "object" && v.frames !== undefined) {
    return ppro.TickTime.createWithFrameAndFrameRate(Number(v.frames), await frameRate(seq));
  }
  if (typeof v === "number") return ppro.TickTime.createWithSeconds(v);
  const s = String(v).trim();
  if (/^-?\d+(\.\d+)?s?$/.test(s)) return ppro.TickTime.createWithSeconds(parseFloat(s));
  if (/^\d+f$/.test(s)) return ppro.TickTime.createWithFrameAndFrameRate(parseInt(s, 10), await frameRate(seq));
  if (/^\d+[:;]\d+[:;]\d+[:;]\d+$/.test(s)) {
    const fr = await frameRate(seq);
    return ppro.TickTime.createWithSeconds(timecodeToSeconds(s, fr.value));
  }
  throw fail("bad_time", `Can't parse time '${v}'. Use seconds (12.5), a timecode ("00:00:12:15"), "300f", or {ticks}.`);
}

async function timeOut(t, seq) {
  if (!t) return null;
  const o = { seconds: round(t.seconds), ticks: t.ticks };
  if (seq) {
    try {
      const fr = await frameRate(seq);
      o.timecode = secondsToTimecode(t.seconds, fr.value);
      o.frame = Math.round((t.ticksNumber || Number(t.ticks)) / fr.ticksPerFrame);
    } catch (_) {}
  }
  return o;
}

// 29.97/59.94 use drop-frame (";" separator) like Premiere's default display.
function isDropFrame(fps) {
  return Math.abs(fps - 29.97) < 0.01 || Math.abs(fps - 59.94) < 0.01;
}

function secondsToTimecode(seconds, fps) {
  const nominal = Math.round(fps);
  let frames = Math.round(seconds * fps);
  const drop = isDropFrame(fps);
  if (drop) {
    const d = nominal === 60 ? 4 : 2; // frames dropped per minute
    const perMin = nominal * 60 - d;
    const per10 = perMin * 10 + d;
    const tens = Math.floor(frames / per10);
    const rem = frames % per10;
    frames += d * 9 * tens + (rem > d ? d * Math.floor((rem - d) / perMin) : 0);
  }
  const ff = frames % nominal;
  const totalSec = Math.floor(frames / nominal);
  const pad = (n) => String(n).padStart(2, "0");
  const sep = drop ? ";" : ":";
  return `${pad(Math.floor(totalSec / 3600))}:${pad(Math.floor(totalSec / 60) % 60)}:${pad(totalSec % 60)}${sep}${pad(ff)}`;
}

function timecodeToSeconds(tc, fps) {
  const parts = tc.split(/[:;]/).map((n) => parseInt(n, 10));
  const [hh, mm, ss, ff] = parts;
  const nominal = Math.round(fps);
  let frames = ((hh * 60 + mm) * 60 + ss) * nominal + ff;
  if (isDropFrame(fps) && /;/.test(tc)) {
    const d = nominal === 60 ? 4 : 2;
    const totalMin = hh * 60 + mm;
    frames -= d * (totalMin - Math.floor(totalMin / 10));
  }
  return frames / fps;
}

function round(n) {
  return Math.round(n * 1000) / 1000;
}

/** Run actions as ONE undoable step. `build` runs synchronously inside lockedAccess. */
function tx(p, label, build) {
  let ok = false;
  let err = null;
  p.lockedAccess(() => {
    try {
      ok = p.executeTransaction((ca) => {
        const actions = build();
        for (const a of [].concat(actions).filter(Boolean)) ca.addAction(a);
      }, label || "Adom Bridge");
    } catch (e) {
      err = e;
    }
  });
  if (err) throw err;
  if (ok === false) throw fail("transaction_failed", `Premiere rejected the edit '${label}'. Check the clip/time arguments (e.g. a trim past the media's length).`);
  return true;
}

// ───────────────────────────── helpers: project items ─────────────────────────────

// Item type numbers come from ppro.ProjectItem.TYPE_* (Constants.ProjectItemType isn't
// exposed at runtime on 26.x).
function typeName(t) {
  const PI = ppro.ProjectItem;
  const map = { [PI.TYPE_BIN]: "bin", [PI.TYPE_CLIP]: "clip", [PI.TYPE_FILE]: "file", [PI.TYPE_ROOT]: "root" };
  if (PI.TYPE_COMPOUND !== undefined) map[PI.TYPE_COMPOUND] = "compound";
  if (PI.TYPE_STYLE !== undefined) map[PI.TYPE_STYLE] = "style";
  return map[t] || String(t);
}

async function walkItems(folder, path, out, depth, maxDepth) {
  const items = await folder.getItems();
  for (const it of items) {
    const type = typeName(it.type);
    const rec = { item: it, name: it.name, type, bin: path, id: safe(() => it.getId()) };
    out.push(rec);
    if (type === "bin" && depth < maxDepth) {
      await walkItems(ppro.FolderItem.cast(it), path ? `${path}/${it.name}` : it.name, out, depth + 1, maxDepth);
    }
  }
  return out;
}

function safe(fn, dflt = null) {
  try {
    return fn();
  } catch (_) {
    return dflt;
  }
}

async function allItems(p) {
  return walkItems(await p.getRootItem(), "", [], 0, 32);
}

/** Item ref: id string, exact name, bin path "Footage/clip.mp4", or {path: mediaFilePath}. */
async function findItem(p, ref) {
  if (!ref) throw fail("bad_args", "item is required (project item id, name, 'Bin/name', or {mediaPath})");
  const recs = await allItems(p);
  const want = typeof ref === "object" ? ref : { key: String(ref) };
  for (const r of recs) {
    if (want.key !== undefined) {
      if (r.id === want.key || r.name === want.key || (r.bin ? `${r.bin}/${r.name}` : r.name) === want.key) return r.item;
    }
  }
  const mediaPath = want.mediaPath || want.path || (want.key && /[\\/]/.test(want.key) ? want.key : null);
  if (mediaPath) {
    for (const r of recs) {
      if (r.type !== "clip" && r.type !== "file") continue;
      const clip = ppro.ClipProjectItem.cast(r.item);
      const mp = clip ? await clip.getMediaFilePath().catch(() => "") : "";
      if (mp && normPath(mp) === normPath(mediaPath)) return r.item;
    }
  }
  throw fail("item_not_found", `No project item matching ${JSON.stringify(ref)}. Call premiere_list_project_items to see ids and names.`);
}

function normPath(s) {
  return String(s).replace(/\\/g, "/").toLowerCase();
}

async function describeItem(rec, withMedia) {
  const o = { id: rec.id, name: rec.name, type: rec.type, bin: rec.bin || "/" };
  if (withMedia && (rec.type === "clip" || rec.type === "file")) {
    const clip = ppro.ClipProjectItem.cast(rec.item);
    if (clip) {
      o.mediaPath = await clip.getMediaFilePath().catch(() => null);
      o.isSequence = await clip.isSequence().catch(() => false);
      o.offline = await clip.isOffline().catch(() => null);
      try {
        const media = await clip.getMedia();
        if (media) o.duration = round((await media.duration).seconds);
      } catch (_) {}
    }
  }
  return o;
}

async function binFor(p, ref) {
  if (!ref || ref === "/" ) return await p.getRootItem();
  const it = await findItem(p, ref);
  const f = ppro.FolderItem.cast(it);
  if (!f) throw fail("not_a_bin", `'${ref}' is not a bin`);
  return f;
}

// ───────────────────────────── helpers: tracks & clips ─────────────────────────────

/** "V1" / "A2" / {type:"video", index:0}. Track numbers in refs are 1-based like the UI. */
function parseTrack(ref) {
  if (ref && typeof ref === "object") {
    return { kind: String(ref.type || ref.kind || "video").startsWith("a") ? "audio" : "video", index: Number(ref.index || 0) };
  }
  const m = /^([VvAa])(\d+)$/.exec(String(ref || "V1").trim());
  if (!m) throw fail("bad_track", `Bad track '${ref}'. Use "V1", "V2", "A1"…`);
  return { kind: m[1].toUpperCase() === "V" ? "video" : "audio", index: parseInt(m[2], 10) - 1 };
}

function trackLabel(kind, index) {
  return (kind === "video" ? "V" : "A") + (index + 1);
}

async function getTrack(seq, kind, index) {
  const count = kind === "video" ? await seq.getVideoTrackCount() : await seq.getAudioTrackCount();
  if (index < 0 || index >= count) throw fail("track_not_found", `${trackLabel(kind, index)} doesn't exist (sequence has ${count} ${kind} tracks). premiere_add_tracks can add more.`);
  return kind === "video" ? await seq.getVideoTrack(index) : await seq.getAudioTrack(index);
}

async function trackClips(track) {
  const items = track.getTrackItems(C.TrackItemType.CLIP, false) || [];
  const withStart = [];
  for (const it of items) withStart.push({ it, start: (await it.getStartTime()).ticksNumber });
  withStart.sort((a, b) => a.start - b.start);
  return withStart.map((x) => x.it);
}

/** Clip ref: "V1:0" (track:index, 0-based in time order), {track:"V1", index}, or {track:"V1", at: seconds}. */
async function findClip(seq, ref) {
  let trackRef, index, at;
  if (typeof ref === "string") {
    const m = /^([VvAa]\d+):(\d+)$/.exec(ref.trim());
    if (!m) throw fail("bad_clip", `Bad clip ref '${ref}'. Use "V1:0" (track:index) — premiere_get_sequence lists them.`);
    trackRef = m[1];
    index = parseInt(m[2], 10);
  } else if (ref && typeof ref === "object") {
    trackRef = ref.track;
    index = ref.index;
    at = ref.at;
  } else {
    throw fail("bad_clip", "clip is required, e.g. \"V1:0\"");
  }
  const { kind, index: tIdx } = parseTrack(trackRef);
  const track = await getTrack(seq, kind, tIdx);
  const clips = await trackClips(track);
  if (at !== undefined) {
    const t = (await toTick(at, seq)).ticksNumber;
    for (let i = 0; i < clips.length; i++) {
      const s = (await clips[i].getStartTime()).ticksNumber;
      const e = (await clips[i].getEndTime()).ticksNumber;
      if (t >= s && t < e) return { clip: clips[i], kind, trackIndex: tIdx, index: i, ref: `${trackLabel(kind, tIdx)}:${i}` };
    }
    throw fail("clip_not_found", `No clip on ${trackLabel(kind, tIdx)} at ${at}`);
  }
  if (index === undefined || index < 0 || index >= clips.length) {
    throw fail("clip_not_found", `${trackLabel(kind, tIdx)} has ${clips.length} clips; index ${index} is out of range. premiere_get_sequence lists them.`);
  }
  return { clip: clips[index], kind, trackIndex: tIdx, index, ref: `${trackLabel(kind, tIdx)}:${index}` };
}

async function describeClip(c, seq, kind, tIdx, i, detail) {
  const o = {
    ref: `${trackLabel(kind, tIdx)}:${i}`,
    name: await c.getName().catch(() => null),
    start: await timeOut(await c.getStartTime(), seq),
    end: await timeOut(await c.getEndTime(), seq),
    duration: round((await c.getDuration()).seconds),
    inPoint: round((await c.getInPoint()).seconds),
    outPoint: round((await c.getOutPoint()).seconds),
    disabled: await c.isDisabled().catch(() => false),
    selected: await c.getIsSelected().catch(() => false),
  };
  if (detail) {
    o.speed = await c.getSpeed().catch(() => null);
    o.isAdjustmentLayer = await c.isAdjustmentLayer().catch(() => false);
    try {
      const pi = await c.getProjectItem();
      if (pi) {
        o.projectItem = { id: safe(() => pi.getId()), name: pi.name };
        const clip = ppro.ClipProjectItem.cast(pi);
        if (clip) o.projectItem.mediaPath = await clip.getMediaFilePath().catch(() => null);
      }
    } catch (_) {}
    try {
      const chain = await c.getComponentChain();
      const names = [];
      for (let k = 0; k < chain.getComponentCount(); k++) names.push(await chain.getComponentAtIndex(k).getDisplayName());
      o.effects = names;
    } catch (_) {}
  }
  return o;
}

/** Clips on the other tracks that are linked to this one (same source, same start/end). */
async function linkedPartners(seq, target) {
  const pi = await target.clip.getProjectItem().catch(() => null);
  const id = pi && safe(() => pi.getId());
  const S = (await target.clip.getStartTime()).ticks;
  const E = (await target.clip.getEndTime()).ticks;
  const out = [];
  for (const kind of ["video", "audio"]) {
    const n = kind === "video" ? await seq.getVideoTrackCount() : await seq.getAudioTrackCount();
    for (let ti = 0; ti < n; ti++) {
      if (kind === target.kind && ti === target.trackIndex) continue;
      const clips = await trackClips(await getTrack(seq, kind, ti));
      for (let i = 0; i < clips.length; i++) {
        const c = clips[i];
        if ((await c.getStartTime()).ticks !== S || (await c.getEndTime()).ticks !== E) continue;
        const cpi = await c.getProjectItem().catch(() => null);
        if (id && cpi && safe(() => cpi.getId()) === id) out.push({ clip: c, kind, trackIndex: ti, index: i, ref: `${trackLabel(kind, ti)}:${i}` });
      }
    }
  }
  return out;
}

/** Targets plus their linked partners (unless args.linked === false), de-duplicated. */
async function withLinked(seq, args, targets) {
  if (args.linked === false) return targets;
  const all = [...targets];
  for (const t of targets) {
    for (const p of await linkedPartners(seq, t)) if (!all.some((x) => x.ref === p.ref)) all.push(p);
  }
  return all;
}

/**
 * Run `fn(selection)` with a TrackItemSelection holding `clips`. The selection object is
 * only valid INSIDE createEmptySelection's callback ("The script object is no longer
 * valid" otherwise), so everything that uses it happens in there.
 */
function withSelection(clips, fn) {
  let result;
  let err = null;
  const ok = ppro.TrackItemSelection.createEmptySelection((sel) => {
    try {
      for (const c of clips) sel.addItem(c, true);
      result = fn(sel);
    } catch (e) {
      err = e;
    }
  });
  if (err) throw err;
  if (ok === false) throw fail("selection_failed", "Couldn't create a track item selection");
  return result;
}

async function resolveClips(seq, args) {
  const refs = args.clips || (args.clip !== undefined ? [args.clip] : null);
  if (!refs) throw fail("bad_args", "clip or clips is required");
  const out = [];
  for (const r of refs) out.push(await findClip(seq, r));
  return out;
}

// ───────────────────────────── helpers: components & params ─────────────────────────────

async function componentsOf(clip) {
  const chain = await clip.getComponentChain();
  const comps = [];
  for (let i = 0; i < chain.getComponentCount(); i++) {
    const c = chain.getComponentAtIndex(i);
    comps.push({ index: i, comp: c, displayName: await c.getDisplayName(), matchName: await c.getMatchName() });
  }
  return { chain, comps };
}

async function findComponent(clip, ref) {
  const { chain, comps } = await componentsOf(clip);
  const r = ref === undefined ? 0 : ref;
  const found =
    typeof r === "number"
      ? comps[r]
      : comps.find((c) => c.matchName === r || c.displayName.toLowerCase() === String(r).toLowerCase());
  if (!found) {
    throw fail("component_not_found", `No component '${ref}' on this clip. It has: ${comps.map((c) => `${c.displayName} (${c.matchName})`).join(", ")}`);
  }
  return { chain, ...found };
}

function findParam(comp, ref) {
  const n = comp.getParamCount();
  const names = [];
  for (let i = 0; i < n; i++) {
    const p = comp.getParam(i);
    names.push(p.displayName);
    if ((typeof ref === "number" && ref === i) || (typeof ref === "string" && p.displayName.toLowerCase() === ref.toLowerCase())) {
      return { param: p, index: i };
    }
  }
  throw fail("param_not_found", `No parameter '${ref}'. Parameters: ${names.join(", ")}`);
}

/**
 * Keyframe positions live in the clip's MEDIA time (timeline time - clip start + clip in
 * point), measured on 26.5. Verbs take sequence time by default (timeBase:"clip" opts out).
 */
async function kfTime(clip, v, seq, timeBase) {
  const t = await toTick(v, seq);
  if (timeBase === "clip") return t;
  return t.subtract(await clip.getStartTime()).add(await clip.getInPoint());
}

async function kfToSeq(clip, t) {
  return t.subtract(await clip.getInPoint()).add(await clip.getStartTime());
}

function makeValue(v) {
  if (Array.isArray(v) && v.length === 2) return new ppro.PointF(Number(v[0]), Number(v[1]));
  if (v && typeof v === "object" && "x" in v && "y" in v) return new ppro.PointF(Number(v.x), Number(v.y));
  if (v && typeof v === "object" && "red" in v) return new ppro.Color(v.red, v.green, v.blue, v.alpha === undefined ? 1 : v.alpha);
  return v;
}

async function paramValueAt(param, t) {
  try {
    const r = await param.getValueAtTime(t);
    return r && r.value !== undefined ? r.value : r;
  } catch (_) {
    try {
      const k = await param.getStartValue();
      return k && k.value !== undefined ? (k.value.value !== undefined ? k.value.value : k.value) : k;
    } catch (_) {
      return null;
    }
  }
}

// Premiere's clip "Volume > Level" param stores linear amplitude; the UI shows dB with
// 0 dB at the param's 1.0-equivalent. These match the long-standing ExtendScript mapping
// (level = 10^((dB-15)/20)); premiere_audio_probe verifies it on the running build.
function dbToLevel(db) {
  return Math.pow(10, (Number(db) - 15) / 20);
}
function levelToDb(level) {
  return level > 0 ? 20 * Math.log10(level) + 15 : -Infinity;
}

async function volumeParam(clip) {
  const { comps } = await componentsOf(clip);
  const vol = comps.find((c) => /volume/i.test(c.displayName) || /ADBE Volume/i.test(c.matchName));
  if (!vol) throw fail("no_volume", "This clip has no Volume component — is it an audio clip? Use an A-track ref like \"A1:0\".");
  const { param } = findParam(vol.comp, "Level");
  return param;
}

// ───────────────────────────── handlers ─────────────────────────────

const handlers = {
  // ── app / project ──
  async ping() {
    return { pong: true, ...(await helloInfo()) };
  },

  async get_project() {
    const p = await project();
    const seqs = await p.getSequences();
    const active = await p.getActiveSequence();
    const recs = await allItems(p);
    return {
      name: p.name,
      path: p.path,
      guid: p.guid.toString(),
      activeSequence: active ? active.name : null,
      sequences: seqs.map((s) => ({ name: s.name, guid: s.guid.toString() })),
      itemCount: recs.length,
      bins: recs.filter((r) => r.type === "bin").map((r) => (r.bin ? `${r.bin}/${r.name}` : r.name)),
    };
  },

  async open_project(a) {
    if (!a.path) throw fail("bad_args", "path is required");
    const opts = new ppro.OpenProjectOptions();
    safe(() => opts.setShowConvertProjectDialog(false));
    safe(() => opts.setShowLocateFileDialog(false));
    safe(() => opts.setShowWarningDialog(false));
    safe(() => opts.setAddToMRUList(true));
    const p = await ppro.Project.open(a.path, opts);
    if (!p) throw fail("open_failed", `Premiere couldn't open ${a.path}`);
    return { name: p.name, path: p.path };
  },

  async new_project(a) {
    if (!a.path) throw fail("bad_args", "path is required (…/MyProject.prproj)");
    const path = /\.prproj$/i.test(a.path) ? a.path : a.path + ".prproj";
    const p = await ppro.Project.createProject(path);
    if (!p) throw fail("create_failed", `Couldn't create ${path} — does the folder exist?`);
    return { name: p.name, path: p.path };
  },

  async save_project(a) {
    const p = await project();
    if (a.path) return { saved: await p.saveAs(a.path), path: a.path };
    return { saved: await p.save(), path: p.path };
  },

  async close_project(a) {
    const p = await project();
    if (a.save) await p.save();
    const opts = new ppro.CloseProjectOptions();
    safe(() => opts.setPromptIfDirty(false));
    safe(() => opts.setShowCancelButton(false));
    return { closed: await p.close(opts) };
  },

  // ── project panel ──
  async list_project_items(a) {
    const p = await project();
    let recs = await allItems(p);
    if (a.bin) recs = recs.filter((r) => r.bin === a.bin || r.bin.startsWith(a.bin + "/"));
    if (a.type) recs = recs.filter((r) => r.type === a.type);
    if (a.search) {
      const q = String(a.search).toLowerCase();
      recs = recs.filter((r) => r.name.toLowerCase().includes(q));
    }
    const limit = a.limit || 500;
    const items = [];
    for (const r of recs.slice(0, limit)) items.push(await describeItem(r, a.details !== false));
    return { count: recs.length, items, truncated: recs.length > limit };
  },

  async import_media(a) {
    const paths = a.paths || (a.path ? [a.path] : null);
    if (!paths || !paths.length) throw fail("bad_args", "paths (array of absolute desktop file paths) is required");
    const p = await project();
    const bin = await binFor(p, a.bin);
    const before = new Set((await allItems(p)).map((r) => r.id));
    const ok = await p.importFiles(paths, true, bin, !!a.asNumberedStills);
    const after = await allItems(p);
    const added = [];
    for (const r of after) if (!before.has(r.id)) added.push(await describeItem(r, true));
    if (!ok && !added.length) throw fail("import_failed", `Premiere refused the import. Check the paths exist on THIS machine: ${paths.join(", ")}`);
    return { imported: added.length, items: added };
  },

  async create_bin(a) {
    const p = await project();
    const parent = await binFor(p, a.parent);
    tx(p, "Create Bin", () => parent.createBinAction(a.name, a.makeUnique !== false));
    return { created: a.name, parent: a.parent || "/" };
  },

  async move_item(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    const dest = await binFor(p, a.bin);
    const parent = item.getParentBin();
    tx(p, "Move Item", () => parent.createMoveItemAction(item, dest));
    return { moved: item.name, to: a.bin || "/" };
  },

  async rename_item(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    tx(p, "Rename Item", () => item.createSetNameAction(a.name));
    return { renamed: a.name };
  },

  async delete_item(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    const parent = item.getParentBin();
    tx(p, "Delete Item", () => parent.createRemoveItemAction(item));
    return { deleted: item.name };
  },

  async set_item_color(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    tx(p, "Label Color", () => item.createSetColorLabelAction(Number(a.colorIndex)));
    return { item: item.name, colorIndex: a.colorIndex };
  },

  async set_source_in_out(a) {
    const p = await project();
    const clip = ppro.ClipProjectItem.cast(await findItem(p, a.item));
    if (!clip) throw fail("not_a_clip", "item is not a clip");
    if (a.clear) {
      tx(p, "Clear In/Out", () => clip.createClearInOutPointsAction());
      return { cleared: true };
    }
    const tin = await toTick(a.in, null);
    const tout = await toTick(a.out, null);
    tx(p, "Source In/Out", () => {
      if (tin && tout) return clip.createSetInOutPointsAction(tin, tout);
      if (tin) return clip.createSetInPointAction(tin);
      if (tout) return clip.createSetOutPointAction(tout);
      return [];
    });
    return { item: clip.name, in: a.in, out: a.out };
  },

  async create_subclip(a) {
    const p = await project();
    const clip = ppro.ClipProjectItem.cast(await findItem(p, a.item));
    const s = await toTick(a.start, null);
    const e = await toTick(a.end, null);
    tx(p, "Subclip", () => clip.createSubClipAction(a.name || `${clip.name} subclip`, s, e, !!a.hardBoundaries, { takeVideo: a.video !== false, takeAudio: a.audio !== false }));
    return { created: a.name || `${clip.name} subclip` };
  },

  async get_metadata(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    return {
      projectMetadata: await ppro.Metadata.getProjectMetadata(item).catch(() => null),
      xmp: a.xmp ? await ppro.Metadata.getXMPMetadata(item).catch(() => null) : undefined,
    };
  },

  async set_xmp_metadata(a) {
    const p = await project();
    const item = await findItem(p, a.item);
    tx(p, "Set Metadata", () => ppro.Metadata.createSetXMPMetadataAction(item, a.xmp));
    return { item: item.name };
  },

  // ── sequences ──
  async list_sequences() {
    const p = await project();
    const active = await p.getActiveSequence();
    const out = [];
    for (const s of await p.getSequences()) {
      out.push({
        name: s.name,
        guid: s.guid.toString(),
        active: !!active && active.guid.toString() === s.guid.toString(),
        duration: round((await s.getEndTime()).seconds),
      });
    }
    return { sequences: out };
  },

  async set_active_sequence(a) {
    const p = await project();
    const s = await findSequence(p, a.sequence);
    await p.openSequence(s).catch(() => {});
    await p.setActiveSequence(s);
    return { active: s.name };
  },

  async create_sequence(a) {
    const p = await project();
    let s;
    if (a.fromItems && a.fromItems.length) {
      // Takes plain ProjectItems; passing an undefined bin is an "Illegal Parameter type".
      const items = [];
      for (const r of a.fromItems) items.push(await findItem(p, r));
      s = a.bin ? await p.createSequenceFromMedia(a.name || "Sequence", items, await binFor(p, a.bin)) : await p.createSequenceFromMedia(a.name || "Sequence", items);
    } else if (a.presetPath) {
      s = await p.createSequenceWithPresetPath(a.name || "Sequence", a.presetPath);
    } else {
      s = await p.createSequence(a.name || "Sequence");
    }
    if (!s) throw fail("create_failed", "Premiere didn't create the sequence");
    await p.setActiveSequence(s).catch(() => {});
    return { name: s.name, guid: s.guid.toString() };
  },

  async duplicate_sequence(a) {
    const { p, seq } = await seqCtx(a);
    tx(p, "Duplicate Sequence", () => seq.createCloneAction());
    return { duplicated: seq.name };
  },

  async delete_sequence(a) {
    const { p, seq } = await seqCtx(a);
    return { deleted: await p.deleteSequence(seq), name: seq.name };
  },

  async nest_selection(a) {
    const { seq } = await seqCtx(a);
    const nested = await seq.createSubsequence(!!a.ignoreTrackTargeting);
    return { nested: nested ? nested.name : null };
  },

  async get_sequence(a) {
    const { seq } = await seqCtx(a);
    const settings = await seq.getSettings();
    const fr = settings.getVideoFrameRate();
    const rect = await seq.getFrameSize();
    const out = {
      name: seq.name,
      guid: seq.guid.toString(),
      width: rect.width,
      height: rect.height,
      fps: round(fr.value || TICKS_PER_SECOND / fr.ticksPerFrame),
      duration: await timeOut(await seq.getEndTime(), seq),
      playhead: await timeOut(await seq.getPlayerPosition(), seq),
      inPoint: await timeOut(await seq.getInPoint(), seq),
      outPoint: await timeOut(await seq.getOutPoint(), seq),
      videoTracks: [],
      audioTracks: [],
    };
    const detail = a.detail !== false;
    for (const kind of ["video", "audio"]) {
      const n = kind === "video" ? await seq.getVideoTrackCount() : await seq.getAudioTrackCount();
      for (let t = 0; t < n; t++) {
        const track = await getTrack(seq, kind, t);
        const clips = await trackClips(track);
        const desc = [];
        for (let i = 0; i < clips.length; i++) desc.push(await describeClip(clips[i], seq, kind, t, i, detail));
        (kind === "video" ? out.videoTracks : out.audioTracks).push({
          track: trackLabel(kind, t),
          name: track.name,
          muted: await track.isMuted().catch(() => false),
          clips: desc,
        });
      }
    }
    return out;
  },

  async get_sequence_settings(a) {
    const { seq } = await seqCtx(a);
    const s = await seq.getSettings();
    const fr = s.getVideoFrameRate();
    const rect = await s.getVideoFrameRect();
    return {
      width: rect.width,
      height: rect.height,
      fps: round(fr.value),
      pixelAspectRatio: await s.getVideoPixelAspectRatio().catch(() => null),
      fieldType: await s.getVideoFieldType().catch(() => null),
      audioSampleRate: await s.getAudioSampleRate().then((r) => r.value).catch(() => null),
      audioChannelCount: await s.getAudioChannelCount().catch(() => null),
      editingMode: await s.getEditingMode().catch(() => null),
      previewCodec: await s.getPreviewCodec().catch(() => null),
      maxBitDepth: await s.getMaximumBitDepth().catch(() => null),
      maxRenderQuality: await s.getMaxRenderQuality().catch(() => null),
    };
  },

  async set_sequence_settings(a) {
    const { p, seq } = await seqCtx(a);
    const s = await seq.getSettings();
    if (a.width && a.height) {
      const r = new ppro.RectF();
      r.width = a.width;
      r.height = a.height;
      await s.setVideoFrameRect(r);
    }
    if (a.fps) s.setVideoFrameRate(ppro.FrameRate.createWithValue(Number(a.fps)));
    if (a.maxBitDepth !== undefined) await s.setMaximumBitDepth(!!a.maxBitDepth);
    if (a.maxRenderQuality !== undefined) await s.setMaxRenderQuality(!!a.maxRenderQuality);
    tx(p, "Sequence Settings", () => seq.createSetSettingsAction(s));
    return await handlers.get_sequence_settings(a);
  },

  async set_playhead(a) {
    const { seq } = await seqCtx(a);
    const t = await toTick(a.time, seq);
    await seq.setPlayerPosition(t);
    return { playhead: await timeOut(await seq.getPlayerPosition(), seq) };
  },

  async set_in_out(a) {
    const { p, seq } = await seqCtx(a);
    const tin = await toTick(a.in, seq);
    const tout = await toTick(a.out, seq);
    tx(p, "Sequence In/Out", () => [tin && seq.createSetInPointAction(tin), tout && seq.createSetOutPointAction(tout)]);
    if (a.workArea) ppro.WorkAreaUtils.setWorkAreaInOutPoints(seq, tin, tout);
    return { inPoint: await timeOut(await seq.getInPoint(), seq), outPoint: await timeOut(await seq.getOutPoint(), seq) };
  },

  // ── timeline editing ──
  async insert_clip(a) {
    return placeClip(a, "insert");
  },
  async overwrite_clip(a) {
    return placeClip(a, "overwrite");
  },
  async append_clip(a) {
    const { seq } = await seqCtx(a);
    const { kind, index } = parseTrack(a.track || "V1");
    let end = 0;
    const clips = await trackClips(await getTrack(seq, kind, index));
    if (clips.length) end = (await clips[clips.length - 1].getEndTime()).seconds;
    return placeClip({ ...a, time: end }, "overwrite");
  },

  async move_clip(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await withLinked(seq, a, await resolveClips(seq, a));
    const plans = [];
    for (const t of targets) {
      const start = await t.clip.getStartTime();
      let delta;
      if (a.by !== undefined) delta = await toTick(a.by, seq);
      else if (a.to !== undefined) delta = (await toTick(a.to, seq)).subtract(start);
      else throw fail("bad_args", "Pass to (new start time) or by (offset, may be negative)");
      plans.push({ clip: t.clip, delta });
    }
    tx(p, "Move Clip", () => plans.map((x) => x.clip.createMoveAction(x.delta)));
    return { moved: targets.map((t) => t.ref), _note: "Moving can overwrite neighbors; call premiere_get_sequence to see the result." };
  },

  async trim_clip(a) {
    const { p, seq } = await seqCtx(a);
    const target = await findClip(seq, a.clip);
    const { clip, ref, kind, trackIndex, index } = target;
    const s0 = await toTick(a.start, seq);
    const e0 = await toTick(a.end, seq);
    const i = await toTick(a.inPoint, seq);
    const o = await toTick(a.outPoint, seq);
    const ds = a.trimStart !== undefined ? (await clip.getStartTime()).add(await toTick(a.trimStart, seq)) : null;
    const de = a.trimEnd !== undefined ? (await clip.getEndTime()).subtract(await toTick(a.trimEnd, seq)) : null;
    const group = await withLinked(seq, a, [target]);
    tx(p, "Trim Clip", () =>
      group.flatMap((g) => [
        (s0 || ds) && g.clip.createSetStartAction(s0 || ds),
        (e0 || de) && g.clip.createSetEndAction(e0 || de),
        i && g.clip.createSetInPointAction(i),
        o && g.clip.createSetOutPointAction(o),
      ])
    );
    return { clip: ref, linked: group.slice(1).map((g) => g.ref), after: await describeClip(clip, seq, kind, trackIndex, index, false) };
  },

  async remove_clips(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await withLinked(seq, a, await resolveClips(seq, a));
    const ed = ppro.SequenceEditor.getEditor(seq);
    withSelection(targets.map((t) => t.clip), (sel) =>
      tx(p, a.ripple ? "Ripple Delete" : "Delete Clips", () => ed.createRemoveItemsAction(sel, !!a.ripple, C.MediaType.ANY, !!a.shiftOverlapping))
    );
    return { removed: targets.map((t) => t.ref), ripple: !!a.ripple };
  },

  async remove_gap(a) {
    // Ripple-close the gap before a clip: it and everything after it on its track (and on
    // its linked partners' tracks, unless linked:false) shift left by the gap.
    const { p, seq } = await seqCtx(a);
    const target = await findClip(seq, a.clip);
    const { clip, kind, trackIndex, index } = target;
    const clips = await trackClips(await getTrack(seq, kind, trackIndex));
    const prevEnd = index > 0 ? await clips[index - 1].getEndTime() : ppro.TickTime.TIME_ZERO;
    const start = await clip.getStartTime();
    const gap = start.subtract(prevEnd);
    if (gap.ticksNumber <= 0) return { gap: 0 };
    const neg = ppro.TickTime.TIME_ZERO.subtract(gap);
    const moving = clips.slice(index);
    for (const partner of a.linked === false ? [] : await linkedPartners(seq, target)) {
      const pcs = await trackClips(await getTrack(seq, partner.kind, partner.trackIndex));
      const pPrevEnd = partner.index > 0 ? await pcs[partner.index - 1].getEndTime() : ppro.TickTime.TIME_ZERO;
      if (start.subtract(pPrevEnd).ticksNumber < gap.ticksNumber) {
        throw fail("gap_blocked", `${partner.ref} can't shift left by ${round(gap.seconds)}s without overlapping the clip before it. Pass linked:false to close the gap on ${trackLabel(kind, trackIndex)} only.`);
      }
      moving.push(...pcs.slice(partner.index));
    }
    tx(p, "Close Gap", () => moving.map((c) => c.createMoveAction(neg)));
    return { closed: round(gap.seconds), movedClips: moving.length };
  },

  async set_clip_enabled(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    tx(p, "Enable/Disable", () => targets.map((t) => t.clip.createSetDisabledAction(a.enabled === false)));
    return { clips: targets.map((t) => t.ref), enabled: a.enabled !== false };
  },

  async rename_clip(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    tx(p, "Rename Clip", () => clip.createSetNameAction(a.name));
    return { clip: ref, name: a.name };
  },

  async duplicate_clip(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const off = (await toTick(a.offset === undefined ? 0 : a.offset, seq)) || ppro.TickTime.TIME_ZERO;
    const ed = ppro.SequenceEditor.getEditor(seq);
    tx(p, "Duplicate Clip", () => ed.createCloneTrackItemAction(clip, off, Number(a.videoTrackOffset || 0), Number(a.audioTrackOffset || 0), a.alignToVideo !== false, !!a.insert));
    return { duplicated: ref };
  },

  async razor(a, ctx) {
    // UXP has no razor. Measured on 26.5: head trims (setStart/setInPoint) keep a clip's
    // sync offset (start - in), tail trims only move the end, and any cloned/moved copy
    // breaks sync — so a trim+clone razor can't be correct. Instead, for each linked
    // V/A group spanning T: trim the tails to T, temporarily set the SOURCE clip's in/out
    // to the right-hand half, overwrite it back in at T (same sync offset), then restore
    // the source in/out. Effects on the original stay on the left half only; the CEP
    // helper's true QE razor (preferred when connected) keeps them on both.
    const { p, seq } = await seqCtx(a);
    const fr = await frameRate(seq);
    let T = await toTick(a.time, seq);
    if (T.alignToNearestFrame) T = T.alignToNearestFrame(fr);
    const want = a.tracks ? new Set(a.tracks.map((t) => String(t).toUpperCase())) : null;
    const spans = [];
    for (const kind of ["video", "audio"]) {
      const n = kind === "video" ? await seq.getVideoTrackCount() : await seq.getAudioTrackCount();
      for (let ti = 0; ti < n; ti++) {
        if (want && !want.has(trackLabel(kind, ti))) continue;
        for (const c of await trackClips(await getTrack(seq, kind, ti))) {
          const S = await c.getStartTime();
          const E = await c.getEndTime();
          if (T.ticksNumber > S.ticksNumber && T.ticksNumber < E.ticksNumber) {
            spans.push({ c, kind, ti, S, E, I: await c.getInPoint(), speed: await c.getSpeed().catch(() => 1), pi: await c.getProjectItem() });
          }
        }
      }
    }
    if (!spans.length) {
      ctx.warn("No clip spans that time on the chosen tracks — nothing was cut.");
      return { cutTracks: [], at: await timeOut(T, seq), method: "uxp-overwrite" };
    }
    // Group linked partners: same source item, same start/end.
    const groups = [];
    for (const sp of spans) {
      const key = `${sp.pi && safe(() => sp.pi.getId())}|${sp.S.ticks}|${sp.E.ticks}`;
      let g = groups.find((x) => x.key === key);
      if (!g) groups.push((g = { key, members: [] }));
      g.members.push(sp);
    }
    const ed = ppro.SequenceEditor.getEditor(seq);
    const cut = [];
    for (const g of groups) {
      const ref = g.members[0];
      if (Math.abs(ref.speed - 1) > 1e-6) throw fail("razor_speed", "This clip has a speed change; the UXP razor fallback can't keep sync. Connect the CEP helper (premiere_install_plugin + restart Premiere) for a true razor.");
      const clipItem = ppro.ClipProjectItem.cast(ref.pi);
      if (!clipItem) throw fail("razor_failed", "Can't razor a clip without a source project item (e.g. a graphic) in UXP mode.");
      const v = g.members.find((m) => m.kind === "video");
      const au = g.members.find((m) => m.kind === "audio");
      const vt = v ? v.ti : -1;
      const at = au ? au.ti : -1;
      const left = T.subtract(ref.S);
      const newIn = ref.I.add(left);
      const newOut = ref.I.add(ref.E.subtract(ref.S));
      const mtV = C.MediaType.VIDEO;
      const prevIn = await clipItem.getInPoint(v ? mtV : C.MediaType.AUDIO).catch(() => null);
      const prevOut = await clipItem.getOutPoint(v ? mtV : C.MediaType.AUDIO).catch(() => null);
      tx(p, "Razor (trim)", () => g.members.map((m) => m.c.createSetEndAction(T)));
      try {
        tx(p, "Razor (source range)", () => clipItem.createSetInOutPointsAction(newIn, newOut));
        // Missing V or A in the group: aim that half at a far track index Premiere ignores is
        // not available, so overwrite both and then remove the stray half below.
        tx(p, "Razor (overwrite)", () => ed.createOverwriteItemAction(ref.pi, T, vt >= 0 ? vt : 0, at >= 0 ? at : 0));
      } finally {
        tx(p, "Razor (restore source)", () => (prevIn && prevOut ? clipItem.createSetInOutPointsAction(prevIn, prevOut) : clipItem.createClearInOutPointsAction()));
      }
      // If only one side was razored, the overwrite also laid the other side down at T on
      // track 0 — remove that stray piece so other tracks are untouched.
      if (vt < 0 || at < 0) {
        const strayKind = vt < 0 ? "video" : "audio";
        const strayTrack = await getTrack(seq, strayKind, 0);
        const stray = await clipStartingAt(strayTrack, T);
        if (stray) {
          withSelection([stray], (sel) => tx(p, "Razor (cleanup)", () => ed.createRemoveItemsAction(sel, false, C.MediaType.ANY, false)));
          ctx.warn(`Overwrite also touched ${trackLabel(strayKind, 0)} at the cut; that piece was removed. Razor linked V+A together to avoid this.`);
        }
      }
      for (const m of g.members) cut.push(trackLabel(m.kind, m.ti));
    }
    return { cutTracks: cut, at: await timeOut(T, seq), method: "uxp-overwrite" };
  },

  async select_clips(a) {
    const { seq } = await seqCtx(a);
    const targets = await withLinked(seq, a, await resolveClips(seq, a));
    withSelection(targets.map((t) => t.clip), (sel) => seq.setSelection(sel));
    return { selected: targets.map((t) => t.ref) };
  },

  async get_selection(a) {
    const { seq } = await seqCtx(a);
    const sel = await seq.getSelection();
    const items = await sel.getTrackItems();
    const out = [];
    for (const it of items) out.push({ name: await it.getName(), start: await timeOut(await it.getStartTime(), seq), end: await timeOut(await it.getEndTime(), seq), track: await it.getTrackIndex() });
    return { count: out.length, items: out };
  },

  async clear_selection(a) {
    const { seq } = await seqCtx(a);
    return { cleared: await seq.clearSelection() };
  },

  async scene_edit_detection(a) {
    const { seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    const op = { cut: ppro.SequenceUtils.SEQUENCE_OPERATION_APPLYCUT, marker: ppro.SequenceUtils.SEQUENCE_OPERATION_CREATEMARKER, subclip: ppro.SequenceUtils.SEQUENCE_OPERATION_CREATESUBCLIP }[a.operation || "cut"];
    const started = await withSelection(targets.map((t) => t.clip), (sel) => ppro.SequenceUtils.performSceneEditDetectionOnSelection(op, sel));
    return { started, operation: a.operation || "cut" };
  },

  async set_track_mute(a) {
    const { seq } = await seqCtx(a);
    const { kind, index } = parseTrack(a.track);
    const track = await getTrack(seq, kind, index);
    await track.setMute(a.mute !== false);
    return { track: trackLabel(kind, index), muted: await track.isMuted() };
  },

  async rename_track(a) {
    const { p, seq } = await seqCtx(a);
    const { kind, index } = parseTrack(a.track);
    const track = await getTrack(seq, kind, index);
    tx(p, "Rename Track", () => track.createSetNameAction(a.name));
    return { track: trackLabel(kind, index), name: a.name };
  },

  // ── effects, params, keyframes ──
  async list_effects(a) {
    const kind = a.kind || "video";
    let names;
    if (kind === "video") {
      const display = await ppro.VideoFilterFactory.getDisplayNames();
      const match = await ppro.VideoFilterFactory.getMatchNames();
      names = match.map((m, i) => ({ matchName: m, displayName: display[i] }));
    } else if (kind === "audio") {
      names = (await ppro.AudioFilterFactory.getDisplayNames()).map((d) => ({ displayName: d }));
    } else if (kind === "transition") {
      names = (await ppro.TransitionFactory.getVideoTransitionMatchNames()).map((m) => ({ matchName: m }));
    } else throw fail("bad_args", "kind must be video, audio, or transition");
    if (a.search) {
      const q = String(a.search).toLowerCase();
      names = names.filter((n) => (n.displayName || "").toLowerCase().includes(q) || (n.matchName || "").toLowerCase().includes(q));
    }
    return { kind, count: names.length, effects: names };
  },

  async get_clip_effects(a) {
    const { seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const { comps } = await componentsOf(clip);
    const at = a.time !== undefined ? await kfTime(clip, a.time, seq, a.timeBase) : await clip.getInPoint();
    const out = [];
    for (const c of comps) {
      const params = [];
      for (let i = 0; i < c.comp.getParamCount(); i++) {
        const prm = c.comp.getParam(i);
        params.push({
          index: i,
          name: prm.displayName,
          value: await paramValueAt(prm, at),
          animated: safe(() => prm.isTimeVarying(), false),
          keyframes: await Promise.all(safe(() => prm.getKeyframeListAsTickTimes(), []).map(async (t) => round((await kfToSeq(clip, t)).seconds))),
        });
      }
      out.push({ index: c.index, displayName: c.displayName, matchName: c.matchName, params });
    }
    return { clip: ref, components: out };
  },

  async add_effect(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    const done = [];
    for (const t of targets) {
      let comp;
      if (t.kind === "video") {
        let match = a.matchName;
        if (!match && a.name) {
          const display = await ppro.VideoFilterFactory.getDisplayNames();
          const matches = await ppro.VideoFilterFactory.getMatchNames();
          const i = display.findIndex((d) => d.toLowerCase() === String(a.name).toLowerCase());
          if (i < 0) throw fail("effect_not_found", `No video effect named '${a.name}'. premiere_list_effects {kind:"video", search:"…"} lists them.`);
          match = matches[i];
        }
        comp = await ppro.VideoFilterFactory.createComponent(match);
      } else {
        comp = await ppro.AudioFilterFactory.createComponentByDisplayName(a.name || a.matchName, t.clip);
      }
      if (!comp) throw fail("effect_not_found", `Premiere couldn't create effect '${a.name || a.matchName}'`);
      const chain = await t.clip.getComponentChain();
      tx(p, "Add Effect", () => (a.index !== undefined ? chain.createInsertComponentAction(comp, Number(a.index)) : chain.createAppendComponentAction(comp)));
      done.push(t.ref);
    }
    return { added: a.name || a.matchName, clips: done };
  },

  async remove_effect(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const { chain, comp, displayName } = await findComponent(clip, a.component);
    tx(p, "Remove Effect", () => chain.createRemoveComponentAction(comp));
    return { clip: ref, removed: displayName };
  },

  async set_param(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    const results = [];
    for (const t of targets) {
      const { comp, displayName } = await findComponent(t.clip, a.component);
      const { param } = findParam(comp, a.param);
      const kf = param.createKeyframe(makeValue(a.value));
      if (a.time !== undefined) {
        kf.position = await kfTime(t.clip, a.time, seq, a.timeBase);
        const animated = safe(() => param.isTimeVarying(), false);
        tx(p, "Keyframe", () => [!animated && param.createSetTimeVaryingAction(true), param.createAddKeyframeAction(kf)]);
      } else {
        tx(p, "Set Param", () => param.createSetValueAction(kf, true));
      }
      results.push({ clip: t.ref, component: displayName, param: param.displayName, value: a.value, keyframedAt: a.time });
    }
    return { set: results };
  },

  async add_keyframes(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const { comp } = await findComponent(clip, a.component);
    const { param } = findParam(comp, a.param);
    if (!Array.isArray(a.keyframes) || !a.keyframes.length) throw fail("bad_args", "keyframes: [{time, value}, …] is required");
    const kfs = [];
    for (const k of a.keyframes) {
      const kf = param.createKeyframe(makeValue(k.value));
      kf.position = await kfTime(clip, k.time, seq, a.timeBase);
      kfs.push(kf);
    }
    const interp = a.interpolation ? { linear: ppro.Keyframe.INTERPOLATION_MODE_LINEAR, hold: ppro.Keyframe.INTERPOLATION_MODE_HOLD, bezier: ppro.Keyframe.INTERPOLATION_MODE_BEZIER }[a.interpolation] : undefined;
    const animated = safe(() => param.isTimeVarying(), false);
    tx(p, "Add Keyframes", () => {
      const acts = [];
      if (!animated) acts.push(param.createSetTimeVaryingAction(true));
      for (const kf of kfs) acts.push(param.createAddKeyframeAction(kf));
      if (interp !== undefined) for (const kf of kfs) acts.push(param.createSetInterpolationAtKeyframeAction(kf.position, interp, true));
      return acts;
    });
    return { clip: ref, param: param.displayName, keyframes: a.keyframes.length };
  },

  async remove_keyframes(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const { comp } = await findComponent(clip, a.component);
    const { param } = findParam(comp, a.param);
    if (a.all) {
      tx(p, "Clear Keyframes", () => param.createSetTimeVaryingAction(false));
      return { clip: ref, cleared: true };
    }
    const s = await kfTime(clip, a.start !== undefined ? a.start : a.time, seq, a.timeBase);
    const e = a.end !== undefined ? await kfTime(clip, a.end, seq, a.timeBase) : null;
    tx(p, "Remove Keyframes", () => (e ? param.createRemoveKeyframeRangeAction(s, e, true) : param.createRemoveKeyframeAction(s, true)));
    return { clip: ref, removed: true };
  },

  async set_transform(a) {
    // Convenience over the built-in Motion / Opacity components.
    const { seq } = await seqCtx(a);
    const out = [];
    const set = async (component, param, value) => {
      if (value === undefined) return;
      await handlers.set_param({ ...a, component, param, value });
      out.push(`${component}.${param}=${JSON.stringify(value)}`);
    };
    await findClip(seq, a.clip);
    await set("Motion", "Scale", a.scale);
    await set("Motion", "Position", a.position);
    await set("Motion", "Rotation", a.rotation);
    await set("Motion", "Anchor Point", a.anchor);
    await set("Opacity", "Opacity", a.opacity);
    return { clip: a.clip, set: out };
  },

  async add_transition(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    let match = a.matchName;
    if (!match) {
      const all = await ppro.TransitionFactory.getVideoTransitionMatchNames();
      const q = String(a.name || "Cross Dissolve").toLowerCase().replace(/\s+/g, "");
      match = all.find((m) => m.toLowerCase().replace(/\s+/g, "").includes(q));
      if (!match) throw fail("transition_not_found", `No transition like '${a.name}'. premiere_list_effects {kind:"transition"} lists match names.`);
    }
    const opts = new ppro.AddTransitionOptions();
    if (a.duration !== undefined) opts.setDuration(await toTick(a.duration, seq));
    opts.setApplyToStart(a.position === "start");
    if (a.singleSided) opts.setForceSingleSided(true);
    if (a.alignment !== undefined) opts.setTransitionAlignment(Number(a.alignment));
    const done = [];
    for (const t of targets) {
      if (t.kind !== "video") throw fail("video_only", "UXP only supports video transitions. For audio, use premiere_add_keyframes on Volume>Level to fade.");
      const tr = ppro.TransitionFactory.createVideoTransition(match);
      tx(p, "Add Transition", () => t.clip.createAddVideoTransitionAction(tr, opts));
      done.push(t.ref);
    }
    return { transition: match, clips: done, position: a.position || "end" };
  },

  async remove_transition(a) {
    const { p, seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const pos = a.position === "start" ? C.TransitionPosition.START : C.TransitionPosition.END;
    tx(p, "Remove Transition", () => clip.createRemoveVideoTransitionAction(pos));
    return { clip: ref, removed: a.position || "end" };
  },

  async insert_mogrt(a) {
    const { p, seq } = await seqCtx(a);
    const t = await toTick(a.time || 0, seq);
    const { index: v } = parseTrack(a.track || "V2");
    const ed = ppro.SequenceEditor.getEditor(seq);
    let items;
    p.lockedAccess(() => {
      items = a.path ? ed.insertMogrtFromPath(a.path, t, v, Number(a.audioTrack || 0)) : ed.insertMogrtFromLibrary(a.library, a.element, t, v, Number(a.audioTrack || 0));
    });
    return { inserted: (items || []).length, track: trackLabel("video", v), _note: "Edit its text with premiere_get_clip_effects then premiere_set_param on the Graphic's text param." };
  },

  // ── audio ──
  async set_clip_volume(a) {
    const { p, seq } = await seqCtx(a);
    const targets = await resolveClips(seq, a);
    const out = [];
    for (const t of targets) {
      if (t.kind !== "audio") throw fail("audio_only", `${t.ref} is a video clip — volume lives on the linked audio clip (A-track).`);
      const param = await volumeParam(t.clip);
      const level = a.level !== undefined ? Number(a.level) : dbToLevel(a.db === undefined ? 0 : a.db);
      const kf = param.createKeyframe(level);
      if (a.time !== undefined) {
        kf.position = await kfTime(t.clip, a.time, seq, a.timeBase);
        const animated = safe(() => param.isTimeVarying(), false);
        tx(p, "Volume Keyframe", () => [!animated && param.createSetTimeVaryingAction(true), param.createAddKeyframeAction(kf)]);
      } else {
        tx(p, "Clip Volume", () => param.createSetValueAction(kf, true));
      }
      out.push({ clip: t.ref, db: a.db, level });
    }
    return { set: out };
  },

  async audio_fade(a) {
    // Volume keyframes: fade in at clip start or fade out at clip end.
    const { p, seq } = await seqCtx(a);
    const { clip, ref, kind } = await findClip(seq, a.clip);
    if (kind !== "audio") throw fail("audio_only", "Use an audio clip ref (A1:0)");
    const param = await volumeParam(clip);
    const dur = await toTick(a.duration || 1, seq);
    // Media-time span of the clip (keyframes live in media time).
    const start = await clip.getInPoint();
    const end = start.add(await clip.getDuration());
    const full = dbToLevel(a.db === undefined ? 0 : a.db);
    const silent = dbToLevel(-96);
    const pts = a.type === "out" ? [[end.subtract(dur), full], [end, silent]] : [[start, silent], [start.add(dur), full]];
    const kfs = pts.map(([t, v]) => {
      const k = param.createKeyframe(v);
      k.position = t;
      return k;
    });
    const animated = safe(() => param.isTimeVarying(), false);
    tx(p, "Audio Fade", () => [!animated && param.createSetTimeVaryingAction(true), ...kfs.map((k) => param.createAddKeyframeAction(k))]);
    return { clip: ref, fade: a.type || "in", seconds: round(dur.seconds) };
  },

  async audio_probe(a) {
    const { seq } = await seqCtx(a);
    const { clip, ref } = await findClip(seq, a.clip);
    const param = await volumeParam(clip);
    const v = await paramValueAt(param, a.time !== undefined ? await kfTime(clip, a.time, seq) : await clip.getInPoint());
    return { clip: ref, levelRaw: v, dbEstimate: typeof v === "number" ? round(levelToDb(v)) : null };
  },

  // ── markers ──
  async list_markers(a) {
    const p = await project();
    const owner = a.item ? ppro.ClipProjectItem.cast(await findItem(p, a.item)) : await findSequence(p, a.sequence);
    const seq = a.item ? null : owner;
    const m = await ppro.Markers.getMarkers(owner);
    const out = [];
    for (const k of m.getMarkers()) {
      out.push({
        guid: k.guid.toString(),
        name: k.getName(),
        comments: k.getComments(),
        start: await timeOut(k.getStart(), seq),
        duration: round(k.getDuration().seconds),
        type: k.getType(),
        colorIndex: k.getColorIndex(),
      });
    }
    return { count: out.length, markers: out };
  },

  async add_marker(a) {
    const p = await project();
    const owner = a.item ? ppro.ClipProjectItem.cast(await findItem(p, a.item)) : await findSequence(p, a.sequence);
    const seq = a.item ? null : owner;
    const m = await ppro.Markers.getMarkers(owner);
    const t = a.time !== undefined ? await toTick(a.time, seq) : seq ? await seq.getPlayerPosition() : ppro.TickTime.TIME_ZERO;
    const d = a.duration !== undefined ? await toTick(a.duration, seq) : ppro.TickTime.TIME_ZERO;
    const type = { comment: ppro.Marker.MARKER_TYPE_COMMENT, chapter: ppro.Marker.MARKER_TYPE_CHAPTER, weblink: ppro.Marker.MARKER_TYPE_WEBLINK }[a.type || "comment"] || ppro.Marker.MARKER_TYPE_COMMENT;
    tx(p, "Add Marker", () => m.createAddMarkerAction(a.name || "", type, t, d, a.comments || ""));
    if (a.colorIndex !== undefined) {
      const m2 = await ppro.Markers.getMarkers(owner);
      const added = m2.getMarkers().find((k) => k.getStart().ticks === t.ticks && k.getName() === (a.name || ""));
      if (added) tx(p, "Marker Color", () => added.createSetColorByIndexAction(Number(a.colorIndex)));
    }
    return { added: a.name || "", at: await timeOut(t, seq) };
  },

  async remove_marker(a) {
    const p = await project();
    const owner = a.item ? ppro.ClipProjectItem.cast(await findItem(p, a.item)) : await findSequence(p, a.sequence);
    const m = await ppro.Markers.getMarkers(owner);
    const all = m.getMarkers();
    const targets = a.all ? all : all.filter((k) => k.guid.toString() === a.guid || (a.name !== undefined && k.getName() === a.name));
    if (!targets.length) throw fail("marker_not_found", "No matching marker (pass guid, name, or all:true)");
    tx(p, "Remove Marker", () => targets.map((k) => m.createRemoveMarkerAction(k)));
    return { removed: targets.length };
  },

  // ── preview & export ──
  async export_frame(a) {
    const { seq } = await seqCtx(a);
    const t = a.time !== undefined ? await toTick(a.time, seq) : await seq.getPlayerPosition();
    const size = await seq.getFrameSize();
    let w = Number(a.width || 0);
    let h = Number(a.height || 0);
    if (!w && !h) w = Math.min(size.width, 1280);
    if (w && !h) h = Math.round((size.height * w) / size.width);
    if (h && !w) w = Math.round((size.width * h) / size.height);
    const fmt = (a.format || "png").toLowerCase();
    const dir = a.dir;
    const base = a.name || `frame_${Math.round(t.seconds * 1000)}ms`;
    if (!dir) throw fail("bad_args", "dir is required");
    const ok = await ppro.Exporter.exportSequenceFrame(seq, t, `${base}.${fmt}`, dir, w, h);
    if (!ok) throw fail("export_failed", `exportSequenceFrame failed (dir ${dir})`);
    // Some builds append the extension again ("x.png.png"); report whichever file exists.
    const sep = dir.includes("\\") ? "\\" : "/";
    const stem = `${dir.replace(/[\\/]$/, "")}${sep}${base}.${fmt}`;
    let path = stem;
    for (const cand of [stem, `${stem}.${fmt}`]) {
      try {
        await fs.lstat(cand);
        path = cand;
        break;
      } catch (_) {}
    }
    return { path, time: await timeOut(t, seq), width: w, height: h };
  },

  async export_frames(a) {
    const { seq } = await seqCtx(a);
    let times = a.times;
    if (!times) {
      const end = (await seq.getEndTime()).seconds;
      const n = Math.max(1, Math.min(Number(a.count || 8), 64));
      const from = a.start !== undefined ? (await toTick(a.start, seq)).seconds : 0;
      const to = a.end !== undefined ? (await toTick(a.end, seq)).seconds : end;
      times = Array.from({ length: n }, (_, i) => from + ((to - from) * (i + 0.5)) / n);
    }
    const frames = [];
    for (let i = 0; i < times.length; i++) {
      frames.push(await handlers.export_frame({ ...a, time: times[i], name: `${a.prefix || "frame"}_${String(i).padStart(3, "0")}` }));
    }
    return { frames };
  },

  async export_sequence(a) {
    const { seq } = await seqCtx(a);
    if (!a.output) throw fail("bad_args", "output (absolute file path on this machine) is required");
    if (!a.preset) throw fail("bad_args", "preset (.epr path) is required — premiere_list_export_presets finds them");
    const em = ppro.EncoderManager.getManager();
    const mode = { immediate: C.ExportType.IMMEDIATELY, ame: C.ExportType.QUEUE_TO_AME, queue: C.ExportType.QUEUE_TO_APP }[a.mode || "immediate"];
    if (a.mode === "ame" && !em.isAMEInstalled) throw fail("ame_missing", "Adobe Media Encoder isn't installed; use mode:\"immediate\".");
    watchEncoder();
    const ok = await em.exportSequence(seq, mode, a.output, a.preset, a.range !== "inout");
    if (a.mode === "ame" && a.startQueue !== false) await em.startBatchEncode().catch(() => {});
    return { started: ok, output: a.output, mode: a.mode || "immediate" };
  },

  async export_interchange(a) {
    const { seq } = await seqCtx(a);
    if (!a.output) throw fail("bad_args", "output path is required");
    const f = a.format || "otio";
    let ok;
    if (f === "otio") ok = await ppro.ProjectConverter.exportAsOpenTimelineIO(seq, a.output, true);
    else if (f === "fcpxml") ok = await ppro.ProjectConverter.exportAsFinalCutProXML(seq, a.output, true);
    else if (f === "aaf") ok = await ppro.ProjectConverter.exportAAF(seq, a.output);
    else throw fail("bad_args", "format must be otio, fcpxml, or aaf");
    return { exported: ok, format: f, output: a.output };
  },

  async source_monitor(a) {
    const p = await project();
    if (a.close) return { closed: await ppro.SourceMonitor.closeAllClips() };
    if (a.path) await ppro.SourceMonitor.openFilePath(a.path);
    else if (a.item) await ppro.SourceMonitor.openProjectItem(await findItem(p, a.item));
    if (a.time !== undefined) await ppro.SourceMonitor.setPosition(await toTick(a.time, null));
    if (a.play) await ppro.SourceMonitor.play(Number(a.speed || 1));
    const pi = await ppro.SourceMonitor.getProjectItem().catch(() => null);
    return { item: pi ? pi.name : null, position: round((await ppro.SourceMonitor.getPosition()).seconds) };
  },

  // ── transcripts ──
  async transcribe(a) {
    const p = await project();
    const clip = ppro.ClipProjectItem.cast(await findItem(p, a.item));
    return { started: await ppro.Transcript.transcribeClipProjectItem(clip, a.language ? { language: a.language } : undefined) };
  },

  async get_transcript(a) {
    const p = await project();
    const clip = ppro.ClipProjectItem.cast(await findItem(p, a.item));
    if (!ppro.Transcript.hasTranscript(clip)) throw fail("no_transcript", "No transcript yet — premiere_transcribe first (takes a while).");
    const json = await ppro.Transcript.exportToJSON(clip);
    return { transcript: safe(() => JSON.parse(json), json) };
  },

  // ── escape hatch ──
  async eval(a) {
    if (!a.code) throw fail("bad_args", "code is required: the body of an async function. In scope: ppro, h (helpers), args. `return` a JSON-able value.");
    const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
    const fn = new AsyncFunction("ppro", "h", "args", "require", a.code);
    const r = await fn(ppro, helpers, a.args || {}, require);
    return { value: toJsonSafe(r) };
  },
};

async function placeClip(a, mode) {
  const { p, seq } = await seqCtx(a);
  const item = await findItem(p, a.item);
  const t = a.time !== undefined ? await toTick(a.time, seq) : await seq.getPlayerPosition();
  const v = a.videoTrack !== undefined ? parseTrack(a.videoTrack).index : a.track ? parseTrack(a.track).index : 0;
  const au = a.audioTrack !== undefined ? parseTrack(a.audioTrack).index : v;
  const ed = ppro.SequenceEditor.getEditor(seq);
  tx(p, mode === "insert" ? "Insert Clip" : "Overwrite Clip", () =>
    mode === "insert" ? ed.createInsertProjectItemAction(item, t, v, au, !!a.limitShift) : ed.createOverwriteItemAction(item, t, v, au)
  );
  // Report the clip that now starts at t on the target video track (if any).
  let placed = null;
  try {
    const track = await getTrack(seq, "video", v);
    const clips = await trackClips(track);
    for (let i = 0; i < clips.length; i++) {
      if ((await clips[i].getStartTime()).ticks === t.ticks) {
        placed = await describeClip(clips[i], seq, "video", v, i, false);
        break;
      }
    }
  } catch (_) {}
  return { mode, item: item.name, at: await timeOut(t, seq), videoTrack: trackLabel("video", v), audioTrack: trackLabel("audio", au), placed };
}

async function clipStartingAt(track, t) {
  for (const c of await trackClips(track)) {
    if (Math.abs((await c.getStartTime()).ticksNumber - t.ticksNumber) < 1000) return c;
  }
  return null;
}

// ───────────────────────────── encoder events ─────────────────────────────

let encoderWatched = false;
function watchEncoder() {
  if (encoderWatched) return;
  encoderWatched = true;
  try {
    const em = ppro.EncoderManager.getManager();
    const E = ppro.EncoderManager;
    const on = (name, kind) =>
      ppro.EventManager.addEventListener(em, name, (ev) => send({ type: "event", kind, data: toJsonSafe(ev), at: Date.now() }));
    on(E.EVENT_RENDER_PROGRESS, "export_progress");
    on(E.EVENT_RENDER_COMPLETE, "export_complete");
    on(E.EVENT_RENDER_ERROR, "export_error");
    on(E.EVENT_RENDER_CANCEL, "export_cancel");
    on(E.EVENT_RENDER_QUEUE, "export_queued");
  } catch (e) {
    log("encoder events unavailable: " + e);
  }
}

function watchProject() {
  try {
    const PE = ppro.ProjectEvent;
    for (const [name, kind] of [[PE.EVENT_OPENED, "project_opened"], [PE.EVENT_ACTIVATED, "project_activated"], [PE.EVENT_DIRTY, "project_dirty"]]) {
      ppro.EventManager.addGlobalEventListener(name, async () => {
        send({ type: "event", kind, at: Date.now() });
        send({ type: "hello_update", fields: await helloInfo() });
      });
    }
  } catch (e) {
    log("project events unavailable: " + e);
  }
}

function toJsonSafe(v, depth = 0) {
  if (v === null || v === undefined) return v === undefined ? null : v;
  if (depth > 6) return "[…]";
  if (typeof v !== "object") return typeof v === "function" ? "[function]" : v;
  if (typeof v.ticks === "string" && typeof v.seconds === "number") return { seconds: v.seconds, ticks: v.ticks };
  if (Array.isArray(v)) return v.map((x) => toJsonSafe(x, depth + 1));
  const out = {};
  for (const k of Object.keys(v)) {
    try {
      out[k] = toJsonSafe(v[k], depth + 1);
    } catch (_) {}
  }
  if (!Object.keys(out).length && typeof v.toString === "function" && v.toString !== Object.prototype.toString) return v.toString();
  return out;
}

// Exposed to premiere_eval as `h`.
const helpers = { project, findSequence, findItem, findClip, getTrack, trackClips, toTick, timeOut, tx, componentsOf, findComponent, findParam, dbToLevel, levelToDb, allItems, parseTrack, handlers };

// ───────────────────────────── boot ─────────────────────────────

watchProject();
connect();