main
anastasia Premiere Pro Bridge 0.1.0: Rust bridge, UXP plugin, CEP helper, skills, macOS manifest 32fb583 10d ago
//! The verb catalog: one table that drives routing (which in-app runtime runs a verb),
//! timeouts, `premiere_describe`, and the `_hint` / `_next` / `related` / `pitfalls`
//! stamped on every response. The calling AI reads verb OUTPUT, not docs — so the
//! guidance lives here, next to the verb.

use serde_json::{json, Value};

#[derive(Clone, Copy, PartialEq)]
pub enum Runtime {
    /// Answered by the Rust bridge itself (no Premiere needed).
    Local,
    /// The UXP plugin (premierepro API) — the main surface.
    Uxp,
    /// The invisible CEP/ExtendScript helper (QE DOM) — razor, speed, tracks.
    Cep,
    /// CEP when connected (true razor), else the UXP emulation.
    PreferCep,
}
use Runtime::*;

pub struct Verb {
    pub name: &'static str,
    pub runtime: Runtime,
    pub timeout_sec: u64,
    pub summary: &'static str,
    pub args: &'static str,
    pub hint: &'static str,
    pub next: &'static [&'static str],
    pub pitfalls: &'static [&'static str],
}

const fn v(
    name: &'static str,
    runtime: Runtime,
    timeout_sec: u64,
    summary: &'static str,
    args: &'static str,
    hint: &'static str,
    next: &'static [&'static str],
    pitfalls: &'static [&'static str],
) -> Verb {
    Verb { name, runtime, timeout_sec, summary, args, hint, next, pitfalls }
}

const CLIP_REF: &str = "Clip refs are \"V1:0\" / \"A2:3\" = track:index (0-based, in time order) exactly as premiere_get_sequence lists them. Indexes shift after edits — re-read the sequence before the next edit.";

pub static VERBS: &[Verb] = &[
    // ── lifecycle (local) ──
    v("describe", Local, 5, "Full machine-readable verb catalog.", "{}", "Pick verbs from here; premiere_eval covers anything not listed.", &["premiere_status"], &[]),
    v("status", Local, 15, "Bridge, Premiere, and plugin connection status plus a live ping.", "{}",
      "If pluginConnected is false follow the _hint. When connected, premiere_get_project / premiere_get_sequence show what's open.",
      &["premiere_get_project", "premiere_get_sequence"], &["status is cheap but pings Premiere; for a no-side-effect readiness check use premiere_readiness."]),
    v("readiness", Local, 5, "Read-only readiness: never launches or installs.", "{}",
      "state: ready | needs-plugin (→ premiere_install_plugin) | app-not-running (→ premiere_launch) | waiting-for-plugin (→ premiere_wait_for_plugin) | no-app.",
      &["premiere_install_plugin", "premiere_launch", "premiere_wait_for_plugin"], &[]),
    v("install_plugin", Local, 180, "Install/refresh the Adom UXP plugin (and the invisible CEP helper) into Premiere. No user clicks.", "{force?:bool, cep?:bool=true}",
      "If Premiere was running during install, restart it (premiere_quit {save:true} then premiere_launch) so it loads the plugin. The UXP panel must be opened ONCE (Window > UXP Plugins > Adom Bridge); after that Premiere restores it every launch.",
      &["premiere_quit", "premiere_launch", "premiere_wait_for_plugin"], &["Installing over a running Premiere doesn't hot-load — restart it."]),
    v("plugin_info", Local, 5, "Installed vs bundled plugin versions and install locations.", "{}", "If installedVersion < bundledVersion run premiere_install_plugin.", &["premiere_install_plugin"], &[]),
    v("launch", Local, 180, "Start Premiere Pro (optionally opening a .prproj) and wait for the plugin.", "{project?:path, year?:2026, foreground?:true, wait?:true, timeoutSec?:120}",
      "When pluginConnected comes back true you're ready. If it times out, follow the _hint (usually: open the Adom Bridge panel once).",
      &["premiere_get_project", "premiere_open_project"], &["A fresh Premiere shows the Home screen; open or create a project before timeline verbs."]),
    v("quit", Local, 90, "Quit Premiere (optionally saving first).", "{save?:bool, force?:bool}", "Pass save:true to avoid losing edits; Premiere may otherwise show a save prompt.", &["premiere_launch"], &["Unsaved changes can block quit with a dialog; use save:true."]),
    v("wait_for_plugin", Local, 600, "Block until the UXP plugin connects.", "{timeoutSec?:90}", "Once connected, run your edit verbs.", &["premiere_get_project"], &[]),
    v("events", Local, 5, "Events pushed by the plugin (export progress/complete/error, project opened/dirty).", "{sinceSeq?:0}", "Poll with the returned lastSeq to see only new events.", &["premiere_export_sequence"], &[]),
    v("read_file_base64", Local, 30, "Return a desktop file (e.g. a preview PNG) as base64.", "{path, maxBytes?}", "Decode and view the image. For big files use adom-bridge pull_file instead.", &[], &[]),
    v("preview_frame", Local, 60, "Render the sequence frame at a time to PNG and return it inline (base64) — the fastest way to SEE the edit.", "{time?:seconds|timecode (default playhead), width?:960, sequence?, inline?:true}",
      "Look at the image before telling the user the edit is right. For an overview of the whole cut use premiere_contact_sheet.",
      &["premiere_contact_sheet", "premiere_get_sequence"], &["Frames render at the sequence's current state — re-preview after each edit.", "Premiere's frame exporter does NOT evaluate keyframes between two keys (a fade shows as before/after only) — use premiere_monitor_snapshot to check mid-animation frames."]),
    v("contact_sheet", Local, 300, "Export N evenly spaced frames across the sequence (or a range) — a storyboard of the cut.", "{count?:8, start?, end?, width?:480, inline?:true}",
      "Review the frames in order to judge pacing and continuity.", &["premiere_preview_frame"], &["Keep count <= 16 with inline:true to keep responses small."]),
    v("monitor_snapshot", Local, 60, "Read-only capture of Premiere's window (Program Monitor + timeline) via Adom Bridge — no clicks, no focus change. Optionally moves the playhead first.", "{time?, sequence?, settleMs?:700}",
      "Use when you need to see exactly what Premiere shows (animated opacity/position mid-keyframe, titles, overlays).", &["premiere_preview_frame"], &["Shows the whole window; the Program Monitor is top-center in the default Editing workspace."]),
    v("list_export_presets", Local, 30, "Find .epr export presets on this machine (Premiere + Media Encoder system and user presets).", "{search?:\"h.264\"}",
      "Pass a presetPath to premiere_export_sequence.", &["premiere_export_sequence"], &[]),

    // ── project ──
    v("ping", Uxp, 10, "Round-trip to the plugin.", "{}", "Plugin is alive.", &["premiere_get_project"], &[]),
    v("get_project", Uxp, 30, "Open project: name, path, sequences, bins.", "{}", "premiere_list_project_items shows media; premiere_get_sequence shows the timeline.", &["premiere_list_project_items", "premiere_get_sequence"], &[]),
    v("open_project", Uxp, 180, "Open a .prproj (dialogs suppressed).", "{path}", "Then premiere_get_project.", &["premiere_get_project", "premiere_list_sequences"], &["Paths are on the DESKTOP machine, not the container."]),
    v("new_project", Uxp, 60, "Create a new .prproj.", "{path}", "A new project has no sequence: premiere_import_media then premiere_create_sequence {fromItems:[…]}.", &["premiere_import_media", "premiere_create_sequence"], &["The folder must exist."]),
    v("save_project", Uxp, 120, "Save (or Save As with path).", "{path?}", "Saved.", &[], &[]),
    v("close_project", Uxp, 60, "Close the project.", "{save?:bool}", "", &["premiere_open_project"], &["Without save:true unsaved edits are discarded."]),

    // ── project panel ──
    v("list_project_items", Uxp, 60, "List bins/clips/sequences in the Project panel with ids, bins, media paths, durations.", "{bin?, type?:clip|bin|…, search?, limit?:500, details?:true}",
      "Use an item's id (or exact name) as `item` in insert/overwrite/append.", &["premiere_insert_clip", "premiere_append_clip"], &[]),
    v("import_media", Uxp, 300, "Import files (video/audio/stills/image sequences) into the project.", "{paths:[abs desktop paths], bin?, asNumberedStills?}",
      "The returned items[].id are ready for premiere_append_clip / premiere_insert_clip. Files in the container must first be sent with adom-bridge send_files.",
      &["premiere_append_clip", "premiere_create_sequence"], &["Paths must exist on the desktop machine."]),
    v("create_bin", Uxp, 30, "Create a bin.", "{name, parent?}", "", &["premiere_move_item"], &[]),
    v("move_item", Uxp, 30, "Move a project item into a bin.", "{item, bin}", "", &[], &[]),
    v("rename_item", Uxp, 30, "Rename a project item.", "{item, name}", "", &[], &[]),
    v("delete_item", Uxp, 30, "Remove an item from the project.", "{item}", "", &[], &["Clips using it on timelines go offline/empty — check first."]),
    v("set_item_color", Uxp, 30, "Set an item's label color.", "{item, colorIndex:0-15}", "", &[], &[]),
    v("set_source_in_out", Uxp, 30, "Set/clear a source clip's in/out points (what gets inserted).", "{item, in?, out?, clear?}", "Then insert/overwrite uses just that range.", &["premiere_insert_clip"], &[]),
    v("create_subclip", Uxp, 30, "Make a subclip from a source range.", "{item, start, end, name?, video?, audio?}", "", &["premiere_list_project_items"], &[]),
    v("get_metadata", Uxp, 30, "Project + XMP metadata for an item.", "{item, xmp?:bool}", "", &[], &[]),
    v("set_xmp_metadata", Uxp, 30, "Write XMP metadata for an item.", "{item, xmp}", "", &[], &[]),
    v("transcribe", Uxp, 60, "Start Speech-to-Text on a clip.", "{item, language?:\"en-us\"}", "Transcription runs in the background; poll premiere_get_transcript.", &["premiere_get_transcript"], &[]),
    v("get_transcript", Uxp, 60, "Get a clip's transcript JSON (words + timings).", "{item}", "Use word timings to find cut points.", &["premiere_razor", "premiere_add_marker"], &[]),

    // ── sequences ──
    v("list_sequences", Uxp, 30, "All sequences with guid, duration, which is active.", "{}", "", &["premiere_set_active_sequence", "premiere_get_sequence"], &[]),
    v("set_active_sequence", Uxp, 30, "Open + activate a sequence.", "{sequence: name|guid}", "", &["premiere_get_sequence"], &[]),
    v("create_sequence", Uxp, 60, "New sequence — from clips (matches their settings), a preset, or defaults.", "{name, fromItems?:[item…], presetPath?, bin?}",
      "fromItems is the best way to get matching resolution/fps.", &["premiere_get_sequence", "premiere_append_clip"], &[]),
    v("duplicate_sequence", Uxp, 30, "Clone a sequence.", "{sequence?}", "", &["premiere_list_sequences"], &[]),
    v("delete_sequence", Uxp, 30, "Delete a sequence.", "{sequence}", "", &[], &[]),
    v("nest_selection", Uxp, 30, "Nest the selected clips into a new sequence.", "{sequence?}", "Select first with premiere_select_clips.", &["premiere_select_clips"], &[]),
    v("get_sequence", Uxp, 60, "THE timeline view: every track and clip with refs, start/end, source in/out, effects, media.", "{sequence?, detail?:true}",
      "Use the clips' `ref` values in edit verbs. Re-read after every edit — indexes shift.", &["premiere_preview_frame", "premiere_trim_clip", "premiere_move_clip"], &[CLIP_REF]),
    v("get_sequence_settings", Uxp, 30, "Frame size, fps, PAR, audio settings.", "{sequence?}", "", &["premiere_set_sequence_settings"], &[]),
    v("set_sequence_settings", Uxp, 30, "Change frame size / fps / render quality.", "{sequence?, width?, height?, fps?, maxBitDepth?, maxRenderQuality?}", "", &[], &[]),
    v("set_playhead", Uxp, 15, "Move the playhead (CTI).", "{time, sequence?}", "premiere_preview_frame with no time shows this frame.", &["premiere_preview_frame"], &[]),
    v("set_in_out", Uxp, 15, "Set sequence in/out (and optionally the work area).", "{in?, out?, workArea?:bool, sequence?}", "export_sequence {range:\"inout\"} renders just this.", &["premiere_export_sequence"], &[]),

    // ── editing ──
    v("insert_clip", Uxp, 60, "Insert a project item at a time (ripples later clips right).", "{item, time?(default playhead), track?|videoTrack?:\"V1\", audioTrack?:\"A1\", limitShift?}",
      "Check placement with premiere_get_sequence, then premiere_preview_frame.", &["premiere_get_sequence", "premiere_preview_frame"], &["Insert shifts everything after `time` on the targeted tracks."]),
    v("overwrite_clip", Uxp, 60, "Overwrite a project item onto the timeline at a time.", "{item, time?, track?|videoTrack?, audioTrack?}", "", &["premiere_get_sequence"], &["Replaces whatever was there."]),
    v("append_clip", Uxp, 60, "Place a project item at the end of a track.", "{item, track?:\"V1\", audioTrack?}", "Repeat to build a rough cut in order.", &["premiere_get_sequence", "premiere_contact_sheet"], &[]),
    v("move_clip", Uxp, 30, "Move clip(s) to a new start time or by an offset.", "{clip|clips, to?|by?, sequence?}", "", &["premiere_get_sequence"], &[CLIP_REF, "Moving onto another clip overwrites it."]),
    v("trim_clip", Uxp, 30, "Trim a clip: set start/end on the timeline, or trimStart/trimEnd by seconds, or source in/out.", "{clip, start?, end?, trimStart?, trimEnd?, inPoint?, outPoint?}",
      "Trimming leaves a gap; premiere_remove_gap closes it.", &["premiere_remove_gap", "premiere_get_sequence"], &[CLIP_REF]),
    v("remove_clips", Uxp, 30, "Delete clips, optionally ripple-deleting (closing the gap).", "{clip|clips, ripple?:false}", "", &["premiere_get_sequence"], &[CLIP_REF, "Linked audio isn't removed unless you include its A-track ref too."]),
    v("remove_gap", Uxp, 30, "Close the gap before a clip (shift it and later clips on that track left).", "{clip}", "", &["premiere_get_sequence"], &["Only that track moves — do the matching A-track too to keep sync."]),
    v("razor", PreferCep, 60, "Cut clips at a time (all tracks, or the ones you list).", "{time, tracks?:[\"V1\",\"A1\"], sequence?}",
      "After cutting, premiere_get_sequence shows the two halves; remove_clips {ripple:true} the unwanted piece.", &["premiere_get_sequence", "premiere_remove_clips"],
      &["Uses the CEP helper's true razor when it's connected; otherwise a trim+clone emulation (method:\"uxp-emulated\") — verify with premiere_get_sequence.", "CEP razor acts on the ACTIVE sequence."]),
    v("set_clip_enabled", Uxp, 30, "Enable/disable clip(s).", "{clip|clips, enabled:bool}", "", &[], &[]),
    v("rename_clip", Uxp, 30, "Rename a timeline clip.", "{clip, name}", "", &[], &[]),
    v("duplicate_clip", Uxp, 30, "Clone a timeline clip by a time offset and/or onto another track.", "{clip, offset?:seconds, videoTrackOffset?, audioTrackOffset?, insert?}", "", &["premiere_get_sequence"], &[]),
    v("select_clips", Uxp, 15, "Select clips in the timeline.", "{clip|clips}", "", &["premiere_nest_selection", "premiere_scene_edit_detection"], &[]),
    v("get_selection", Uxp, 15, "What's selected in the timeline.", "{}", "", &[], &[]),
    v("clear_selection", Uxp, 15, "Deselect all.", "{}", "", &[], &[]),
    v("scene_edit_detection", Uxp, 120, "Auto-detect scene cuts in clips (apply cuts, markers, or subclips).", "{clip|clips, operation?:cut|marker|subclip}", "Runs async in Premiere; re-read the sequence in a few seconds.", &["premiere_get_sequence"], &[]),
    v("set_speed", Cep, 60, "Change clip speed (and reverse / ripple / keep pitch).", "{clip:\"V1:0\", speed:0.5|2.0, reverse?, ripple?, maintainPitch?:true}",
      "Needs the CEP helper. Re-read the sequence: duration changes.", &["premiere_get_sequence"], &["Acts on the ACTIVE sequence."]),
    v("add_tracks", Cep, 30, "Add video and/or audio tracks.", "{video?:n, audio?:n, audioType?:stereo|mono|5.1}", "", &["premiere_get_sequence"], &["Needs the CEP helper; acts on the active sequence."]),
    v("remove_track", Cep, 30, "Delete a track.", "{track:\"V3\"}", "", &[], &["Needs the CEP helper."]),
    v("lock_track", Cep, 15, "Lock/unlock a track.", "{track, locked?:true}", "", &[], &["Needs the CEP helper."]),
    v("set_track_target", Cep, 15, "Target/untarget a track for edits.", "{track, targeted?:true}", "", &[], &["Needs the CEP helper."]),
    v("set_track_mute", Uxp, 15, "Mute/unmute a track.", "{track:\"A2\", mute?:true}", "", &[], &[]),
    v("rename_track", Uxp, 15, "Rename a track.", "{track, name}", "", &[], &[]),

    // ── effects ──
    v("list_effects", Uxp, 30, "Available video effects, audio effects, or video transitions.", "{kind?:video|audio|transition, search?}", "Use matchName (video/transition) or displayName (audio) in add_effect / add_transition.", &["premiere_add_effect", "premiere_add_transition"], &[]),
    v("get_clip_effects", Uxp, 30, "A clip's components (Motion, Opacity, Volume, added effects) with every param's value and keyframes.", "{clip, time?}",
      "Use component + param names from here in premiere_set_param / premiere_add_keyframes.", &["premiere_set_param", "premiere_add_keyframes"], &[CLIP_REF]),
    v("add_effect", Uxp, 30, "Apply a video or audio effect to clip(s).", "{clip|clips, matchName?|name?, index?}", "Then premiere_get_clip_effects to see its params.", &["premiere_get_clip_effects", "premiere_set_param"], &["Audio effects need an A-track clip; video effects a V-track clip."]),
    v("remove_effect", Uxp, 30, "Remove a component from a clip.", "{clip, component: index|name|matchName}", "", &[], &[]),
    v("set_param", Uxp, 30, "Set an effect parameter (static, or a keyframe when `time` is given).", "{clip|clips, component, param, value (number|bool|[x,y]|{red,green,blue,alpha}), time?}",
      "preview_frame to check the look.", &["premiere_preview_frame", "premiere_get_clip_effects"], &["Read the current value with premiere_get_clip_effects first and keep the same shape/scale (e.g. Motion>Position is [x,y])."]),
    v("add_keyframes", Uxp, 30, "Animate a parameter with several keyframes.", "{clip, component, param, keyframes:[{time, value}], interpolation?:linear|hold|bezier}", "", &["premiere_preview_frame"], &["Keyframe times are sequence time."]),
    v("remove_keyframes", Uxp, 30, "Remove one keyframe, a range, or all (static again).", "{clip, component, param, time?|start&end?|all?}", "", &[], &[]),
    v("set_transform", Uxp, 30, "Convenience: Motion scale/position/rotation/anchor and Opacity in one call.", "{clip, scale?, position?:[x,y], rotation?, anchor?, opacity?, time?}", "", &["premiere_preview_frame"], &[]),
    v("add_transition", Uxp, 30, "Add a video transition at a clip's start or end.", "{clip|clips, name?:\"Cross Dissolve\"|matchName?, duration?:1, position?:end|start, singleSided?}",
      "", &["premiere_preview_frame"], &["Video only. For audio crossfades use premiere_audio_fade."]),
    v("remove_transition", Uxp, 30, "Remove a clip's start/end transition.", "{clip, position?:end|start}", "", &[], &[]),
    v("insert_mogrt", Uxp, 60, "Insert a Motion Graphics Template (titles/lower thirds) from a .mogrt path or a CC Library.", "{path?|library&element, time?, track?:\"V2\"}",
      "Then get_clip_effects on it to find its text param and set_param to change the text.", &["premiere_get_clip_effects", "premiere_set_param"], &[]),

    // ── audio ──
    v("set_clip_volume", Uxp, 30, "Set an audio clip's level in dB (or as a keyframe at a time).", "{clip|clips:\"A1:0\", db?:-6, level?:raw, time?}",
      "Use premiere_audio_probe to read the current level.", &["premiere_audio_probe", "premiere_audio_fade"], &["Volume lives on A-track clips, not the linked video clip."]),
    v("audio_fade", Uxp, 30, "Fade an audio clip in or out with Volume keyframes.", "{clip:\"A1:0\", type?:in|out, duration?:1, db?:0}", "", &["premiere_audio_probe"], &[]),
    v("audio_probe", Uxp, 15, "Read an audio clip's raw Volume level + dB estimate.", "{clip}", "", &["premiere_set_clip_volume"], &[]),

    // ── markers ──
    v("list_markers", Uxp, 30, "Sequence (or clip) markers.", "{sequence?|item?}", "", &["premiere_add_marker"], &[]),
    v("add_marker", Uxp, 30, "Add a marker (comment/chapter/weblink) with name, comments, duration, color.", "{time?, name?, comments?, duration?, type?, colorIndex?, item?}", "", &["premiere_list_markers"], &[]),
    v("remove_marker", Uxp, 30, "Remove markers by guid, name, or all.", "{guid?|name?|all?}", "", &[], &[]),

    // ── preview / export ──
    v("export_frame", Uxp, 60, "Write the frame at a time to an image file on the desktop.", "{time?, dir?, name?, format?:png|jpg|tif, width?, height?}", "premiere_preview_frame does this AND returns the image inline.", &["premiere_preview_frame", "premiere_read_file_base64"], &[]),
    v("export_frames", Uxp, 300, "Write several frames (times[] or count evenly spaced).", "{times?:[…], count?, start?, end?, dir?, width?}", "", &["premiere_contact_sheet"], &[]),
    v("export_sequence", Uxp, 300, "Render the sequence to a file with an .epr preset (immediately, or queued in Media Encoder).", "{output:abs path, preset:.epr path, mode?:immediate|ame|queue, range?:full|inout}",
      "mode:immediate renders inside Premiere and returns when the file is written; mode:ame queues to Media Encoder (watch premiere_events). Then adom-bridge pull_file the output to inspect it.", &["premiere_events", "premiere_list_export_presets"], &["Set premiere_set_in_out first and pass range:\"inout\" to render only part.", "Long immediate renders can exceed the HTTP budget — pass timeoutSec or use mode:\"ame\"."]),
    v("export_interchange", Uxp, 120, "Export the sequence as OpenTimelineIO, FCP XML, or AAF.", "{output, format?:otio|fcpxml|aaf}", "", &[], &[]),
    v("source_monitor", Uxp, 30, "Open a clip/file in the Source Monitor, seek, play, or close.", "{item?|path?, time?, play?, speed?, close?}", "", &["premiere_set_source_in_out"], &[]),

    // ── escape hatches ──
    v("eval", Uxp, 600, "Run arbitrary async JS inside Premiere's UXP plugin with the full premierepro API.", "{code: async-function body; in scope: ppro, h (helpers: findClip, toTick, tx, …), args, require}",
      "Use for anything no verb covers. Edits must go through h.tx(project, label, () => [actions]) (built synchronously).",
      &["premiere_get_sequence"], &["Return JSON-able values.", "create*Action calls only work inside h.tx (lockedAccess)."]),
    v("extendscript", Cep, 600, "Run ExtendScript (incl. the QE DOM) via the CEP helper.", "{code: function body (ES3), args?}", "For legacy-only features (QE). Prefer UXP verbs.", &[], &["ES3: no let/const/arrow; return plain values."]),
    v("export_frame_qe", Cep, 60, "Export a frame via QE (fallback when Exporter.exportSequenceFrame misbehaves).", "{time, path}", "", &["premiere_read_file_base64"], &[]),
];

pub fn find(bare: &str) -> Option<&'static Verb> {
    VERBS.iter().find(|v| v.name == bare && v.runtime != Local)
}

pub fn get(bare: &str) -> Option<&'static Verb> {
    VERBS.iter().find(|v| v.name == bare)
}

fn prefixed(names: &[&str]) -> Vec<String> {
    names.iter().map(|n| n.to_string()).collect()
}

/// Stamp guidance onto a response. Never overwrites a `_hint` the verb wrote itself.
pub fn decorate(bare: &str, out: &mut Value) {
    let Some(spec) = get(bare) else { return };
    let ok = out["success"].as_bool().unwrap_or(false);
    if !out.is_object() {
        return;
    }
    if ok {
        if out.get("_hint").is_none() && !spec.hint.is_empty() {
            out["_hint"] = json!(spec.hint);
        }
        if out.get("_next").is_none() && !spec.next.is_empty() {
            out["_next"] = json!(prefixed(spec.next));
        }
    } else if out.get("_hint").is_none() {
        out["_hint"] = json!(error_hint(out["errorCode"].as_str().unwrap_or("")));
    }
    if !spec.pitfalls.is_empty() && out.get("pitfalls").is_none() {
        out["pitfalls"] = json!(spec.pitfalls);
    }
    out["verb"] = json!(format!("premiere_{bare}"));
}

fn error_hint(code: &str) -> &'static str {
    match code {
        "no_project" => "No project open: premiere_open_project {path} or premiere_new_project {path}.",
        "no_sequence" => "No active sequence: premiere_list_sequences then premiere_set_active_sequence, or premiere_create_sequence.",
        "clip_not_found" | "bad_clip" => "Re-read the timeline with premiere_get_sequence and use a `ref` from it (\"V1:0\").",
        "item_not_found" => "premiere_list_project_items {search:\"…\"} shows item ids/names; import with premiere_import_media first.",
        "track_not_found" => "premiere_get_sequence lists the tracks; premiere_add_tracks adds more.",
        "component_not_found" | "param_not_found" => "premiere_get_clip_effects lists the clip's components and exact param names.",
        "effect_not_found" | "transition_not_found" => "premiere_list_effects {kind, search} lists exact names.",
        "transaction_failed" => "Premiere rejected the edit. Read the clip with premiere_get_sequence and check times are inside the media.",
        "bad_time" => "Times: seconds (12.5), timecode \"00:00:12:15\", \"300f\" (frames), or {ticks}.",
        "not_in_uxp" => "This plugin build lacks that verb — premiere_install_plugin {force:true} then restart Premiere, or use premiere_eval.",
        "cep_not_connected" => "The CEP helper isn't connected. premiere_install_plugin (sets it up), then restart Premiere.",
        "extendscript_error" => "ExtendScript threw — check the `error` and `line`; QE calls need an active sequence.",
        _ => "Read `error`. premiere_status shows connection health; premiere_eval lets you probe the API directly.",
    }
}

pub fn describe() -> Value {
    let verbs: Vec<Value> = VERBS
        .iter()
        .map(|v| {
            json!({
                "name": format!("premiere_{}", v.name),
                "summary": v.summary,
                "input": v.args,
                "runsIn": match v.runtime { Local => "bridge", Uxp => "uxp-plugin", Cep => "cep-helper", PreferCep => "cep-helper|uxp-plugin" },
                "timeoutSeconds": v.timeout_sec,
                "longRunning": v.timeout_sec > 60,
                "statusVerb": "premiere_status",
                "hint": v.hint,
                "related": prefixed(v.next),
                "pitfalls": v.pitfalls,
            })
        })
        .collect();
    let catalog = json!({
        "bridge": "premiere",
        "version": crate::VERSION,
        "verbCount": verbs.len(),
        "conventions": {
            "clipRefs": CLIP_REF,
            "times": "seconds (12.5) | timecode \"00:00:12:15\" | \"300f\" | {ticks}",
            "tracks": "\"V1\".. / \"A1\".. (1-based like the Premiere UI)",
            "paths": "All file paths are on the desktop running Premiere. Move files with adom-bridge send_files / pull_file.",
            "undo": "Every edit verb is ONE undo step in Premiere (Edit > Undo) labelled with the action.",
        },
        "verbs": verbs,
    });
    json!({"success": true, "data": catalog.clone(), "catalog": catalog})
}