premiere-pro-mcp 1.18.3 → 1.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.18.5] - 2026-09-28
10
+
11
+ ### Fixed
12
+
13
+ - `unnest_sequence` refuses before any change when the nest is trimmed, speed-changed or reversed, a target track is locked, or the target range already holds a clip. It now overwrites each nested clip's exact source range in place instead of inserting it, which pushed later clips down the track and ignored each clip's in and out. It reads every clip back afterwards and reports "The timeline changed … Use Undo" on any mismatch. Effects, keyframes, and transitions inside the nest are not carried over; the description says so. (#642)
14
+ - `move_clip_to_track` refuses before calling QE when either track is locked or the target range is occupied, finds the moved clip even when its node ID changes, and reports a copy left on the original track or a changed start, length, or in/out. It is now marked EXPERIMENTAL (QE) and rejects non-integer track numbers. (#642)
15
+ - `replace_clip` overwrites exactly the old clip's start and end instead of inserting the new item, which pushed later clips down the track and used the new item's full length. It verifies the span, that the old clip is gone, and that track clip counts are unchanged, and refuses on a locked track. The old clip's linked partner is left in place; the description says so. (#642)
16
+ - `export_frame_uxp` checks that the PNG exists after Premiere's exporter returns. It fails when no file was written, reports the real path when the host names the file differently (with or without the `.png` extension), and reports `committed_unverified` when UXP storage cannot check or the file already existed before the export. The panel's direct frame-export path now uses the same handler. (#642)
17
+ - `encode_media_uxp` results for jobs sent to Adobe Media Encoder carry `ameQueueStarted: "unknown"` and a note. Premiere 26.5.1 accepted UXP encodes into the Media Encoder queue without starting it. The tool does not start the queue through CEP; the note points to Media Encoder or `start_batch_encode`. (#642)
18
+ - `organize_project_items_uxp` remove verifies absence by walking the project tree instead of checking the Project panel selection first, which could still hold the removed item and turned a successful removal into `committed_unverified`. Remove now requires `project_item_id` and never falls back to the selection. (#642)
19
+ - `refresh_media` reads the interpreted frame rate before and after the refresh. When the refresh leaves an implausible rate (reported on stills as 29.97 becoming 2.75e-8), it restores the previous rate, reads it back, and reports `repaired: true`; if the restore does not stick, it fails with the manual fix. A real rate change from the source file is kept. (#642)
20
+ - `edit_timeline_uxp` MOGRT inserts (`insert_mogrt_path`, `insert_mogrt_library`) read the placement back instead of reporting the SequenceEditor return value. The result is `verified` when a returned item starts at the requested time, within one frame, on the requested video track. It fails with `UXP_VERIFICATION_FAILED` when nothing is there, and stays `committed_unverified` when the track cannot be read. Results include `sequenceId`, `requested`, and up to 16 `placements`. (#642)
21
+ - `set_sequence_pixel_aspect_ratio` compares the read-back ratio as a number, so a host that formats it as `1`, `1:1`, or `1.42222` no longer fails a correct update. A real mismatch reports the value Premiere read back. (#642)
22
+ - `play_source_monitor` fails with "No clip is loaded in the Source Monitor" instead of reporting a playback request that does nothing, names the loaded clip, and rejects a `speed` that is zero, not finite, or outside -64 to 64 before building a script. (#642)
23
+ - `create_bars_and_tone` takes an optional `bin_id`. The bin is resolved before anything is created, the new item is moved there, and its location is read back; the result is `committed_unverified` when the move cannot be confirmed. (#642)
24
+ - `get_unused_media` and `get_duplicate_media` page their output with `offset`, `limit` (1-500, default 100), and a case-insensitive `contains` name filter, and return `total`, `returned`, `truncated`, and `nextOffset`. Large projects no longer return unbounded lists. (#642)
25
+ - `get_duplicate_media` no longer groups different After Effects compositions imported from the same `.aep` or `.aepx` as duplicates. The same composition imported twice is still reported. (#642)
26
+ - `get_project_panel_metadata` caps the returned XML at `max_chars` (256-200000, default 20000) and reports `truncated` and `totalChars`. Truncated XML is marked as unsafe to pass back to `set_project_panel_metadata`. (#642)
27
+
28
+ ### Verification scope
29
+
30
+ - Automated checks validate package behavior. These changes follow live reports from Premiere 26.5.1 but have not been re-verified on a licensed Premiere host. The `unnest_sequence` and `replace_clip` overwrite path (`Track.overwriteClip` with source In/Out marks) has only been exercised against test fakes; its readback reports any host difference as a failure.
31
+
32
+ ## [1.18.4] - 2026-09-28
33
+
34
+ ### Fixed
35
+
36
+ - `import_transcript_uxp` refuses to import over an existing transcript (`UXP_TRANSCRIPT_OVERWRITE_REFUSED`). On Premiere 26.5.1 a failed import cleared an existing transcript that could not be restored. (#642)
37
+ - UXP commands larger than the panel's 64 KiB frame limit are refused by the server with `UXP_COMMAND_TOO_LARGE` instead of timing out after 30 seconds. (#642)
38
+ - `manage_metadata_uxp` update and field update fail with `UXP_METADATA_NOT_APPLIED` when Premiere commits the transaction but the metadata reads back unchanged, instead of reporting `updated: true`. (#642)
39
+ - UXP project Save As, create, and branch copies ask for `confirmOverwrite` only when the destination file exists. Premiere 26.5.1's `Project.isProject` reports true for any `.prproj` path. (#642)
40
+ - CEP tools that act on a timeline clip through the QE DOM no longer pass the DOM clip index to `qeTrack.getItemAt()`. QE counts gaps as track items, so on a track with a leading or intermediate gap these tools renamed, re-effected, or retimed the wrong clip. `rename_clip`, `remove_all_effects`, `set_frame_blend`, `set_time_interpolation`, `apply_effect`, `apply_audio_effect`, `color_correct`, `apply_lut`, `stabilize_clip`, `copy_effects_between_clips`, `batch_rename_clips`, and `get_qe_clip_info` now match the QE clip by timeline start and fail before any change when no clip matches. `copy_effects_between_clips` reports each effect as verified, committed_unverified, or failed from a readback of the target's components instead of silently dropping errors. `apply_effect` and `apply_audio_effect` also no longer emit a malformed error string that stopped the generated script from parsing. (#642)
41
+
42
+ ### Verification scope
43
+
44
+ - Automated checks validate package behavior. These changes follow live reports from Premiere 26.5.1 but have not been re-verified on a licensed Premiere host. The QE clip lookup change has not been verified on a licensed Premiere host either.
45
+
9
46
  ## [1.18.3] - 2026-09-28
10
47
 
11
48
  ### Security
package/README.md CHANGED
@@ -110,9 +110,9 @@ The current source exposes 384 core tools for supported workflow steps spanning
110
110
 
111
111
  <a id="latest-release"></a>
112
112
 
113
- ### Latest release: 1.18.2
113
+ ### Latest release: 1.18.4
114
114
 
115
- The published v1.18.2 npm artifact contains **384 core tools**, 382 in its default profile,
115
+ The published v1.18.4 npm artifact contains **384 core tools**, 382 in its default profile,
116
116
  and 478 with a compatible UXP connection. The development catalog above can include
117
117
  unreleased work. See the [versioned facts and package provenance](https://premiere-pro-mcp.com/facts/).
118
118
 
@@ -162,7 +162,7 @@ if the connection is unavailable.
162
162
  local Premiere processes. See the generated [supported action catalog](docs/supported-actions.md)
163
163
  for individual capability and verification contracts.
164
164
 
165
- See the [v1.18.2 release notes](https://github.com/leancoderkavy/premiere-pro-mcp/releases/tag/v1.18.2)
165
+ See the [v1.18.4 release notes](https://github.com/leancoderkavy/premiere-pro-mcp/releases/tag/v1.18.4)
166
166
  for complete details. Live installation in Premiere Pro still requires host verification.
167
167
 
168
168
  ### Current MCP protocol support
@@ -210,7 +210,7 @@ their bins, media rules, and organization rules before a facility uses one.
210
210
  > ### Install the published package (verify the name)
211
211
  >
212
212
  > ```bash
213
- > npm i -g premiere-pro-mcp@1.18.2
213
+ > npm i -g premiere-pro-mcp@1.18.4
214
214
  > ```
215
215
  >
216
216
  > This repository publishes only **`premiere-pro-mcp`**. A differently named package (`adobe-premiere-pro-mcp`) may also declare a `premiere-pro-mcp` executable. Before configuring a client, confirm:
@@ -218,7 +218,7 @@ their bins, media rules, and organization rules before a facility uses one.
218
218
  > | Check | Expected |
219
219
  > | --- | --- |
220
220
  > | Package name | `premiere-pro-mcp` (not `adobe-premiere-pro-mcp`) |
221
- > | Version | `1.18.2` |
221
+ > | Version | `1.18.4` |
222
222
  > | Homepage / repo | https://premiere-pro-mcp.com/ · https://github.com/leancoderkavy/premiere-pro-mcp |
223
223
  >
224
224
  > ```bash
@@ -231,9 +231,9 @@ their bins, media rules, and organization rules before a facility uses one.
231
231
 
232
232
  ### Easiest supported path: Claude Desktop
233
233
 
234
- 1. Download the current [Claude Desktop bundle (`.mcpb`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.18.2/premiere-pro-mcp-1.18.2.mcpb).
234
+ 1. Download the current [Claude Desktop bundle (`.mcpb`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.18.4/premiere-pro-mcp-1.18.4.mcpb).
235
235
  2. In Claude Desktop, open **Settings > Extensions > Advanced settings > Install Extension**, select the downloaded bundle, and restart Claude Desktop.
236
- 3. Download the separate [signed Premiere connector (`.zxp`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.18.2/MCPBridgeCEP.zxp). Open it with your trusted ZXP installer. If your computer has no ZXP installer, use the npm connector installer in **Advanced setup** below.
236
+ 3. Download the separate [signed Premiere connector (`.zxp`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.18.4/MCPBridgeCEP.zxp). Open it with your trusted ZXP installer. If your computer has no ZXP installer, use the npm connector installer in **Advanced setup** below.
237
237
  4. Restart Premiere, open a project, then open **Window > Extensions > MCP for Adobe Premiere Pro**.
238
238
  5. In Claude, enter: `Safely check my Premiere connection with verify_premiere_connection. Make no changes.`
239
239
 
@@ -533,11 +533,11 @@ From a clone of this repository:
533
533
  ```bash
534
534
  codex plugin marketplace add .
535
535
  codex plugin add premiere-pro@premiere-pro-mcp
536
- npx -y premiere-pro-mcp@1.18.2 --install-cep
536
+ npx -y premiere-pro-mcp@1.18.4 --install-cep
537
537
  ```
538
538
 
539
539
  Restart Premiere Pro and start a new Codex session after installation. The plugin
540
- launches `premiere-pro-mcp@1.18.2` through `npx`; the separate CEP installation is
540
+ launches `premiere-pro-mcp@1.18.4` through `npx`; the separate CEP installation is
541
541
  required because the MCP server communicates with the running Premiere host through
542
542
  the local bridge.
543
543
 
@@ -575,7 +575,7 @@ For Claude Code, add this repository as a marketplace and install the plugin:
575
575
  Then install the Premiere bridge and start a new Claude Code session:
576
576
 
577
577
  ```bash
578
- npx -y premiere-pro-mcp@1.18.2 --install-cep
578
+ npx -y premiere-pro-mcp@1.18.4 --install-cep
579
579
  ```
580
580
 
581
581
  The Claude Code package lives in
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.aftereffects.bridge" ExtensionBundleVersion="1.18.3" ExtensionBundleName="MCP for Adobe After Effects">
2
+ <ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.aftereffects.bridge" ExtensionBundleVersion="1.18.5" ExtensionBundleName="MCP for Adobe After Effects">
3
3
  <ExtensionList>
4
- <Extension Id="com.mcp.aftereffects.bridge.panel" Version="1.18.3"/>
4
+ <Extension Id="com.mcp.aftereffects.bridge.panel" Version="1.18.5"/>
5
5
  </ExtensionList>
6
6
  <ExecutionEnvironment>
7
7
  <HostList>
Binary file
@@ -1,8 +1,8 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.premiere.bridge" ExtensionBundleVersion="1.18.3" ExtensionBundleName="MCP for Adobe Premiere Pro">
2
+ <ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.premiere.bridge" ExtensionBundleVersion="1.18.5" ExtensionBundleName="MCP for Adobe Premiere Pro">
3
3
  <ExtensionList>
4
- <Extension Id="com.mcp.premiere.bridge.panel" Version="1.18.3"/>
5
- <Extension Id="com.mcp.premiere.bridge.headless" Version="1.18.3"/>
4
+ <Extension Id="com.mcp.premiere.bridge.panel" Version="1.18.5"/>
5
+ <Extension Id="com.mcp.premiere.bridge.headless" Version="1.18.5"/>
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -81,7 +81,7 @@
81
81
  <section class="update-section" aria-live="polite">
82
82
  <div class="update-copy">
83
83
  <span class="section-label">MCP updates</span>
84
- <strong id="updateTitle">Version 1.18.3</strong>
84
+ <strong id="updateTitle">Version 1.18.5</strong>
85
85
  <span id="updateDetail">Checking the global MCP server and connector release…</span>
86
86
  </div>
87
87
  <button id="btnUpdate" class="button button-update" onclick="handleUpdateClick()" type="button" aria-describedby="updateDetail" disabled>
@@ -7,7 +7,7 @@
7
7
  })(this, function () {
8
8
  "use strict";
9
9
 
10
- var CURRENT_VERSION = "1.18.3";
10
+ var CURRENT_VERSION = "1.18.5";
11
11
  var PACKAGE_NAME = "premiere-pro-mcp";
12
12
  var LATEST_PACKAGE_API = "https://registry.npmjs.org/" + PACKAGE_NAME;
13
13
  var LATEST_RELEASE_API =
@@ -72,6 +72,70 @@ function __writeClipSpan(item, startTicks, endTicks) {
72
72
  }
73
73
  }
74
74
 
75
+ // Tick comparisons for values that round-trip through seconds (ProjectItem
76
+ // marks take seconds). Far below one frame at any frame rate.
77
+ var __TICK_MATCH_TOL = 16;
78
+
79
+ function __isTrackLocked(track) {
80
+ try {
81
+ if (track && typeof track.isLocked === "function") return !!track.isLocked();
82
+ } catch (eLocked) {}
83
+ return false;
84
+ }
85
+
86
+ // Place exactly [inTicks, outTicks) of a project item on ONE track at
87
+ // startTicks without rippling. Track.overwriteClip places the item's In/Out
88
+ // range, so set those marks first and restore the item's own marks afterwards.
89
+ // mediaType: 1 = video, 2 = audio. Callers must prove the destination range is
90
+ // empty first; overwrite never shifts neighbours but would replace them.
91
+ // Returns { ok, attempted, marksRestored, error }. attempted=true means the
92
+ // overwrite call ran (or threw), so the timeline may have changed.
93
+ function __overwriteRangeOnTrack(track, item, startTicks, inTicks, outTicks, mediaType) {
94
+ if (!track || typeof track.overwriteClip !== "function") {
95
+ return { ok: false, attempted: false, marksRestored: true, error: "Track.overwriteClip is unavailable on this Premiere build" };
96
+ }
97
+ if (!item || typeof item.getInPoint !== "function" || typeof item.getOutPoint !== "function" ||
98
+ typeof item.setInPoint !== "function" || typeof item.setOutPoint !== "function") {
99
+ return { ok: false, attempted: false, marksRestored: true, error: "Project item In/Out marks cannot be set on this Premiere build" };
100
+ }
101
+ var originalIn = null;
102
+ var originalOut = null;
103
+ try {
104
+ originalIn = String(item.getInPoint(mediaType).ticks);
105
+ originalOut = String(item.getOutPoint(mediaType).ticks);
106
+ } catch (eMarks) {
107
+ return { ok: false, attempted: false, marksRestored: true, error: "Could not read the project item's In/Out marks" };
108
+ }
109
+ function setMarks(wantIn, wantOut) {
110
+ var currentOut = parseFloat(item.getOutPoint(mediaType).ticks);
111
+ if (parseFloat(wantIn) >= currentOut) {
112
+ item.setOutPoint(__ticksToSeconds(wantOut), mediaType);
113
+ item.setInPoint(__ticksToSeconds(wantIn), mediaType);
114
+ } else {
115
+ item.setInPoint(__ticksToSeconds(wantIn), mediaType);
116
+ item.setOutPoint(__ticksToSeconds(wantOut), mediaType);
117
+ }
118
+ return Math.abs(parseFloat(item.getInPoint(mediaType).ticks) - parseFloat(wantIn)) <= __TICK_MATCH_TOL &&
119
+ Math.abs(parseFloat(item.getOutPoint(mediaType).ticks) - parseFloat(wantOut)) <= __TICK_MATCH_TOL;
120
+ }
121
+ function restore() {
122
+ try { return setMarks(originalIn, originalOut); } catch (eRestore) { return false; }
123
+ }
124
+ var applied = false;
125
+ try { applied = setMarks(inTicks, outTicks); } catch (eSet) { applied = false; }
126
+ if (!applied) {
127
+ return { ok: false, attempted: false, marksRestored: restore(), error: "Premiere did not apply the source In/Out range to project item " + item.name };
128
+ }
129
+ var at = new Time();
130
+ at.ticks = String(startTicks);
131
+ try {
132
+ track.overwriteClip(item, at);
133
+ } catch (eOverwrite) {
134
+ return { ok: false, attempted: true, marksRestored: restore(), error: "Track.overwriteClip rejected project item " + item.name + ": " + eOverwrite.toString() };
135
+ }
136
+ return { ok: true, attempted: true, marksRestored: restore(), error: "" };
137
+ }
138
+
75
139
  function __ticksToTimecode(ticks, fps) {
76
140
  var totalSeconds = __ticksToSeconds(ticks);
77
141
  var hours = Math.floor(totalSeconds / 3600);
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
+ /** Must match MAX_COMMAND_BYTES in uxp-plugin/protocol.cjs. */
3
+ export declare const MAX_UXP_COMMAND_BYTES: number;
2
4
  export interface UxpBridgeOptions {
3
5
  token: string;
4
6
  port?: number;
@@ -5,6 +5,8 @@ import { WebSocket, WebSocketServer } from "ws";
5
5
  import { assertNoSymlinkedPaths, SymlinkPathError } from "../security/path-guard.js";
6
6
  const LOOPBACK_HOST = "127.0.0.1";
7
7
  const SUPPORTED_PROTOCOLS = new Set([1, 2]);
8
+ /** Must match MAX_COMMAND_BYTES in uxp-plugin/protocol.cjs. */
9
+ export const MAX_UXP_COMMAND_BYTES = 64 * 1024;
8
10
  export class UxpBridgeError extends Error {
9
11
  code;
10
12
  constructor(code, message) {
@@ -142,19 +144,25 @@ export class UxpWebSocketBridge extends EventEmitter {
142
144
  }
143
145
  const requestTimeoutMs = Math.max(this.options.requestTimeoutMs, minimumTimeoutMs);
144
146
  const requestId = randomUUID();
147
+ const frame = JSON.stringify({
148
+ protocolVersion: hello.protocolVersion,
149
+ type: "command",
150
+ requestId,
151
+ command,
152
+ args,
153
+ });
154
+ // The panel drops frames over 64 KiB before it can read the requestId, so an
155
+ // oversized command would only surface as a timeout. Refuse it here instead.
156
+ if (Buffer.byteLength(frame, "utf8") > MAX_UXP_COMMAND_BYTES) {
157
+ throw new UxpBridgeError("UXP_COMMAND_TOO_LARGE", `UXP command '${command}' is larger than the panel's 64 KiB limit. Send less data (for example a narrower metadata update). No command was sent to Premiere.`);
158
+ }
145
159
  return new Promise((resolve, reject) => {
146
160
  const timer = setTimeout(() => {
147
161
  this.pending.delete(requestId);
148
162
  reject(new UxpBridgeError("UXP_TIMEOUT", `UXP command '${command}' timed out`));
149
163
  }, requestTimeoutMs);
150
164
  this.pending.set(requestId, { command, resolve, reject, timer });
151
- socket.send(JSON.stringify({
152
- protocolVersion: hello.protocolVersion,
153
- type: "command",
154
- requestId,
155
- command,
156
- args,
157
- }), (error) => {
165
+ socket.send(frame, (error) => {
158
166
  if (!error)
159
167
  return;
160
168
  const pending = this.pending.get(requestId);
@@ -515,7 +515,9 @@ export function getAdvancedTools(bridgeOptions) {
515
515
  },
516
516
  },
517
517
  move_clip_to_track: {
518
- description: "Move a clip to a different track. Uses QE DOM.",
518
+ description: "Move a clip to a different track of the same type, keeping its start, duration, and source in/out. EXPERIMENTAL: uses the undocumented QE DOM moveToTrack. " +
519
+ "Refuses without changing anything when the origin or destination track is locked or the destination range is occupied. " +
520
+ "Reads the timeline back: verified only when the clip is on the destination track with the same span and source range and is gone from the origin track; committed_unverified when the source range cannot be read; otherwise failure with Undo guidance.",
519
521
  parameters: {
520
522
  type: "object",
521
523
  properties: {
@@ -531,6 +533,9 @@ export function getAdvancedTools(bridgeOptions) {
531
533
  required: ["node_id", "target_track_index"],
532
534
  },
533
535
  handler: async (args) => {
536
+ if (!Number.isSafeInteger(args.target_track_index) || args.target_track_index < 0) {
537
+ return { success: false, error: "target_track_index must be a non-negative integer" };
538
+ }
534
539
  const nodeId = escapeForExtendScript(args.node_id);
535
540
  const script = buildToolScript(`
536
541
  app.enableQE();
@@ -549,6 +554,47 @@ export function getAdvancedTools(bridgeOptions) {
549
554
  return __result({ moved: false, verified: true, alreadyOnTrack: true, clipName: result.clip.name, trackIndex: result.trackIndex });
550
555
  }
551
556
 
557
+ // Preflight: refuse before any QE call when either track is locked or
558
+ // the destination range holds another clip (QE would overwrite it).
559
+ var originTrack = targetTracks[result.trackIndex];
560
+ var destTrack = targetTracks[${args.target_track_index}];
561
+ if (__isTrackLocked(originTrack)) {
562
+ return __error("Move refused; nothing was changed. Origin " + result.trackType + " track " + result.trackIndex + " is locked.");
563
+ }
564
+ if (__isTrackLocked(destTrack)) {
565
+ return __error("Move refused; nothing was changed. Destination " + result.trackType + " track ${args.target_track_index} is locked.");
566
+ }
567
+ var moveStart = parseFloat(result.clip.start.ticks);
568
+ var moveEnd = parseFloat(result.clip.end.ticks);
569
+ for (var oi = 0; oi < destTrack.clips.numItems; oi++) {
570
+ var occupant = destTrack.clips[oi];
571
+ var occupantStart = parseFloat(occupant.start.ticks);
572
+ var occupantEnd = parseFloat(occupant.end.ticks);
573
+ if (occupantStart < moveEnd - __TICK_MATCH_TOL && occupantEnd > moveStart + __TICK_MATCH_TOL) {
574
+ return __error("Move refused; nothing was changed. The destination range " + __ticksToSeconds(moveStart) + "s-" + __ticksToSeconds(moveEnd) + "s on " + result.trackType + " track ${args.target_track_index} is already occupied by " + occupant.name + ", and moving there would overwrite it.");
575
+ }
576
+ }
577
+ var movedName = result.clip.name;
578
+ var movedSourceId = "";
579
+ try { movedSourceId = result.clip.projectItem ? String(result.clip.projectItem.nodeId) : ""; } catch (eMovedSource) {}
580
+
581
+ // Premiere can mint a new node ID when a clip changes track, so fall
582
+ // back to matching the same source item, name, and start on a track.
583
+ function findMovedOn(trackIndex) {
584
+ var track = targetTracks[trackIndex];
585
+ for (var fi = 0; fi < track.clips.numItems; fi++) {
586
+ var cand = track.clips[fi];
587
+ if (String(cand.nodeId) === "${nodeId}") return { clip: cand, trackIndex: trackIndex, trackType: result.trackType };
588
+ var candSource = "";
589
+ try { candSource = cand.projectItem ? String(cand.projectItem.nodeId) : ""; } catch (eCandSource) {}
590
+ if (movedSourceId && candSource === movedSourceId && cand.name === movedName &&
591
+ Math.abs(parseFloat(cand.start.ticks) - moveStart) <= __TICK_MATCH_TOL) {
592
+ return { clip: cand, trackIndex: trackIndex, trackType: result.trackType };
593
+ }
594
+ }
595
+ return null;
596
+ }
597
+
552
598
  var qeTrack = result.trackType === "video"
553
599
  ? qeSeq.getVideoTrackAt(result.trackIndex)
554
600
  : qeSeq.getAudioTrackAt(result.trackIndex);
@@ -586,41 +632,64 @@ export function getAdvancedTools(bridgeOptions) {
586
632
 
587
633
  // QE reports nothing useful on success, so confirm against the DOM.
588
634
  var after = __findClip("${nodeId}");
589
- if (!after) return __error("Clip ${nodeId} could not be found after the track move; the timeline may be in an unexpected state.");
635
+ if (!after) after = findMovedOn(${args.target_track_index});
636
+ if (!after) return __error("The timeline changed: clip " + movedName + " could not be found on its original or destination track after the track move. Use Undo and check the timeline.");
590
637
  if (after.trackIndex !== ${args.target_track_index}) {
638
+ if (findMovedOn(${args.target_track_index})) {
639
+ return __error("The timeline changed: Premiere placed a copy of " + movedName + " on track ${args.target_track_index} but the original is still on track " + after.trackIndex + ". Use Undo and move the clip in the Premiere UI.");
640
+ }
591
641
  return __error("Premiere accepted the moveToTrack call but the clip is still on track " + after.trackIndex + " rather than ${args.target_track_index}. Structural QE edits are known to no-op on some Premiere Pro 26.x installations (confirmed on 26.2.2).");
592
642
  }
643
+ if (findMovedOn(result.trackIndex)) {
644
+ return __error("The timeline changed: " + movedName + " now exists on both track " + result.trackIndex + " and track ${args.target_track_index}. Use Undo and move the clip in the Premiere UI.");
645
+ }
593
646
  // moveToTrack can rewrite end independently of start (#550). Re-assert
594
647
  // the original span before verifying, then fail closed if it did not hold.
595
648
  if (String(after.clip.start.ticks) !== beforeMoveStartTicks || String(after.clip.end.ticks) !== beforeMoveEndTicks) {
596
649
  try {
597
650
  __writeClipSpan(after.clip, beforeMoveStartTicks, beforeMoveEndTicks);
598
651
  } catch (spanErr) {
599
- return __error("Premiere changed the clip's timeline range during the track move and it could not be restored (" + spanErr.toString() + "). Use Undo and retry in the Premiere UI.");
652
+ return __error("The timeline changed: Premiere changed the clip's timeline range during the track move and it could not be restored (" + spanErr.toString() + "). Use Undo and retry in the Premiere UI.");
600
653
  }
601
- after = __findClip("${nodeId}");
602
- if (!after) return __error("Clip ${nodeId} could not be found after restoring its timeline range; the timeline may be in an unexpected state.");
654
+ var restored = __findClip("${nodeId}");
655
+ after = restored || findMovedOn(${args.target_track_index});
656
+ if (!after) return __error("The timeline changed: clip " + movedName + " could not be found after restoring its timeline range. Use Undo and check the timeline.");
603
657
  }
604
658
  var afterMoveStartTicks = String(after.clip.start.ticks);
605
659
  var afterMoveEndTicks = String(after.clip.end.ticks);
606
660
  if (parseFloat(afterMoveStartTicks) >= parseFloat(afterMoveEndTicks)) {
607
- return __error("Premiere left clip ${nodeId} with an inverted or empty timeline range after the track move. Use Undo and retry in the Premiere UI.");
661
+ return __error("The timeline changed: Premiere left clip " + movedName + " with an inverted or empty timeline range after the track move. Use Undo and retry in the Premiere UI.");
662
+ }
663
+ if (Math.abs(parseFloat(afterMoveStartTicks) - parseFloat(beforeMoveStartTicks)) > 1) {
664
+ return __error("The timeline changed: Premiere moved the clip to " + __ticksToSeconds(afterMoveStartTicks) + "s instead of keeping its start at " + __ticksToSeconds(beforeMoveStartTicks) + "s. Use Undo and retry in the Premiere UI.");
608
665
  }
609
666
  if (Math.abs((parseFloat(afterMoveEndTicks) - parseFloat(afterMoveStartTicks)) - spanTicks) > 1) {
610
- return __error("Premiere changed the clip duration during the track move. Use Undo and retry in the Premiere UI.");
667
+ return __error("The timeline changed: Premiere changed the clip duration during the track move. Use Undo and retry in the Premiere UI.");
611
668
  }
612
- if (String(after.clip.inPoint.ticks) !== beforeMoveInTicks || String(after.clip.outPoint.ticks) !== beforeMoveOutTicks) {
613
- return __error("Premiere changed the clip's source in/out points during the track move. Use Undo and retry in the Premiere UI.");
669
+ var sourceReadable = true;
670
+ var afterInTicks = "";
671
+ var afterOutTicks = "";
672
+ try {
673
+ afterInTicks = String(after.clip.inPoint.ticks);
674
+ afterOutTicks = String(after.clip.outPoint.ticks);
675
+ } catch (eSourceRead) {
676
+ sourceReadable = false;
677
+ }
678
+ if (sourceReadable && (afterInTicks !== beforeMoveInTicks || afterOutTicks !== beforeMoveOutTicks)) {
679
+ return __error("The timeline changed: Premiere changed the clip's source in/out points during the track move. Use Undo and retry in the Premiere UI.");
614
680
  }
615
681
 
616
- return __result({
682
+ var moveResult = {
617
683
  moved: true,
618
- verified: true,
684
+ verified: sourceReadable,
685
+ outcome: sourceReadable ? "verified" : "committed_unverified",
619
686
  clipName: after.clip.name,
620
687
  newTrackIndex: after.trackIndex,
621
688
  startSeconds: __ticksToSeconds(afterMoveStartTicks),
622
689
  endSeconds: __ticksToSeconds(afterMoveEndTicks)
623
- });
690
+ };
691
+ if (!sourceReadable) moveResult.warning = "The clip is on the destination track with the same start and duration, but its source in/out points could not be read back.";
692
+ return __result(moveResult);
624
693
  `);
625
694
  return sendCommand(script, bridgeOptions);
626
695
  },
@@ -649,9 +718,11 @@ export function getAdvancedTools(bridgeOptions) {
649
718
  var qeTrack = result.trackType === "video"
650
719
  ? qeSeq.getVideoTrackAt(result.trackIndex)
651
720
  : qeSeq.getAudioTrackAt(result.trackIndex);
652
- var qeClip = qeTrack.getItemAt(result.clipIndex);
653
- if (!qeClip) return __error("QE clip not found");
654
-
721
+ if (!qeTrack) return __error("QE track not found; nothing was changed.");
722
+ // QE track items include gaps, so the DOM clip index is not a QE index.
723
+ var qeClip = __findQeClipByDomClip(qeTrack, result.clip);
724
+ if (!qeClip) return __error("Could not match the QE clip for " + result.clip.name + " by timeline start; nothing was changed.");
725
+
655
726
  qeClip.removeEffects();
656
727
  return __result({ removed: true, clipName: result.clip.name });
657
728
  `);
@@ -738,9 +809,11 @@ export function getAdvancedTools(bridgeOptions) {
738
809
  var qeTrack = result.trackType === "video"
739
810
  ? qeSeq.getVideoTrackAt(result.trackIndex)
740
811
  : qeSeq.getAudioTrackAt(result.trackIndex);
741
- var qeClip = qeTrack.getItemAt(result.clipIndex);
742
- if (!qeClip) return __error("QE clip not found");
743
-
812
+ if (!qeTrack) return __error("QE track not found; nothing was changed.");
813
+ // QE track items include gaps, so the DOM clip index is not a QE index.
814
+ var qeClip = __findQeClipByDomClip(qeTrack, result.clip);
815
+ if (!qeClip) return __error("Could not match the QE clip for " + result.clip.name + " by timeline start; nothing was changed.");
816
+
744
817
  qeClip.setFrameBlend(${args.enabled});
745
818
  return __result({ frameBlend: ${args.enabled}, clipName: result.clip.name });
746
819
  `);
@@ -775,9 +848,11 @@ export function getAdvancedTools(bridgeOptions) {
775
848
  var qeTrack = result.trackType === "video"
776
849
  ? qeSeq.getVideoTrackAt(result.trackIndex)
777
850
  : qeSeq.getAudioTrackAt(result.trackIndex);
778
- var qeClip = qeTrack.getItemAt(result.clipIndex);
779
- if (!qeClip) return __error("QE clip not found");
780
-
851
+ if (!qeTrack) return __error("QE track not found; nothing was changed.");
852
+ // QE track items include gaps, so the DOM clip index is not a QE index.
853
+ var qeClip = __findQeClipByDomClip(qeTrack, result.clip);
854
+ if (!qeClip) return __error("Could not match the QE clip for " + result.clip.name + " by timeline start; nothing was changed.");
855
+
781
856
  qeClip.setTimeInterpolationType(${args.interpolation_type});
782
857
  var typeNames = ["Frame Sampling", "Frame Blending", "Optical Flow"];
783
858
  return __result({
@@ -817,9 +892,11 @@ export function getAdvancedTools(bridgeOptions) {
817
892
  var qeTrack = result.trackType === "video"
818
893
  ? qeSeq.getVideoTrackAt(result.trackIndex)
819
894
  : qeSeq.getAudioTrackAt(result.trackIndex);
820
- var qeClip = qeTrack.getItemAt(result.clipIndex);
821
- if (!qeClip) return __error("QE clip not found");
822
-
895
+ if (!qeTrack) return __error("QE track not found; nothing was changed.");
896
+ // QE track items include gaps, so the DOM clip index is not a QE index.
897
+ var qeClip = __findQeClipByDomClip(qeTrack, result.clip);
898
+ if (!qeClip) return __error("Could not match the QE clip for " + result.clip.name + " by timeline start; nothing was changed.");
899
+
823
900
  var oldName = result.clip.name;
824
901
  qeClip.setName("${escapeForExtendScript(args.new_name)}");
825
902
  return __result({ renamed: true, oldName: oldName, newName: "${escapeForExtendScript(args.new_name)}" });
@@ -469,17 +469,39 @@ export function getClipboardTools(bridgeOptions) {
469
469
 
470
470
  var src = srcResult.clip;
471
471
  var tgt = tgtResult.clip;
472
- var copied = 0;
472
+ var copied = [];
473
+ var unverified = [];
474
+ var failed = [];
473
475
  var effectFilter = ${args.effect_name ? `"${escapeForExtendScript(args.effect_name)}"` : "null"};
474
476
  var intrinsic = ["Motion", "Opacity", "Time Remapping", "Volume", "Channel Volume", "Panner"];
475
477
 
476
478
  // Use QE to copy effects by name
477
479
  var qeSeq = qe.project.getActiveSequence();
480
+ if (!qeSeq) return __error("No active sequence (QE); nothing was changed.");
478
481
  var tgtTrackType = tgtResult.trackType;
479
- var tgtTrack = tgtTrackType === "video"
480
- ? qeSeq.getVideoTrackAt(tgtResult.trackIndex)
482
+ var tgtTrack = tgtTrackType === "video"
483
+ ? qeSeq.getVideoTrackAt(tgtResult.trackIndex)
481
484
  : qeSeq.getAudioTrackAt(tgtResult.trackIndex);
482
- var qeTgtClip = tgtTrack.getItemAt(tgtResult.clipIndex);
485
+ if (!tgtTrack) return __error("QE target track not found; nothing was changed.");
486
+ // QE track items include gaps, so the DOM clip index is not a QE index.
487
+ var qeTgtClip = __findQeClipByDomClip(tgtTrack, tgt);
488
+ if (!qeTgtClip) return __error("Could not match the QE clip for target " + tgt.name + " by timeline start; nothing was changed.");
489
+
490
+ function countTargetComponents(wanted) {
491
+ var n = 0;
492
+ for (var ci = 0; ci < tgt.components.numItems; ci++) {
493
+ if (tgt.components[ci].displayName === wanted) n++;
494
+ }
495
+ return n;
496
+ }
497
+
498
+ function listNames(entries) {
499
+ var out = [];
500
+ for (var li = 0; li < entries.length; li++) {
501
+ out.push(typeof entries[li] === "string" ? entries[li] : entries[li].effect + " (" + entries[li].reason + ")");
502
+ }
503
+ return out.join(", ");
504
+ }
483
505
 
484
506
  for (var i = 0; i < src.components.numItems; i++) {
485
507
  var comp = src.components[i];
@@ -494,23 +516,61 @@ export function getClipboardTools(bridgeOptions) {
494
516
  if (skip) continue;
495
517
  }
496
518
 
497
- // Apply effect via QE
519
+ // Apply effect via QE, then read the target component list back.
520
+ var beforeCount = null;
521
+ try { beforeCount = countTargetComponents(name); } catch (beforeErr) {}
522
+ var qeEffect = null;
498
523
  try {
499
- var qeEffect = tgtTrackType === "video"
524
+ qeEffect = tgtTrackType === "video"
500
525
  ? qe.project.getVideoEffectByName(name)
501
526
  : qe.project.getAudioEffectByName(name);
502
- if (qeEffect) {
503
- if (tgtTrackType === "video") {
504
- qeTgtClip.addVideoEffect(qeEffect);
505
- } else {
506
- qeTgtClip.addAudioEffect(qeEffect);
507
- }
508
- copied++;
527
+ } catch (lookupErr) {
528
+ failed.push({ effect: name, reason: "QE effect lookup failed: " + lookupErr.toString() });
529
+ continue;
530
+ }
531
+ if (!qeEffect) {
532
+ failed.push({ effect: name, reason: "QE did not resolve a " + tgtTrackType + " effect with this name" });
533
+ continue;
534
+ }
535
+ try {
536
+ if (tgtTrackType === "video") {
537
+ qeTgtClip.addVideoEffect(qeEffect);
538
+ } else {
539
+ qeTgtClip.addAudioEffect(qeEffect);
509
540
  }
510
- } catch(e) {}
541
+ } catch (addErr) {
542
+ failed.push({ effect: name, reason: "Premiere rejected the effect: " + addErr.toString() });
543
+ continue;
544
+ }
545
+ var afterCount = null;
546
+ try { afterCount = countTargetComponents(name); } catch (afterErr) {}
547
+ if (beforeCount !== null && afterCount !== null && afterCount > beforeCount) {
548
+ copied.push(name);
549
+ } else {
550
+ unverified.push({ effect: name, reason: "Premiere accepted the call but the target component list did not show a new " + name + " component" });
551
+ }
552
+ }
553
+
554
+ if (failed.length > 0) {
555
+ var partial = copied.length + unverified.length > 0;
556
+ return __error((partial ? "copy_effects_between_clips was only partially applied" : "No effects were copied") +
557
+ " to " + tgt.name + ". Verified: [" + listNames(copied) + "]; committed_unverified: [" + listNames(unverified) +
558
+ "]; failed: [" + listNames(failed) + "]. Check Effect Controls before retrying.");
511
559
  }
512
560
 
513
- return __result({ copiedEffects: copied, source: src.name, target: tgt.name });
561
+ var status = copied.length + unverified.length === 0
562
+ ? "unchanged"
563
+ : (unverified.length > 0 ? "committed_unverified" : "verified");
564
+ return __result({
565
+ status: status,
566
+ verified: status === "verified",
567
+ copiedEffects: copied.length,
568
+ copied: copied,
569
+ committedUnverified: unverified,
570
+ failed: failed,
571
+ source: src.name,
572
+ target: tgt.name
573
+ });
514
574
  `);
515
575
  return sendCommand(script, bridgeOptions);
516
576
  },