feat: initial commit - Jhonny Editor

- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
This commit is contained in:
João Henrique
2026-09-08 09:59:31 -04:00
commit b541f502ba
1507 changed files with 387650 additions and 0 deletions
+1254
View File
File diff suppressed because it is too large Load Diff
+1021
View File
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
import {
buildToolScript,
escapeForExtendScript,
} from "../bridge/script-builder.js";
import { sendCommand, type BridgeOptions } from "../bridge/file-bridge.js";
const BLOCKED_REASON =
"No documented ExtendScript or Premiere UXP API exposes this operation. Use Premiere Pro's UI; MCP will not invoke QE or guess private component parameters.";
export function getAvSettingsTools(bridgeOptions: BridgeOptions) {
return {
get_av_feature_support: {
description:
"Report the documented automation boundary for advanced audio and modern color management, including actionable reasons for UI-only features.",
parameters: {},
handler: async () => ({
success: true,
data: {
schemaVersion: 1,
supported: {
sequenceAvInspection: {
status: "supported",
backend: "cep",
fields: [
"audioChannelCount",
"audioChannelType",
"audioSampleRate",
"audioDisplayFormat",
"autoToneMapEnabled",
"compositeLinearColor",
"maximumBitDepth",
"maximumRenderQuality",
],
note: "Field availability varies by Premiere version and sequence preset.",
},
projectItemColorInspection: {
status: "supported",
backend: "cep",
fields: [
"effectiveColorSpace",
"originalColorSpace",
"overrideColorSpaceOptions",
"embeddedLutId",
"inputLutId",
],
},
projectItemAudioChannelInspection: {
status: "supported",
backend: "cep",
fields: ["audioChannelsType", "audioClipsNumber"],
},
projectItemAudioChannelMapping: {
status: "supported_with_limitations",
backend: "cep",
note: "The documented mapping object can set one output-to-source channel mapping and reports whether Premiere accepted it; the API does not expose a per-channel mapping getter for post-write comparison.",
},
projectGraphicsWhiteLuminance: {
status: "uxp_only",
minPremiereVersion: "25.6",
note: "Documented UXP inspection exists, but the current MCP UXP transport does not yet route this command.",
},
},
blocked: {
sequenceWorkingColorSpace: { status: "unsupported", reason: BLOCKED_REASON },
sequenceOutputColorSpace: { status: "unsupported", reason: BLOCKED_REASON },
projectItemColorSpaceOverrideMutation: { status: "unsupported", reason: BLOCKED_REASON },
loudnessMeasurementAndNormalization: { status: "unsupported", reason: BLOCKED_REASON },
audioTrackMixerRoutingAndSubmixes: { status: "unsupported", reason: BLOCKED_REASON },
enhanceSpeech: { status: "ui_only", reason: BLOCKED_REASON },
remix: { status: "ui_only", reason: BLOCKED_REASON },
},
},
}),
},
inspect_sequence_av_settings: {
description:
"Inspect documented audio, tone-mapping, linear-compositing, bit-depth, render-quality, and display settings for the active sequence.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var settings = seq.getSettings();
if (!settings) return __error("Could not get sequence settings");
function valueOfTime(value) {
if (value === undefined || value === null) return null;
var result = {};
try { if (value.seconds !== undefined) result.seconds = value.seconds; } catch (_) {}
try { if (value.ticks !== undefined) result.ticks = value.ticks.toString(); } catch (_) {}
return result;
}
function optional(name) {
try { return settings[name] === undefined ? null : settings[name]; } catch (_) { return null; }
}
return __result({
sequence: seq.name,
audio: {
channelCount: optional("audioChannelCount"),
channelType: optional("audioChannelType"),
channelTypeLabels: { "0": "mono", "1": "stereo", "2": "5.1", "3": "multichannel", "4": "4-channel", "5": "8-channel" },
sampleRate: valueOfTime(optional("audioSampleRate")),
displayFormat: optional("audioDisplayFormat")
},
colorAndRender: {
autoToneMapEnabled: optional("autoToneMapEnabled"),
compositeLinearColor: optional("compositeLinearColor"),
maximumBitDepth: optional("maximumBitDepth"),
maximumRenderQuality: optional("maximumRenderQuality")
},
unavailableThroughDocumentedApi: ["sequenceWorkingColorSpace", "sequenceOutputColorSpace"],
note: "null means the current Premiere build or sequence preset did not expose that documented field."
});
`);
return sendCommand(script, bridgeOptions);
},
},
inspect_project_item_av_metadata: {
description:
"Inspect a project item's documented effective/original color space, LUT IDs, available color-space overrides, and audio channel shape.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Project item node ID or exact name",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
function optionalCall(name) {
try { return typeof item[name] === "function" ? item[name]() : null; } catch (_) { return null; }
}
function optionalProperty(name) {
try { return item[name] === undefined ? null : item[name]; } catch (_) { return null; }
}
var mapping = optionalProperty("getAudioChannelMapping");
if (typeof mapping === "function" || !mapping) mapping = optionalCall("getAudioChannelMapping");
var overrideList = optionalProperty("getOverrideColorSpaceList");
if (typeof overrideList === "function" || !overrideList) overrideList = optionalCall("getOverrideColorSpaceList");
return __result({
item: item.name,
nodeId: item.nodeId,
color: {
effective: optionalCall("getColorSpace"),
original: optionalCall("getOriginalColorSpace"),
overrideOptions: overrideList,
embeddedLutId: optionalCall("getEmbeddedLUTID"),
inputLutId: optionalCall("getInputLUTID")
},
audio: mapping ? {
channelType: mapping.audioChannelsType,
clipCount: mapping.audioClipsNumber
} : null
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_project_item_audio_channel_mapping: {
description:
"Map one output audio channel of a project item to a source channel using Premiere's documented AudioChannelMapping API.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Project item node ID or exact name",
},
channel_index: {
type: "number",
description: "Zero-based output channel index to configure",
},
source_channel_index: {
type: "number",
description: "Zero-based source channel index to map",
},
},
required: ["item_id", "channel_index", "source_channel_index"],
},
handler: async (args: {
item_id: string;
channel_index: number;
source_channel_index: number;
}) => {
if (!Number.isInteger(args.channel_index) || args.channel_index < 0) {
return { success: false, error: "channel_index must be a non-negative integer" };
}
if (!Number.isInteger(args.source_channel_index) || args.source_channel_index < 0) {
return { success: false, error: "source_channel_index must be a non-negative integer" };
}
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
var mapping = null;
try { mapping = item.getAudioChannelMapping; } catch (_) {}
if (typeof mapping === "function" || !mapping) {
try { if (typeof item.getAudioChannelMapping === "function") mapping = item.getAudioChannelMapping(); } catch (_) {}
}
if (!mapping || typeof mapping.setMappingForChannel !== "function") {
return __error("This item or Premiere build does not expose AudioChannelMapping.setMappingForChannel");
}
var accepted = mapping.setMappingForChannel(${args.channel_index}, ${args.source_channel_index});
if (accepted !== true) return __error("Premiere rejected this channel mapping");
return __result({
item: item.name,
channelIndex: ${args.channel_index},
sourceChannelIndex: ${args.source_channel_index},
accepted: true,
verification: "api-return",
limitation: "The documented API does not expose a per-channel mapping getter for post-write comparison."
});
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+241
View File
@@ -0,0 +1,241 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
import {
buildCaptionTimingPlan,
MAX_CAPTION_ARTIFACT_CHARACTERS,
} from "../ai/caption-timing.js";
function finiteNonNegativeSeconds(value: unknown, field: string, fallback = 0): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 604_800) {
throw new Error(`${field} must be a finite number from 0 through 604800`);
}
return value;
}
function requiredItemId(value: unknown): string {
if (typeof value !== "string" || !value.trim() || value.trim().length > 512) {
throw new Error("item_id is required for action import and must be at most 512 characters");
}
return value.trim();
}
function lectureCaptionWorkflow(plan: ReturnType<typeof buildCaptionTimingPlan>) {
return {
id: "guided_lecture_caption_workflow",
planId: plan.planId,
appliesCaptionArtifact: false,
steps: [
{
id: "prepare_test_sequence",
mutatesProject: true,
route: "manage_sequences_uxp",
instruction: "Use a duplicate/test sequence before importing captions. When a compatible UXP bridge is available, clone with the documented sequence workflow; otherwise duplicate it in Premiere and re-query the stable sequence ID.",
verification: "A cloned sequence must be independently identified before any caption import. This plan does not create or select a sequence.",
},
{
id: "review_timing_preview",
mutatesProject: false,
route: "create_caption_track",
instruction: "Review the beginning, middle, and end timing samples before changing the caller-owned SRT or VTT artifact. If the preview requires manual review, resolve that mismatch before import.",
verification: "The timing preview is not a modified artifact and is not proof of subtitle readability.",
},
{
id: "import_caption_track",
mutatesProject: true,
route: "create_caption_track",
instruction: "Import the reviewed artifact into the Premiere project, then call create_caption_track with action import, the imported item_id, and an intentional start_seconds value.",
verification: "CEP reports caption-track count readback when available; accepted requests without readback are not structural verification.",
},
{
id: "read_back_caption_track",
mutatesProject: false,
route: "read_sequence_captions",
instruction: "Read the caption-track structure from the intended duplicate/test sequence after import.",
verification: "Caption-track structure does not prove playback synchronization, line breaks, safe area, or visual readability.",
},
{
id: "review_beginning_middle_end",
mutatesProject: false,
route: "export_sequence_review_frames",
instruction: "Review frames around the plan's beginning, middle, and end samples and play the same ranges in Premiere before delivery.",
verification: "Review frames and playback establish different evidence levels; rendered output remains a separate delivery check.",
},
],
verification: {
structuralReadback: "not_run",
playbackReview: "not_run",
renderedOutputReview: "not_run",
boundary: "The workflow is a guide. It has not imported captions, modified a sequence, exported review frames, or established any host-side verification.",
},
};
}
export function getCaptionTools(bridgeOptions: BridgeOptions) {
return {
create_caption_track: {
description:
"Import an already reviewed caption artifact into the active sequence, or create a local lecture-caption timing and review workflow plan. Import reports structural success only when the host exposes a caption-track readback; the planning action never contacts Premiere or changes an artifact.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: {
type: "string",
enum: ["import", "plan_lecture_workflow"],
default: "import",
description: "Use import (the default) to create a Premiere caption track, or plan_lecture_workflow for a read-only SRT/VTT timing and review plan.",
},
item_id: {
type: "string",
maxLength: 512,
description:
"For action import, the node ID or name of the imported caption project item (for example, an SRT file). Omit for plan_lecture_workflow.",
},
start_seconds: {
type: "number",
minimum: 0,
maximum: 604800,
description:
"For action import, offset in seconds from the start of the sequence (default: 0).",
},
caption_format: {
type: "string",
description:
"For action import, Premiere caption format: subtitle (default), 608, 708, teletext, ebu, op42, or op47.",
},
caption_content: {
type: "string",
minLength: 1,
maxLength: MAX_CAPTION_ARTIFACT_CHARACTERS,
description: "For plan_lecture_workflow, the caller-owned SRT or VTT content to parse locally. The returned plan contains no caption text and does not write this artifact.",
},
artifact_format: {
type: "string",
enum: ["srt", "vtt"],
description: "For plan_lecture_workflow, the syntax of caption_content. VTT content must include its WEBVTT header.",
},
target_duration_seconds: {
type: "number",
minimum: 0.001,
maximum: 604800,
description: "For plan_lecture_workflow, optional intended sequence duration. A mismatch requires review unless proportional scaling is explicitly authorized.",
},
observed_offset_seconds: {
type: "number",
minimum: -86400,
maximum: 86400,
description: "For plan_lecture_workflow, an editor-observed constant offset in seconds. Positive means captions currently appear later than intended; the preview proposes the inverse shift when safe.",
},
allow_proportional_scaling: {
type: "boolean",
description: "For plan_lecture_workflow, explicitly permit a bounded timing-scale preview when the artifact end does not match target_duration_seconds. This still never rewrites a caption artifact.",
},
timing_tolerance_seconds: {
type: "number",
minimum: 0.001,
maximum: 10,
description: "For plan_lecture_workflow, tolerance used to classify an offset or duration mismatch (default: 0.25 seconds).",
},
},
},
handler: async (args: {
action?: "import" | "plan_lecture_workflow";
item_id?: string;
start_seconds?: number;
caption_format?: string;
caption_content?: string;
artifact_format?: "srt" | "vtt";
target_duration_seconds?: number;
observed_offset_seconds?: number;
allow_proportional_scaling?: boolean;
timing_tolerance_seconds?: number;
}) => {
try {
const action = args.action ?? "import";
if (action === "plan_lecture_workflow") {
const plan = buildCaptionTimingPlan(args.caption_content, args.artifact_format, {
targetDurationSeconds: args.target_duration_seconds,
observedOffsetSeconds: args.observed_offset_seconds,
allowProportionalScaling: args.allow_proportional_scaling,
timingToleranceSeconds: args.timing_tolerance_seconds,
});
return {
success: true,
data: {
...plan,
workflow: lectureCaptionWorkflow(plan),
},
};
}
if (action !== "import") return { success: false, error: "action must be either import or plan_lecture_workflow" };
const itemId = requiredItemId(args.item_id);
const startSeconds = finiteNonNegativeSeconds(args.start_seconds, "start_seconds");
const formatMap: Record<string, string> = {
subtitle: "Sequence.CAPTION_FORMAT_SUBTITLE",
"608": "Sequence.CAPTION_FORMAT_608",
"708": "Sequence.CAPTION_FORMAT_708",
teletext: "Sequence.CAPTION_FORMAT_TELETEXT",
ebu: "Sequence.CAPTION_FORMAT_OPEN_EBU",
op42: "Sequence.CAPTION_FORMAT_OP42",
op47: "Sequence.CAPTION_FORMAT_OP47",
};
const format =
formatMap[args.caption_format || "subtitle"] ||
"Sequence.CAPTION_FORMAT_SUBTITLE";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var item = __findProjectItem("${escapeForExtendScript(itemId)}");
if (!item) return __error("Caption item not found: ${escapeForExtendScript(itemId)}");
function __captionTrackCount(sequence) {
var tracks = null;
try {
if (typeof sequence.getCaptionTracks === "function") tracks = sequence.getCaptionTracks();
else if (sequence.captionTracks) tracks = sequence.captionTracks;
} catch (readError) { tracks = null; }
if (!tracks) return null;
try { return tracks.numTracks !== undefined ? Number(tracks.numTracks) : Number(tracks.length); }
catch (countError) { return null; }
}
var beforeCount = __captionTrackCount(seq);
var result = seq.createCaptionTrack(item, ${startSeconds}, ${format});
if (!result) return __error("Failed to create caption track");
var afterCount = __captionTrackCount(seq);
if (beforeCount !== null && afterCount !== null) {
if (afterCount <= beforeCount) {
return __error("Premiere accepted caption-track creation but no caption track appeared in host readback. No successful creation is reported.");
}
return __result({
created: true,
verified: true,
renderVerified: false,
verificationScope: "Caption-track count readback only; verify playback or exported frames before delivery.",
item: item.name,
startSeconds: ${startSeconds},
format: "${args.caption_format || "subtitle"}",
beforeTrackCount: beforeCount,
afterTrackCount: afterCount
});
}
return __result({
accepted: true,
verified: false,
renderVerified: false,
verificationScope: "Premiere accepted the caption-track request, but this CEP host exposes no caption-track readback. No structural creation is claimed; verify playback or exported frames before delivery.",
item: item.name,
startSeconds: ${startSeconds},
format: "${args.caption_format || "subtitle"}"
});
`);
return sendCommand(script, bridgeOptions);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
};
}
+494
View File
@@ -0,0 +1,494 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getClipboardTools(bridgeOptions: BridgeOptions) {
return {
copy_effects_between_clips: {
description: "Copy all effects (or a specific effect) from one clip to another. Does not copy intrinsic properties like Motion/Opacity unless specified.",
parameters: {
type: "object" as const,
properties: {
source_node_id: {
type: "string",
description: "Node ID of the source clip to copy effects from",
},
target_node_id: {
type: "string",
description: "Node ID of the target clip to paste effects to",
},
effect_name: {
type: "string",
description: "Specific effect display name to copy (copies all non-intrinsic effects if omitted)",
},
},
required: ["source_node_id", "target_node_id"],
},
handler: async (args: { source_node_id: string; target_node_id: string; effect_name?: string }) => {
const script = buildToolScript(`
app.enableQE();
var srcResult = __findClip("${escapeForExtendScript(args.source_node_id)}");
if (!srcResult) return __error("Source clip not found");
var tgtResult = __findClip("${escapeForExtendScript(args.target_node_id)}");
if (!tgtResult) return __error("Target clip not found");
var src = srcResult.clip;
var tgt = tgtResult.clip;
var copied = 0;
var effectFilter = ${args.effect_name ? `"${escapeForExtendScript(args.effect_name)}"` : "null"};
var intrinsic = ["Motion", "Opacity", "Time Remapping", "Volume", "Channel Volume", "Panner"];
// Use QE to copy effects by name
var qeSeq = qe.project.getActiveSequence();
var tgtTrackType = tgtResult.trackType;
var tgtTrack = tgtTrackType === "video"
? qeSeq.getVideoTrackAt(tgtResult.trackIndex)
: qeSeq.getAudioTrackAt(tgtResult.trackIndex);
var qeTgtClip = tgtTrack.getItemAt(tgtResult.clipIndex);
for (var i = 0; i < src.components.numItems; i++) {
var comp = src.components[i];
var name = comp.displayName;
if (effectFilter && name !== effectFilter) continue;
if (!effectFilter) {
var skip = false;
for (var k = 0; k < intrinsic.length; k++) {
if (name === intrinsic[k]) { skip = true; break; }
}
if (skip) continue;
}
// Apply effect via QE
try {
var qeEffect = tgtTrackType === "video"
? qe.project.getVideoEffectByName(name)
: qe.project.getAudioEffectByName(name);
if (qeEffect) {
if (tgtTrackType === "video") {
qeTgtClip.addVideoEffect(qeEffect);
} else {
qeTgtClip.addAudioEffect(qeEffect);
}
copied++;
}
} catch(e) {}
}
return __result({ copiedEffects: copied, source: src.name, target: tgt.name });
`);
return sendCommand(script, bridgeOptions);
},
},
copy_effect_values: {
description:
"Copy verified scalar effect-property values from one effect to the matching effect on another clip. Both clips must already have the same effect applied. Legacy CEP deliberately refuses Blend Mode because Premiere can corrupt its enum value on cross-clip writes.",
parameters: {
type: "object" as const,
properties: {
source_node_id: {
type: "string",
description: "Node ID of the source clip",
},
target_node_id: {
type: "string",
description: "Node ID of the target clip",
},
effect_name: {
type: "string",
description: "Display name of the effect to copy values for",
},
},
required: ["source_node_id", "target_node_id", "effect_name"],
},
handler: async (args: { source_node_id: string; target_node_id: string; effect_name: string }) => {
const script = buildToolScript(`
var srcResult = __findClip("${escapeForExtendScript(args.source_node_id)}");
if (!srcResult) return __error("Source clip not found");
var tgtResult = __findClip("${escapeForExtendScript(args.target_node_id)}");
if (!tgtResult) return __error("Target clip not found");
var srcComp = null;
var tgtComp = null;
for (var i = 0; i < srcResult.clip.components.numItems; i++) {
if (srcResult.clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}") {
srcComp = srcResult.clip.components[i];
break;
}
}
if (!srcComp) return __error("Effect not found on source clip: ${escapeForExtendScript(args.effect_name)}");
for (var i = 0; i < tgtResult.clip.components.numItems; i++) {
if (tgtResult.clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}") {
tgtComp = tgtResult.clip.components[i];
break;
}
}
if (!tgtComp) return __error("Effect not found on target clip: ${escapeForExtendScript(args.effect_name)}");
function valuesMatch(left, right) {
if (typeof left === "number" && typeof right === "number") return Math.abs(left - right) < 0.000001;
return String(left) === String(right);
}
var copied = 0;
var skipped = [];
var failures = [];
for (var p = 0; p < srcComp.properties.numItems; p++) {
var srcProp = srcComp.properties[p];
for (var q = 0; q < tgtComp.properties.numItems; q++) {
if (tgtComp.properties[q].displayName === srcProp.displayName) {
if (srcProp.displayName === "Blend Mode") {
skipped.push({ property: srcProp.displayName, reason: "Legacy CEP enum writes can corrupt Blend Mode; no write was attempted." });
break;
}
try {
var val = srcProp.getValue(0, 0);
tgtComp.properties[q].setValue(val, true);
var readback = tgtComp.properties[q].getValue(0, 0);
if (!valuesMatch(readback, val)) {
failures.push(srcProp.displayName + " did not match its source value after the write");
} else {
copied++;
}
} catch(e) {
failures.push(srcProp.displayName + " could not be copied and read back: " + e.toString());
}
break;
}
}
}
if (skipped.length || failures.length) {
return __error(
"Effect-value copy was not fully verified. Copied " + copied + " property value(s); " +
"skipped: " + skipped.length + "; failures: " + failures.length + ". " +
"Blend Mode is intentionally refused on legacy CEP because Premiere can write an unrelated enum value. Inspect Effect Controls before retrying."
);
}
return __result({ copiedProperties: copied, verified: true, effect: "${escapeForExtendScript(args.effect_name)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
replace_clip_media: {
description: "Unavailable by design: the legacy ExtendScript overwrite route cannot prove that replacing media preserves the original clip's trim, position, linked audio, or adjacent clips, so this tool performs no mutation.",
operationalCapability: {
backend: "local" as const,
backends: ["local" as const],
status: "unsupported" as const,
minimumPremiereVersion: null,
authority: "edit" as const,
verificationBoundary: "static_metadata_only" as const,
hostVerificationRequired: false,
notes: ["Unavailable by design; this tool returns a local negative result and never contacts Premiere."],
},
parameters: {
type: "object" as const,
properties: {
clip_node_id: {
type: "string",
description: "Node ID of the timeline clip to replace media on",
},
new_item_id: {
type: "string",
description: "Node ID or name of the new source project item",
},
},
required: ["clip_node_id", "new_item_id"],
},
handler: async () => ({
success: false,
error: "replace_clip_media is unavailable because the legacy overwrite route cannot preserve and verify clip duration, placement, linked audio, and neighboring clips. No mutation was attempted.",
}),
},
batch_apply_effect: {
description: "Apply one audio or video effect to compatible selected clips, a compatible track, or all compatible clips. Every target is preflighted and then checked by component-count readback.",
parameters: {
type: "object" as const,
properties: {
effect_name: {
type: "string",
description: "Display name of the effect to apply (e.g., 'Gaussian Blur', 'Lumetri Color')",
},
target: {
type: "string",
enum: ["selected", "track", "all"],
description: "Which clips to apply to: selected clips, all on a track, or all in sequence",
},
track_type: {
type: "string",
enum: ["video", "audio"],
description: "Track type (required when target is 'track')",
},
track_index: {
type: "number",
description: "Track index (required when target is 'track')",
},
},
required: ["effect_name", "target"],
},
handler: async (args: { effect_name: string; target: string; track_type?: string; track_index?: number }) => {
if (!args.effect_name.trim()) return { success: false, error: "effect_name must not be empty" };
if (args.target !== "selected" && args.target !== "track" && args.target !== "all") {
return { success: false, error: "target must be selected, track, or all" };
}
if (args.target === "track" && (args.track_type !== "video" && args.track_type !== "audio")) {
return { success: false, error: "track_type must be video or audio when target is track" };
}
if (args.target === "track" && (!Number.isInteger(args.track_index) || (args.track_index as number) < 0)) {
return { success: false, error: "track_index must be a non-negative integer when target is track" };
}
const script = buildToolScript(`
app.enableQE();
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var effectName = "${escapeForExtendScript(args.effect_name)}";
var qeEffect = qe.project.getVideoEffectByName(effectName);
var effectType = "video";
if (!qeEffect) {
qeEffect = qe.project.getAudioEffectByName(effectName);
effectType = "audio";
}
if (!qeEffect) return __error("Effect not found: " + effectName);
var target = "${args.target}";
var targets = [];
var selectedIncompatible = 0;
function collectTracks(tracks, trackType, selectedOnly, onlyTrackIndex) {
for (var t = 0; t < tracks.numTracks; t++) {
if (onlyTrackIndex !== null && t !== onlyTrackIndex) continue;
for (var c = 0; c < tracks[t].clips.numItems; c++) {
var clip = tracks[t].clips[c];
if (selectedOnly && !clip.isSelected()) continue;
if (trackType !== effectType) {
if (selectedOnly) selectedIncompatible++;
continue;
}
targets.push({ clip: clip, trackIndex: t, trackType: trackType, qeClip: null, beforeCount: 0 });
}
}
}
if (target === "selected") {
collectTracks(seq.videoTracks, "video", true, null);
collectTracks(seq.audioTracks, "audio", true, null);
} else if (target === "track") {
var requestedTrackType = "${args.track_type || "video"}";
var requestedTrackIndex = ${args.track_index ?? 0};
if (requestedTrackType !== effectType) {
return __error("Effect " + effectName + " is a " + effectType + " effect and cannot be applied to an " + requestedTrackType + " track. No mutation was attempted.");
}
var requestedTracks = requestedTrackType === "video" ? seq.videoTracks : seq.audioTracks;
if (requestedTrackIndex >= requestedTracks.numTracks) return __error("Track index out of range");
collectTracks(requestedTracks, requestedTrackType, false, requestedTrackIndex);
} else {
collectTracks(effectType === "video" ? seq.videoTracks : seq.audioTracks, effectType, false, null);
}
if (targets.length === 0) {
return __error("No compatible " + effectType + " clips matched target " + target + ". No mutation was attempted.");
}
function findQeClip(target) {
var qeTrack = target.trackType === "video"
? qeSeq.getVideoTrackAt(target.trackIndex)
: qeSeq.getAudioTrackAt(target.trackIndex);
if (!qeTrack) return null;
var expectedStart = parseFloat(target.clip.start.ticks);
for (var qi = 0; qi < qeTrack.numItems; qi++) {
var candidate = qeTrack.getItemAt(qi);
if (!candidate || String(candidate.type) !== "Clip") continue;
try {
if (Math.abs(parseFloat(candidate.start.ticks) - expectedStart) < 1) return candidate;
} catch (lookupError) {}
}
return null;
}
function countEffectComponents(clip) {
var count = 0;
for (var ci = 0; ci < clip.components.numItems; ci++) {
var component = clip.components[ci];
if (component.displayName === effectName || component.matchName === effectName) count++;
}
return count;
}
// Resolve every QE clip before changing anything. QE item indexes include
// gaps, so a DOM clip index cannot safely be used as a QE item index.
for (var preflightIndex = 0; preflightIndex < targets.length; preflightIndex++) {
var preflightTarget = targets[preflightIndex];
preflightTarget.qeClip = findQeClip(preflightTarget);
if (!preflightTarget.qeClip) {
return __error("Could not match a selected " + effectType + " clip to its QE item. No effects were applied.");
}
preflightTarget.beforeCount = countEffectComponents(preflightTarget.clip);
}
var applied = 0;
var failures = [];
for (var targetIndex = 0; targetIndex < targets.length; targetIndex++) {
var current = targets[targetIndex];
try {
if (effectType === "audio") current.qeClip.addAudioEffect(qeEffect);
else current.qeClip.addVideoEffect(qeEffect);
} catch (applyError) {
failures.push("track " + current.trackIndex + ": " + applyError.toString());
continue;
}
var afterCount = countEffectComponents(current.clip);
if (afterCount <= current.beforeCount) {
failures.push("track " + current.trackIndex + ": component count did not increase");
continue;
}
applied++;
}
if (failures.length > 0) {
return __error("Batch effect application was only partially verified (" + applied + "/" + targets.length + "). Do not retry blindly; inspect Effect Controls. Failures: " + failures.join("; "));
}
return __result({ applied: applied, verified: true, effect: effectName, effectType: effectType, target: target, selectedIncompatible: selectedIncompatible });
`);
return sendCommand(script, bridgeOptions);
},
},
remove_effect_by_name: {
description: "Remove all instances of a specific effect from a clip by display name. Returns a capability error when the host cannot remove individual components.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect to remove",
},
},
required: ["node_id", "effect_name"],
},
handler: async (args: { node_id: string; effect_name: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var effectName = "${escapeForExtendScript(args.effect_name)}";
var matches = [];
// Record matching indices from back to front. This both keeps indexes
// stable during removal and lets us preflight every match before any
// mutation, so an unsupported Amplify component cannot cause a partial
// all-matches removal.
for (var i = clip.components.numItems - 1; i >= 0; i--) {
if (clip.components[i].displayName === effectName) {
matches.push(i);
}
}
if (matches.length === 0) return __error("Effect not found: " + effectName);
function canRemoveComponent(component) {
try {
return !!component && typeof component.remove === "function";
} catch (e) {
return false;
}
}
// QE offers only removeEffects(), which would also remove unrelated
// effects. Do not substitute that broad mutation for this targeted tool.
for (var j = 0; j < matches.length; j++) {
var component = clip.components[matches[j]];
if (!canRemoveComponent(component)) {
return __error("Premiere does not expose Component.remove() for \"" + effectName + "\". No matching components were removed. No safe targeted QE fallback exists; remove it manually in Effect Controls.");
}
}
var removed = 0;
for (var j = 0; j < matches.length; j++) {
var component = clip.components[matches[j]];
try {
component.remove();
removed++;
} catch (e) {
return __error("Premiere could not remove \"" + effectName + "\" after removing " + removed + " matching component(s): " + e.toString() + ". Inspect Effect Controls before retrying.");
}
}
return __result({ removed: removed, effect: effectName, clip: clip.name });
`);
return sendCommand(script, bridgeOptions);
},
},
set_blend_mode: {
description: "Set the blend mode on a video clip. Uses the Opacity effect's Blend Mode property.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the video clip",
},
blend_mode: {
type: "string",
enum: [
"Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn", "Darker Color",
"Lighten", "Screen", "Color Dodge", "Linear Dodge", "Lighter Color",
"Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light", "Hard Mix",
"Difference", "Exclusion", "Subtract", "Divide",
"Hue", "Saturation", "Color", "Luminosity"
],
description: "Blend mode name",
},
},
required: ["node_id", "blend_mode"],
},
handler: async (args: { node_id: string; blend_mode: string }) => {
const blendModeMap: Record<string, number> = {
"Normal": 1, "Dissolve": 2, "Darken": 3, "Multiply": 4, "Color Burn": 5,
"Linear Burn": 6, "Darker Color": 7, "Lighten": 8, "Screen": 9, "Color Dodge": 10,
"Linear Dodge": 11, "Lighter Color": 12, "Overlay": 13, "Soft Light": 14,
"Hard Light": 15, "Vivid Light": 16, "Linear Light": 17, "Pin Light": 18,
"Hard Mix": 19, "Difference": 20, "Exclusion": 21, "Subtract": 22, "Divide": 23,
"Hue": 24, "Saturation": 25, "Color": 26, "Luminosity": 27
};
const modeValue = blendModeMap[args.blend_mode] ?? 1;
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var set = false;
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.displayName === "Opacity") {
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "Blend Mode") {
comp.properties[p].setValue(${modeValue}, true);
set = true;
break;
}
}
break;
}
}
if (!set) return __error("Could not find Blend Mode property on clip");
return __result({ blendMode: "${escapeForExtendScript(args.blend_mode)}", clip: clip.name });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+894
View File
@@ -0,0 +1,894 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type BatchTimelineClip = {
item_id: string;
track_index?: number;
start_seconds?: number;
audio_track_index?: number;
};
type DuckingWindow = {
start_seconds: number;
end_seconds: number;
ducked_db: number;
};
type ClipPropertyBatchItem = {
node_id: string;
opacity?: number;
scale?: number;
position_x?: number;
position_y?: number;
rotation?: number;
speed?: number;
};
function finiteNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function finiteNonNegativeNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function finiteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function resultFromUxp(
bridge: UxpWebSocketBridge,
command: string,
args: Record<string, unknown>,
) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
/**
* Tools that close concrete interoperability gaps without pretending CEP can
* safely automate Premiere APIs which open modal UI or block the panel.
*/
export function getCompetitorGapTools(
bridgeOptions: BridgeOptions,
uxpBridge?: UxpWebSocketBridge,
) {
return {
import_edl: {
description:
"Unavailable by design: CMX 3600 EDL import opens Premiere UI that can block the CEP bridge. No import is attempted; convert the EDL to FCP7 XML and use import_fcp_xml for unattended interchange.",
parameters: {
type: "object" as const,
properties: {
file_path: {
type: "string",
description: "Absolute path to the CMX 3600 .edl file that was requested for import.",
},
},
required: ["file_path"],
},
handler: async (args: { file_path: string }) => ({
success: false,
error:
"CMX 3600 EDL import is not run through the unattended CEP bridge because Premiere can open an interactive sequence/source-media dialog and block the panel. No mutation was attempted. Convert '" +
args.file_path +
"' to FCP7 XML, then call import_fcp_xml.",
}),
},
add_to_timeline_batch: {
description:
"Insert up to 32 project items in one validated CEP request. All items and target tracks are preflighted before the first insertion; every requested placement is read back, and the tool fails closed if Premiere cannot verify one.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
clips: {
type: "array",
minItems: 1,
maxItems: 32,
description:
"Ordered placements. They use the same insert-edit semantics as add_to_timeline; later placements must not share a target video-track start time.",
items: {
type: "object",
additionalProperties: false,
properties: {
item_id: { type: "string", minLength: 1, maxLength: 512, description: "Project item node ID or unique name." },
track_index: { type: "integer", minimum: 0, description: "Video track index (defaults to 0)." },
start_seconds: { type: "number", minimum: 0, description: "Timeline insertion time in seconds (defaults to 0)." },
audio_track_index: { type: "integer", minimum: 0, description: "Audio target for linked source audio (defaults to 0)." },
},
required: ["item_id"],
},
},
},
required: ["clips"],
},
handler: async (args: { clips: BatchTimelineClip[] }) => {
if (!Array.isArray(args.clips) || args.clips.length < 1 || args.clips.length > 32) {
return { success: false, error: "clips must contain between 1 and 32 placements." };
}
const starts = new Set<string>();
const clips: Array<Required<BatchTimelineClip>> = [];
for (let index = 0; index < args.clips.length; index++) {
const item = args.clips[index];
const trackIndex = item.track_index ?? 0;
const startSeconds = item.start_seconds ?? 0;
const audioTrackIndex = item.audio_track_index ?? 0;
if (!item || typeof item.item_id !== "string" || item.item_id.length < 1 || item.item_id.length > 512) {
return { success: false, error: `clips[${index}].item_id must be a non-empty string no longer than 512 characters.` };
}
if (!finiteNonNegativeInteger(trackIndex) || !finiteNonNegativeInteger(audioTrackIndex) || !finiteNonNegativeNumber(startSeconds)) {
return {
success: false,
error: `clips[${index}] requires non-negative integer track indexes and a finite non-negative start_seconds.`,
};
}
// Equal-time insertions on the same video track are order-dependent.
// Refuse them rather than silently producing a different edit.
const startKey = `${trackIndex}:${startSeconds}`;
if (starts.has(startKey)) {
return {
success: false,
error: `clips[${index}] shares video track ${trackIndex} and start_seconds ${startSeconds} with another placement; split this into explicit sequential edits.`,
};
}
starts.add(startKey);
clips.push({ item_id: item.item_id, track_index: trackIndex, start_seconds: startSeconds, audio_track_index: audioTrackIndex });
}
// Ascending positions preserve the requested locations under insert-edit
// semantics: a later insert does not shift an earlier one.
clips.sort((left, right) => left.start_seconds - right.start_seconds);
const emittedClips = clips.map((item) => `
{
itemId: "${escapeForExtendScript(item.item_id)}",
trackIndex: ${item.track_index},
startSeconds: ${item.start_seconds},
audioTrackIndex: ${item.audio_track_index}
}`).join(",");
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var placements = [${emittedClips}];
var i;
// Preflight every mutable dependency before the first insert. This
// avoids the common partial batch caused by a typo late in the list.
for (i = 0; i < placements.length; i++) {
var preflight = placements[i];
if (!seq.videoTracks[preflight.trackIndex]) {
return __error("Video track index " + preflight.trackIndex + " is out of range for placement " + i + ". No placement was attempted.");
}
if (!seq.audioTracks[preflight.audioTrackIndex]) {
return __error("Audio track index " + preflight.audioTrackIndex + " is out of range for placement " + i + ". No placement was attempted.");
}
preflight.item = __findProjectItem(preflight.itemId);
if (!preflight.item) {
return __error("Project item not found for placement " + i + ": " + preflight.itemId + ". No placement was attempted.");
}
}
var results = [];
for (i = 0; i < placements.length; i++) {
var placement = placements[i];
var videoTrack = seq.videoTracks[placement.trackIndex];
var audioTrack = seq.audioTracks.numTracks > 0 ? seq.audioTracks[placement.audioTrackIndex] : null;
var beforeVideoIds = {};
var beforeAudioIds = {};
var c;
for (c = 0; c < videoTrack.clips.numItems; c++) beforeVideoIds[videoTrack.clips[c].nodeId] = true;
if (audioTrack) {
for (c = 0; c < audioTrack.clips.numItems; c++) beforeAudioIds[audioTrack.clips[c].nodeId] = true;
}
try {
seq.insertClip(placement.item, __secondsToTicks(placement.startSeconds).toString(), placement.trackIndex, placement.audioTrackIndex);
} catch (insertError) {
return __error("Batch insertion " + i + " threw after " + results.length + " verified placement(s): " + insertError.toString());
}
var matched = null;
var addedCount = 0;
for (c = 0; c < videoTrack.clips.numItems; c++) {
var videoClip = videoTrack.clips[c];
if (!beforeVideoIds[videoClip.nodeId]) {
addedCount++;
if (videoClip.projectItem && videoClip.projectItem.nodeId === placement.item.nodeId) matched = videoClip;
}
}
if (audioTrack) {
for (c = 0; c < audioTrack.clips.numItems; c++) {
var audioClip = audioTrack.clips[c];
if (!beforeAudioIds[audioClip.nodeId]) {
addedCount++;
if (!matched && audioClip.projectItem && audioClip.projectItem.nodeId === placement.item.nodeId) matched = audioClip;
}
}
}
if (!matched) {
return __error("Batch insertion " + i + " did not produce the requested project item after " + results.length + " verified placement(s). The batch is not reported as verified.");
}
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var tolerance = __ticksToSeconds(frameTicks);
var actualStart = __ticksToSeconds(matched.start.ticks);
if (Math.abs(actualStart - placement.startSeconds) > tolerance) {
return __error("Batch insertion " + i + " landed at " + actualStart + "s instead of " + placement.startSeconds + "s after " + results.length + " verified placement(s). The batch is not reported as verified.");
}
results.push({
item: placement.item.name,
itemId: placement.item.nodeId,
videoTrackIndex: placement.trackIndex,
requestedStartSeconds: placement.startSeconds,
actualStartSeconds: actualStart,
insertedTrackItems: addedCount
});
}
return __result({ inserted: true, verified: true, placementCount: results.length, placements: results });
`);
return sendCommand(script, bridgeOptions);
},
},
crop_clip: {
description:
"Apply or update Premiere's Crop effect on one video clip and read back every requested value. Adding Crop uses the legacy QE catalog only when the clip does not already contain it.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
node_id: { type: "string", minLength: 1, maxLength: 512, description: "Timeline video-clip node ID." },
left: { type: "number", minimum: 0, maximum: 100, description: "Percent cropped from the left edge." },
right: { type: "number", minimum: 0, maximum: 100, description: "Percent cropped from the right edge." },
top: { type: "number", minimum: 0, maximum: 100, description: "Percent cropped from the top edge." },
bottom: { type: "number", minimum: 0, maximum: 100, description: "Percent cropped from the bottom edge." },
edge_feather: { type: "number", minimum: 0, maximum: 100, description: "Crop edge feather percentage." },
zoom: { type: "boolean", description: "Whether Crop should scale the remaining image to fill the frame." },
},
required: ["node_id"],
},
handler: async (args: {
node_id: string;
left?: number;
right?: number;
top?: number;
bottom?: number;
edge_feather?: number;
zoom?: boolean;
}) => {
const numeric = [args.left, args.right, args.top, args.bottom, args.edge_feather];
if (!args.node_id || numeric.some((value) => value !== undefined && (!finiteNumber(value) || value < 0 || value > 100))) {
return { success: false, error: "node_id is required and crop percentages must be finite numbers from 0 through 100." };
}
if (numeric.every((value) => value === undefined) && args.zoom === undefined) {
return { success: false, error: "crop_clip requires at least one crop value or zoom setting; no effect was added." };
}
const changes: Array<{ name: string; value: string }> = [];
if (args.left !== undefined) changes.push({ name: "Left", value: String(args.left) });
if (args.right !== undefined) changes.push({ name: "Right", value: String(args.right) });
if (args.top !== undefined) changes.push({ name: "Top", value: String(args.top) });
if (args.bottom !== undefined) changes.push({ name: "Bottom", value: String(args.bottom) });
if (args.edge_feather !== undefined) changes.push({ name: "Edge Feather", value: String(args.edge_feather) });
if (args.zoom !== undefined) changes.push({ name: "Zoom", value: args.zoom ? "1" : "0" });
const emittedChanges = changes.map((change) => `{ name: "${change.name}", value: ${change.value} }`).join(", ");
const script = buildToolScript(`
var found = __findClip("${escapeForExtendScript(args.node_id)}");
if (!found) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
if (found.trackType !== "video") return __error("crop_clip only supports video timeline clips.");
var clip = found.clip;
var crop = null;
var componentIndex = -1;
var i;
for (i = 0; i < clip.components.numItems; i++) {
var component = clip.components[i];
if (component && (component.displayName === "Crop" || component.matchName === "AE.ADBE AECrop")) {
crop = component;
componentIndex = i;
break;
}
}
var effectAdded = false;
if (!crop) {
var catalog = __getQeEffectCatalog("video");
if (!catalog.ok) return __error(catalog.error + " Crop was not added.");
var cropEffect = null;
for (i = 0; i < catalog.effects.numItems; i++) {
if (catalog.effects[i].name === "Crop") { cropEffect = catalog.effects[i]; break; }
}
if (!cropEffect) return __error("The legacy QE video-effect catalog does not contain Crop. No effect was added.");
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence is available through QE. Crop was not added.");
var qeTrack = qeSeq.getVideoTrackAt(found.trackIndex);
if (!qeTrack) return __error("QE video track was not found. Crop was not added.");
// QE item indexes include gaps, so use the DOM clip start rather
// than assuming its DOM array index addresses the QE item.
var qeClip = null;
var expectedStart = parseFloat(clip.start.ticks);
for (i = 0; i < qeTrack.numItems; i++) {
var candidate = qeTrack.getItemAt(i);
if (!candidate || String(candidate.type) !== "Clip") continue;
if (candidate.start && Math.abs(parseFloat(candidate.start.ticks) - expectedStart) < 1) { qeClip = candidate; break; }
}
if (!qeClip || typeof qeClip.addVideoEffect !== "function") return __error("QE could not address the target video clip for Crop. Crop was not added.");
try { qeClip.addVideoEffect(cropEffect); } catch (addError) { return __error("Premiere rejected the Crop effect addition: " + addError.toString()); }
for (i = 0; i < clip.components.numItems; i++) {
component = clip.components[i];
if (component && (component.displayName === "Crop" || component.matchName === "AE.ADBE AECrop")) {
crop = component;
componentIndex = i;
break;
}
}
if (!crop) return __error("Premiere accepted the Crop addition but the component did not appear on the target clip.");
effectAdded = true;
}
var requests = [${emittedChanges}];
var resolved = [];
for (i = 0; i < requests.length; i++) {
var request = requests[i];
var property = null;
for (var p = 0; p < crop.properties.numItems; p++) {
if (String(crop.properties[p].displayName) === request.name) { property = crop.properties[p]; break; }
}
if (!property) return __error("Crop does not expose the '" + request.name + "' property on this host. No requested crop value was written.");
resolved.push({ name: request.name, requested: request.value, property: property });
}
var written = [];
for (i = 0; i < resolved.length; i++) {
var target = resolved[i];
try { target.property.setValue(target.requested, true); } catch (writeError) {
return __error("Premiere rejected Crop " + target.name + ": " + writeError.toString());
}
var actual;
try { actual = target.property.getValue(); } catch (readError) {
return __error("Premiere did not provide Crop " + target.name + " after writing it; the crop is not reported as verified.");
}
if (Math.abs(Number(actual) - Number(target.requested)) > 0.0001) {
return __error("Premiere read back Crop " + target.name + " as " + actual + " instead of " + target.requested + "; the crop is not reported as verified.");
}
written.push({ name: target.name, value: actual });
}
return __result({
updated: true,
verified: true,
renderVerified: false,
verificationScope: "Premiere component readback only; verify playback or an exported frame before delivery.",
clipName: clip.name,
effectAdded: effectAdded,
componentIndex: componentIndex,
values: written
});
`);
return sendCommand(script, bridgeOptions);
},
},
setup_ducking: {
description:
"Build a verified Volume > Level keyframe curve for one audio clip. Ducking-window times are relative to that clip's start; overlapping or out-of-range windows are rejected before any keyframe write.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
node_id: { type: "string", minLength: 1, maxLength: 512, description: "Timeline audio-clip node ID." },
base_db: { type: "number", description: "Normal clip level in dB (defaults to 0)." },
ducking_windows: {
type: "array",
minItems: 0,
maxItems: 32,
description: "Non-overlapping windows during which this clip should be quieter.",
items: {
type: "object",
additionalProperties: false,
properties: {
start_seconds: { type: "number", minimum: 0, description: "Window start, relative to clip start." },
end_seconds: { type: "number", minimum: 0, description: "Window end, relative to clip start." },
ducked_db: { type: "number", description: "Level during the window, in dB." },
},
required: ["start_seconds", "end_seconds", "ducked_db"],
},
},
fade_seconds: { type: "number", exclusiveMinimum: 0, description: "Fade length on each side of a window in seconds (defaults to 0.2)." },
},
required: ["node_id", "ducking_windows"],
},
handler: async (args: { node_id: string; base_db?: number; ducking_windows: DuckingWindow[]; fade_seconds?: number }) => {
const baseDb = args.base_db ?? 0;
const fadeSeconds = args.fade_seconds ?? 0.2;
if (!args.node_id || !finiteNumber(baseDb) || !finiteNumber(fadeSeconds) || fadeSeconds <= 0 || !Array.isArray(args.ducking_windows) || args.ducking_windows.length > 32) {
return { success: false, error: "node_id, a finite base_db, 0–32 ducking windows, and a finite positive fade_seconds are required." };
}
const windows = args.ducking_windows.map((window, index) => ({ ...window, index })).sort((left, right) => left.start_seconds - right.start_seconds);
for (let index = 0; index < windows.length; index++) {
const window = windows[index];
if (!finiteNonNegativeNumber(window.start_seconds) || !finiteNonNegativeNumber(window.end_seconds) || !finiteNumber(window.ducked_db) || window.end_seconds <= window.start_seconds) {
return { success: false, error: `ducking_windows[${window.index}] needs finite non-negative bounds with end_seconds greater than start_seconds, plus a finite ducked_db.` };
}
if (index > 0 && window.start_seconds < windows[index - 1].end_seconds) {
return { success: false, error: "ducking_windows must not overlap; merge intersecting windows before applying automation." };
}
}
const emittedWindows = windows.map((window) => `{ startSeconds: ${window.start_seconds}, endSeconds: ${window.end_seconds}, duckedDb: ${window.ducked_db} }`).join(", ");
const script = buildToolScript(`
var found = __findClip("${escapeForExtendScript(args.node_id)}");
if (!found) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
if (found.trackType !== "audio") return __error("setup_ducking only supports audio timeline clips. Target the music or SFX audio clip, not its linked video clip.");
var clip = found.clip;
var duration = __ticksToSeconds(clip.duration.ticks);
if (!isFinite(duration) || duration <= 0) return __error("The audio clip has no readable positive duration; no automation was written.");
var windows = [${emittedWindows}];
var i;
for (i = 0; i < windows.length; i++) {
if (windows[i].endSeconds > duration + 0.0001) {
return __error("Ducking window " + i + " ends at " + windows[i].endSeconds + "s but the clip duration is " + duration + "s. No automation was written.");
}
}
var level = null;
for (i = 0; i < clip.components.numItems; i++) {
var component = clip.components[i];
if (component.displayName === "Volume" || component.matchName === "audioVolume") {
for (var p = 0; p < component.properties.numItems; p++) {
if (component.properties[p].displayName === "Level") { level = component.properties[p]; break; }
}
}
if (level) break;
}
if (!level) return __error("Could not find the audio Volume > Level property. No automation was written.");
var keyMap = {};
function putKey(seconds, db) {
var bounded = Math.max(0, Math.min(duration, seconds));
var key = String(Math.round(bounded * 1000) / 1000);
keyMap[key] = { seconds: Number(key), db: db };
}
putKey(0, ${baseDb});
for (i = 0; i < windows.length; i++) {
var window = windows[i];
putKey(window.startSeconds - ${fadeSeconds}, ${baseDb});
putKey(window.startSeconds, window.duckedDb);
putKey(window.endSeconds, window.duckedDb);
putKey(window.endSeconds + ${fadeSeconds}, ${baseDb});
}
putKey(duration, ${baseDb});
var keys = [];
for (var rawKey in keyMap) if (keyMap.hasOwnProperty(rawKey)) keys.push(keyMap[rawKey]);
keys.sort(function(left, right) { return left.seconds - right.seconds; });
try { level.setTimeVarying(true); } catch (varyingError) {
return __error("Premiere could not enable Level keyframes: " + varyingError.toString());
}
var verified = [];
for (i = 0; i < keys.length; i++) {
var key = keys[i];
var time = new Time();
time.ticks = __secondsToTicks(key.seconds).toString();
var amplitude = Math.max(Math.pow(10, key.db / 20), 0.0000001);
try { level.addKey(time); } catch (addKeyError) {}
var wrote = false;
try { level.setValueAtKey(time, amplitude, 1); wrote = true; } catch (atKeyError) {
try { level.setValueAtTime(time, amplitude, 1); wrote = true; } catch (atTimeError) {}
}
var actual = NaN;
try { actual = Number(level.getValueAtTime(time)); } catch (readError) {}
if (!wrote || isNaN(actual) || Math.abs(actual - amplitude) > 0.0001) {
return __error("Premiere did not verify audio keyframe " + i + " at " + key.seconds + "s. Earlier keyframes may exist; inspect Volume > Level before retrying.");
}
verified.push({ timeSeconds: key.seconds, levelDb: key.db, amplitude: actual });
}
return __result({
updated: true,
verified: true,
renderVerified: false,
verificationScope: "Premiere keyframe storage readback only; verify playback or an exported mix before delivery.",
clipName: clip.name,
clipDurationSeconds: duration,
duckingWindowCount: windows.length,
fadeSeconds: ${fadeSeconds},
keyframes: verified
});
`);
return sendCommand(script, bridgeOptions);
},
},
validate_project_for_export: {
description:
"Run a non-mutating export readiness audit for an active or named sequence. It reports blocking offline media, empty timelines, inaccessible preset/output paths, duration, and optional timeline gaps without queuing an export.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
sequence_id: { type: "string", minLength: 1, maxLength: 512, description: "Sequence ID or name. Defaults to the active sequence." },
output_path: { type: "string", minLength: 1, maxLength: 4096, description: "Optional intended delivery path; its parent folder is checked." },
preset_path: { type: "string", minLength: 1, maxLength: 4096, description: "Optional .epr preset path to check." },
require_non_empty_timeline: { type: "boolean", description: "Treat an empty sequence as a blocking error (defaults to true)." },
check_gaps: { type: "boolean", description: "Report gaps on populated tracks as warnings (defaults to true)." },
},
},
handler: async (args: {
sequence_id?: string;
output_path?: string;
preset_path?: string;
require_non_empty_timeline?: boolean;
check_gaps?: boolean;
}) => {
const requireNonEmpty = args.require_non_empty_timeline !== false;
const checkGaps = args.check_gaps !== false;
const sequenceLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found: ${escapeForExtendScript(args.sequence_id)}");`
: "var seq = app.project.activeSequence; if (!seq) return __error(\"No active sequence\");";
const outputPath = args.output_path ? `"${escapeForExtendScript(args.output_path)}"` : "";
const presetPath = args.preset_path ? `"${escapeForExtendScript(args.preset_path)}"` : "";
const script = buildToolScript(`
${sequenceLookup}
var errors = [];
var warnings = [];
var usedSources = {};
var offlineMedia = [];
var gaps = [];
var totalVideoClips = 0;
var totalAudioClips = 0;
function secondsOf(value) {
if (value === undefined || value === null) return NaN;
if (typeof value === "number") return value;
if (typeof value === "string") return __ticksToSeconds(value);
try {
if (value.seconds !== undefined) return Number(value.seconds);
if (value.ticks !== undefined) return __ticksToSeconds(value.ticks);
} catch (timeError) {}
return NaN;
}
function inspectTracks(tracks, trackType) {
for (var t = 0; t < tracks.numTracks; t++) {
var track = tracks[t];
var cursor = 0;
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
var start = __ticksToSeconds(clip.start.ticks);
var end = __ticksToSeconds(clip.end.ticks);
if (${checkGaps ? "true" : "false"} && start - cursor > 0.05) {
gaps.push({ trackType: trackType, trackIndex: t, startSeconds: cursor, endSeconds: start, durationSeconds: start - cursor });
}
if (end > cursor) cursor = end;
if (trackType === "video") totalVideoClips++; else totalAudioClips++;
try {
var source = clip.projectItem;
if (source && !usedSources[source.nodeId]) {
usedSources[source.nodeId] = true;
var offline = false;
var mediaPath = "";
try { offline = Boolean(source.isOffline()); } catch (offlineError) {}
try { mediaPath = String(source.getMediaPath() || ""); } catch (pathError) {}
if (offline) offlineMedia.push({ nodeId: source.nodeId, name: source.name, mediaPath: mediaPath, offline: true });
}
} catch (sourceError) {}
}
}
}
inspectTracks(seq.videoTracks, "video");
inspectTracks(seq.audioTracks, "audio");
var totalClips = totalVideoClips + totalAudioClips;
if (${requireNonEmpty ? "true" : "false"} && totalClips === 0) {
errors.push({ code: "EMPTY_TIMELINE", message: "The sequence has no video or audio clips." });
}
if (totalVideoClips === 0) warnings.push({ code: "NO_VIDEO_CLIPS", message: "The sequence has no video clips." });
if (totalAudioClips === 0) warnings.push({ code: "NO_AUDIO_CLIPS", message: "The sequence has no audio clips." });
if (offlineMedia.length) errors.push({ code: "OFFLINE_MEDIA", message: "One or more media items used by this sequence are offline.", items: offlineMedia });
if (${checkGaps ? "true" : "false"} && gaps.length) warnings.push({ code: "TIMELINE_GAPS", message: "Gaps were found on populated tracks; verify that they are intentional.", gaps: gaps });
var duration = secondsOf(seq.end);
if (!isFinite(duration) || duration <= 0) errors.push({ code: "ZERO_DURATION", message: "The sequence has no readable positive duration." });
var presetPath = ${presetPath ? presetPath : "null"};
if (presetPath) {
var preset = new File(presetPath);
if (!preset.exists) errors.push({ code: "PRESET_NOT_FOUND", message: "The requested export preset does not exist.", path: presetPath });
else if (!/\\.epr$/i.test(presetPath)) warnings.push({ code: "PRESET_EXTENSION", message: "The preset exists but does not use the expected .epr extension.", path: presetPath });
} else {
warnings.push({ code: "PRESET_NOT_PROVIDED", message: "No preset_path was provided, so preset readiness was not checked." });
}
var outputPath = ${outputPath ? outputPath : "null"};
if (outputPath) {
var destination = new File(outputPath).parent;
if (!destination || !destination.exists) errors.push({ code: "OUTPUT_FOLDER_NOT_FOUND", message: "The requested output folder does not exist.", path: outputPath });
} else {
warnings.push({ code: "OUTPUT_PATH_NOT_PROVIDED", message: "No output_path was provided, so destination readiness was not checked." });
}
return __result({
readyForExport: errors.length === 0,
errors: errors,
warnings: warnings,
summary: {
sequenceId: seq.sequenceID,
sequenceName: seq.name,
durationSeconds: duration,
videoTrackCount: seq.videoTracks.numTracks,
audioTrackCount: seq.audioTracks.numTracks,
videoClipCount: totalVideoClips,
audioClipCount: totalAudioClips,
offlineMediaCount: offlineMedia.length,
gapCount: gaps.length
},
checked: {
requireNonEmptyTimeline: ${requireNonEmpty ? "true" : "false"},
checkGaps: ${checkGaps ? "true" : "false"},
presetPath: presetPath,
outputPath: outputPath
}
});
`);
return sendCommand(script, bridgeOptions);
},
},
read_sequence_captions: {
description:
"Diagnose whether the active Premiere scripting host can enumerate caption tracks. It never treats an empty result as proof that the sequence has no captions, because most CEP builds expose caption creation but not caption reads.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
sequence_id: { type: "string", minLength: 1, maxLength: 512, description: "Sequence ID or name. Defaults to the active sequence." },
},
},
handler: async (args: { sequence_id?: string }) => {
const sequenceLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found: ${escapeForExtendScript(args.sequence_id)}");`
: "var seq = app.project.activeSequence; if (!seq) return __error(\"No active sequence\");";
const script = buildToolScript(`
${sequenceLookup}
var tracks = null;
var probe = "none";
try {
if (typeof seq.getCaptionTracks === "function") { tracks = seq.getCaptionTracks(); probe = "getCaptionTracks"; }
else if (seq.captionTracks) { tracks = seq.captionTracks; probe = "captionTracks"; }
} catch (probeError) { tracks = null; probe = "error"; }
var trackCount = 0;
var captions = [];
if (tracks) {
try { trackCount = tracks.numTracks !== undefined ? tracks.numTracks : tracks.length; } catch (countError) { trackCount = 0; }
for (var t = 0; t < trackCount; t++) {
var track = tracks[t];
var clips = track && (track.clips || track.captions);
var clipCount = clips ? (clips.numItems !== undefined ? clips.numItems : clips.length) : 0;
for (var c = 0; c < clipCount; c++) {
var caption = clips[c];
var start = null;
var end = null;
var text = null;
try { start = caption.start && caption.start.ticks !== undefined ? __ticksToSeconds(caption.start.ticks) : null; } catch (startError) {}
try { end = caption.end && caption.end.ticks !== undefined ? __ticksToSeconds(caption.end.ticks) : null; } catch (endError) {}
try { text = typeof caption.text === "string" ? caption.text : (typeof caption.captionText === "string" ? caption.captionText : null); } catch (textError) {}
captions.push({ trackIndex: t, startSeconds: start, endSeconds: end, text: text });
}
}
}
var supported = probe !== "none" && probe !== "error";
return __result({
sequenceId: seq.sequenceID,
sequenceName: seq.name,
captionReadSupported: supported,
hostProbe: probe,
trackCount: trackCount,
captionCount: captions.length,
captions: captions,
note: supported
? "Caption tracks were exposed by this host. Text and timing are script readback only; verify rendered subtitles before delivery."
: "Premiere CEP normally exposes createCaptionTrack but not a caption-track read API. trackCount:0 and captions:[] do not prove this sequence has no captions. Read the source SRT/VTT outside Premiere when cue text and timing are required."
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_clip_properties_batch: {
description:
"Apply Motion/Opacity values to up to 16 clips after preflighting every target property. The handler reads each requested value back and never reports a partial batch as verified.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
items: {
type: "array",
minItems: 1,
maxItems: 16,
description: "Each item changes one or more supported opacity, Motion scale, position, or rotation values.",
items: {
type: "object",
additionalProperties: false,
properties: {
node_id: { type: "string", minLength: 1, maxLength: 512, description: "Timeline clip node ID." },
opacity: { type: "number", minimum: 0, maximum: 100, description: "Opacity percentage." },
scale: { type: "number", exclusiveMinimum: 0, description: "Motion scale percentage." },
position_x: { type: "number", description: "Motion horizontal position in sequence pixels." },
position_y: { type: "number", description: "Motion vertical position in sequence pixels." },
rotation: { type: "number", description: "Motion rotation in degrees." },
speed: { type: "number", description: "Unsupported; supplying it fails before any clip is changed." },
},
required: ["node_id"],
},
},
},
required: ["items"],
},
handler: async (args: { items: ClipPropertyBatchItem[] }) => {
if (!Array.isArray(args.items) || args.items.length < 1 || args.items.length > 16) {
return { success: false, error: "items must contain between 1 and 16 clip updates." };
}
const seen = new Set<string>();
for (let index = 0; index < args.items.length; index++) {
const item = args.items[index];
if (!item || typeof item.node_id !== "string" || item.node_id.length < 1 || item.node_id.length > 512) {
return { success: false, error: `items[${index}].node_id must be a non-empty string no longer than 512 characters.` };
}
if (seen.has(item.node_id)) return { success: false, error: `items contains duplicate node_id '${item.node_id}'; combine changes for each clip into one item.` };
seen.add(item.node_id);
if (item.speed !== undefined) {
return { success: false, error: "Changing timeline speed is not exposed by Premiere's supported scripting APIs. No batch mutation was attempted." };
}
if ((item.opacity !== undefined && (!finiteNumber(item.opacity) || item.opacity < 0 || item.opacity > 100)) ||
(item.scale !== undefined && (!finiteNumber(item.scale) || item.scale <= 0)) ||
(item.position_x !== undefined && !finiteNumber(item.position_x)) ||
(item.position_y !== undefined && !finiteNumber(item.position_y)) ||
(item.rotation !== undefined && !finiteNumber(item.rotation))) {
return { success: false, error: `items[${index}] has an invalid property value.` };
}
if (item.opacity === undefined && item.scale === undefined && item.position_x === undefined && item.position_y === undefined && item.rotation === undefined) {
return { success: false, error: `items[${index}] must specify at least one supported property.` };
}
}
const emittedItems = args.items.map((item) => `{
nodeId: "${escapeForExtendScript(item.node_id)}",
opacity: ${item.opacity === undefined ? "null" : item.opacity},
scale: ${item.scale === undefined ? "null" : item.scale},
positionX: ${item.position_x === undefined ? "null" : item.position_x},
positionY: ${item.position_y === undefined ? "null" : item.position_y},
rotation: ${item.rotation === undefined ? "null" : item.rotation}
}`).join(",");
const script = buildToolScript(`
var specs = [${emittedItems}];
var prepared = [];
var i;
function findProperty(clip, componentName, matchName, propertyName) {
for (var ci = 0; ci < clip.components.numItems; ci++) {
var component = clip.components[ci];
if (!component || (component.displayName !== componentName && component.matchName !== matchName)) continue;
for (var pi = 0; pi < component.properties.numItems; pi++) {
if (component.properties[pi].displayName === propertyName) return component.properties[pi];
}
}
return null;
}
// Resolve all required property handles and old values before writing
// any clip. A missing component on a later clip therefore cannot cause
// a partial batch.
for (i = 0; i < specs.length; i++) {
var spec = specs[i];
var found = __findClip(spec.nodeId);
if (!found) return __error("Clip not found for batch item " + i + ": " + spec.nodeId + ". No batch mutation was attempted.");
var resolved = { spec: spec, clip: found.clip, opacity: null, scale: null, position: null, rotation: null, old: {} };
if (spec.opacity !== null) {
resolved.opacity = findProperty(found.clip, "Opacity", "AE.ADBE Opacity", "Opacity");
if (!resolved.opacity) return __error("Opacity is unavailable on batch item " + i + ". No batch mutation was attempted.");
try { resolved.old.opacity = resolved.opacity.getValue(); } catch (opacityReadError) { return __error("Opacity could not be read on batch item " + i + ". No batch mutation was attempted."); }
}
if (spec.scale !== null || spec.positionX !== null || spec.positionY !== null || spec.rotation !== null) {
if (spec.scale !== null) {
resolved.scale = findProperty(found.clip, "Motion", "AE.ADBE Motion", "Scale");
if (!resolved.scale) return __error("Motion Scale is unavailable on batch item " + i + ". No batch mutation was attempted.");
try { resolved.old.scale = resolved.scale.getValue(); } catch (scaleReadError) { return __error("Scale could not be read on batch item " + i + ". No batch mutation was attempted."); }
}
if (spec.positionX !== null || spec.positionY !== null) {
resolved.position = findProperty(found.clip, "Motion", "AE.ADBE Motion", "Position");
if (!resolved.position) return __error("Motion Position is unavailable on batch item " + i + ". No batch mutation was attempted.");
try {
resolved.old.position = resolved.position.getValue();
if (!resolved.old.position || resolved.old.position.length < 2) return __error("Motion Position was unreadable on batch item " + i + ". No batch mutation was attempted.");
} catch (positionReadError) { return __error("Position could not be read on batch item " + i + ". No batch mutation was attempted."); }
}
if (spec.rotation !== null) {
resolved.rotation = findProperty(found.clip, "Motion", "AE.ADBE Motion", "Rotation");
if (!resolved.rotation) return __error("Motion Rotation is unavailable on batch item " + i + ". No batch mutation was attempted.");
try { resolved.old.rotation = resolved.rotation.getValue(); } catch (rotationReadError) { return __error("Rotation could not be read on batch item " + i + ". No batch mutation was attempted."); }
}
}
prepared.push(resolved);
}
var applied = [];
function restoreAll() {
for (var ri = 0; ri < prepared.length; ri++) {
var prior = prepared[ri];
try { if (prior.opacity) prior.opacity.setValue(prior.old.opacity, true); } catch (restoreOpacityError) {}
try { if (prior.scale) prior.scale.setValue(prior.old.scale, true); } catch (restoreScaleError) {}
try { if (prior.position) prior.position.setValue(prior.old.position, true); } catch (restorePositionError) {}
try { if (prior.rotation) prior.rotation.setValue(prior.old.rotation, true); } catch (restoreRotationError) {}
}
}
for (i = 0; i < prepared.length; i++) {
var update = prepared[i];
var spec = update.spec;
try {
if (update.opacity) update.opacity.setValue(spec.opacity, true);
if (update.scale) update.scale.setValue(spec.scale, true);
if (update.position) update.position.setValue([
spec.positionX === null ? update.old.position[0] : spec.positionX,
spec.positionY === null ? update.old.position[1] : spec.positionY
], true);
if (update.rotation) update.rotation.setValue(spec.rotation, true);
} catch (writeError) {
restoreAll();
return __error("Premiere rejected batch item " + i + "; a best-effort restore was attempted: " + writeError.toString());
}
var mismatch = false;
try { if (update.opacity && Math.abs(Number(update.opacity.getValue()) - Number(spec.opacity)) > 0.0001) mismatch = true; } catch (readOpacityError) { mismatch = true; }
try { if (update.scale && Math.abs(Number(update.scale.getValue()) - Number(spec.scale)) > 0.0001) mismatch = true; } catch (readScaleError) { mismatch = true; }
try {
if (update.position) {
var actualPosition = update.position.getValue();
var expectedX = spec.positionX === null ? update.old.position[0] : spec.positionX;
var expectedY = spec.positionY === null ? update.old.position[1] : spec.positionY;
if (!actualPosition || Math.abs(Number(actualPosition[0]) - Number(expectedX)) > 0.0001 || Math.abs(Number(actualPosition[1]) - Number(expectedY)) > 0.0001) mismatch = true;
}
} catch (readPositionError) { mismatch = true; }
try { if (update.rotation && Math.abs(Number(update.rotation.getValue()) - Number(spec.rotation)) > 0.0001) mismatch = true; } catch (readRotationError) { mismatch = true; }
if (mismatch) {
restoreAll();
return __error("Premiere did not verify batch item " + i + "; a best-effort restore was attempted. No partial batch is reported as verified.");
}
applied.push({ nodeId: spec.nodeId, clipName: update.clip.name });
}
return __result({ updated: true, verified: true, renderVerified: false, verificationScope: "Premiere parameter readback only; verify playback or exported frames before delivery.", items: applied });
`);
return sendCommand(script, bridgeOptions);
},
},
detect_scene_edits: {
description:
"Safe scene-edit facade. It uses the authenticated Premiere UXP bridge when connected and explicitly confirmed; CEP fallback is intentionally withheld because synchronous scene detection can block the panel.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
mode: { type: "string", enum: ["apply_cuts", "create_markers", "create_subclips"], description: "How Premiere should materialize detected edits on the current native selection." },
confirm_non_undoable: { type: "boolean", description: "Must be true because the host operation mutates the project and is not claimed undoable." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Optional idempotency key sent to the UXP bridge." },
},
required: ["mode", "confirm_non_undoable"],
},
handler: async (args: { mode: "apply_cuts" | "create_markers" | "create_subclips"; confirm_non_undoable: boolean; operation_id?: string }) => {
if (args.confirm_non_undoable !== true) {
return { success: false, error: "detect_scene_edits requires confirm_non_undoable:true. No scene detection was started." };
}
if (!uxpBridge) {
return {
success: false,
error:
"detect_scene_edits requires an authenticated Premiere UXP bridge. The CEP fallback is intentionally unavailable because Premiere's synchronous scene detection can block the panel. Connect UXP, then retry this confirmed request or call detect_scene_edits_uxp directly.",
};
}
const modes: Record<string, string> = {
apply_cuts: "applyCuts",
create_markers: "createMarkers",
create_subclips: "createSubclips",
};
const mode = modes[args.mode];
if (!mode) return { success: false, error: `Unsupported scene-edit mode: ${String(args.mode)}` };
return resultFromUxp(uxpBridge, "sceneEdit.detect", {
mode,
...(args.operation_id ? { operationId: args.operation_id } : {}),
});
},
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import { createHash } from "node:crypto";
export type DialogueSegment = {
id: string;
source_project_item_id: string;
transcript_revision: string;
start_seconds: number;
end_seconds: number;
text: string;
speaker_label?: string;
};
export type DialogueCandidateReason = "filler_word" | "repeated_phrase" | "long_silence";
export type DialogueEditCandidate = {
id: string;
reason: DialogueCandidateReason;
confidence: "deterministic" | "review_required";
source_project_item_id: string;
start_seconds: number;
end_seconds: number;
segment_ids: string[];
matched_text?: string;
};
const MAX_SEGMENTS = 10_000;
const MAX_CANDIDATES = 512;
const MAX_INPUT_BYTES = 5 * 1024 * 1024;
const REVISION = /^sha256:[a-f0-9]{64}$/;
function finiteRange(start: unknown, end: unknown, label: string) {
if (typeof start !== "number" || !Number.isFinite(start) || start < 0) throw new Error(`${label}.start_seconds must be a finite non-negative number`);
if (typeof end !== "number" || !Number.isFinite(end) || end <= start || end > 86_400) throw new Error(`${label}.end_seconds must be greater than start_seconds and at most 86400`);
return { start_seconds: Number(start.toFixed(6)), end_seconds: Number(end.toFixed(6)) };
}
function text(value: unknown, label: string, max: number): string {
if (typeof value !== "string" || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
return value.trim();
}
function normalizeWords(value: string): string {
return value.normalize("NFKC").toLocaleLowerCase().replace(/[^\p{L}\p{N}']+/gu, " ").trim().replace(/\s+/g, " ");
}
function stable(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stable(record[key])}`).join(",")}}`;
}
function digest(value: unknown): string {
return `sha256:${createHash("sha256").update(stable(value)).digest("hex")}`;
}
export function normalizeDialogueSegments(value: unknown): DialogueSegment[] {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SEGMENTS) throw new Error(`segments must contain between 1 and ${MAX_SEGMENTS} entries`);
if (Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_INPUT_BYTES) throw new Error("segments exceed the 5 MiB input limit");
const ids = new Set<string>();
const result = value.map((raw, index) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`segments[${index}] must be an object`);
const item = raw as Record<string, unknown>;
const allowed = ["id", "source_project_item_id", "transcript_revision", "start_seconds", "end_seconds", "text", "speaker_label"];
const unknown = Object.keys(item).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`segments[${index}] has an unknown field: ${unknown}`);
const id = text(item.id, `segments[${index}].id`, 128);
if (ids.has(id)) throw new Error(`segments contains duplicate id: ${id}`);
ids.add(id);
const revision = text(item.transcript_revision, `segments[${index}].transcript_revision`, 71);
if (!REVISION.test(revision)) throw new Error(`segments[${index}].transcript_revision must be a sha256 revision`);
const range = finiteRange(item.start_seconds, item.end_seconds, `segments[${index}]`);
const body = typeof item.text === "string" && item.text.length <= 1_000 ? item.text : undefined;
if (body === undefined) throw new Error(`segments[${index}].text must be a string of at most 1000 characters`);
const speaker = item.speaker_label === undefined ? undefined : text(item.speaker_label, `segments[${index}].speaker_label`, 128);
return {
id,
source_project_item_id: text(item.source_project_item_id, `segments[${index}].source_project_item_id`, 512),
transcript_revision: revision,
...range,
text: body,
...(speaker ? { speaker_label: speaker } : {}),
};
});
return result.sort((a, b) => a.source_project_item_id.localeCompare(b.source_project_item_id) || a.start_seconds - b.start_seconds || a.end_seconds - b.end_seconds);
}
function unionDuration(candidates: DialogueEditCandidate[]): number {
const bySource = new Map<string, Array<{ start: number; end: number }>>();
for (const item of candidates) {
const list = bySource.get(item.source_project_item_id) ?? [];
list.push({ start: item.start_seconds, end: item.end_seconds });
bySource.set(item.source_project_item_id, list);
}
let total = 0;
for (const ranges of bySource.values()) {
ranges.sort((a, b) => a.start - b.start || a.end - b.end);
let current: { start: number; end: number } | undefined;
for (const range of ranges) {
if (!current || range.start > current.end) {
if (current) total += current.end - current.start;
current = { ...range };
} else current.end = Math.max(current.end, range.end);
}
if (current) total += current.end - current.start;
}
return Number(total.toFixed(6));
}
export function analyzeDialogueEditCandidates(input: {
segments: unknown;
fillerWords?: unknown;
silenceRanges?: unknown;
minimumSilenceSeconds?: unknown;
}) {
const segments = normalizeDialogueSegments(input.segments);
const fillerWords = input.fillerWords === undefined ? [] : (() => {
if (!Array.isArray(input.fillerWords) || input.fillerWords.length > 64) throw new Error("filler_words must contain at most 64 entries");
const words = input.fillerWords.map((value, index) => normalizeWords(text(value, `filler_words[${index}]`, 64))).filter(Boolean);
if (new Set(words).size !== words.length) throw new Error("filler_words contains duplicates");
return words;
})();
const minimumSilence = input.minimumSilenceSeconds === undefined ? 0.7 : input.minimumSilenceSeconds;
if (typeof minimumSilence !== "number" || !Number.isFinite(minimumSilence) || minimumSilence < 0.1 || minimumSilence > 30) throw new Error("minimum_silence_seconds must be between 0.1 and 30");
const candidates: DialogueEditCandidate[] = [];
const add = (candidate: Omit<DialogueEditCandidate, "id">) => {
if (candidates.length >= MAX_CANDIDATES) return;
candidates.push({ ...candidate, id: digest(candidate) });
};
for (const segment of segments) {
const normalized = normalizeWords(segment.text);
for (const filler of fillerWords) {
const escaped = filler.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`(^|\\s)${escaped}(?=\\s|$)`, "u").test(normalized)) {
add({ reason: "filler_word", confidence: "review_required", source_project_item_id: segment.source_project_item_id, start_seconds: segment.start_seconds, end_seconds: segment.end_seconds, segment_ids: [segment.id], matched_text: filler });
break;
}
}
}
const priorBySource = new Map<string, DialogueSegment>();
for (const segment of segments) {
const prior = priorBySource.get(segment.source_project_item_id);
const normalized = normalizeWords(segment.text);
if (prior && normalized.length >= 8 && normalized === normalizeWords(prior.text)) {
add({ reason: "repeated_phrase", confidence: "review_required", source_project_item_id: segment.source_project_item_id, start_seconds: segment.start_seconds, end_seconds: segment.end_seconds, segment_ids: [prior.id, segment.id], matched_text: normalized.slice(0, 128) });
}
priorBySource.set(segment.source_project_item_id, segment);
}
if (input.silenceRanges !== undefined) {
if (!Array.isArray(input.silenceRanges) || input.silenceRanges.length > 2_000) throw new Error("silence_ranges must contain at most 2000 entries");
for (let index = 0; index < input.silenceRanges.length; index++) {
const raw = input.silenceRanges[index];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`silence_ranges[${index}] must be an object`);
const item = raw as Record<string, unknown>;
const unknown = Object.keys(item).find((key) => !["source_project_item_id", "start_seconds", "end_seconds"].includes(key));
if (unknown) throw new Error(`silence_ranges[${index}] has an unknown field: ${unknown}`);
const range = finiteRange(item.start_seconds, item.end_seconds, `silence_ranges[${index}]`);
if (range.end_seconds - range.start_seconds < minimumSilence) continue;
add({ reason: "long_silence", confidence: "deterministic", source_project_item_id: text(item.source_project_item_id, `silence_ranges[${index}].source_project_item_id`, 512), ...range, segment_ids: [] });
}
}
candidates.sort((a, b) => a.source_project_item_id.localeCompare(b.source_project_item_id) || a.start_seconds - b.start_seconds || a.reason.localeCompare(b.reason));
const truncated = candidates.length === MAX_CANDIDATES;
return {
analysis_revision: digest({ segments, fillerWords, silenceRanges: input.silenceRanges ?? [], minimumSilence }),
candidate_count: candidates.length,
candidates,
projected_removed_seconds: unionDuration(candidates),
truncated,
applied: false,
limitations: [
"Candidates are deterministic suggestions, not editorial approval.",
"Filler and repeated-phrase candidates cover whole supplied segments because word-level timing was not supplied.",
"No transcript text is retained or sent to a model provider by this server.",
],
};
}
export function getDialogueAnalysisTools() {
return {
analyze_dialogue_edit_candidates: {
description: "Analyze caller-supplied, revision-bound transcript segments and optional local silence ranges for deterministic dialogue-edit candidates. It never calls a model, persists transcript text, or changes Premiere.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
segments: { type: "array", minItems: 1, maxItems: MAX_SEGMENTS, description: "Normalized transcript segments with stable IDs, source IDs, transcript revisions, time ranges, text, and optional speaker labels.", items: { type: "object", additionalProperties: false, properties: {
id: { type: "string", minLength: 1, maxLength: 128, description: "Caller-stable segment ID." },
source_project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Exact Premiere source project-item ID." },
transcript_revision: { type: "string", pattern: "^sha256:[a-f0-9]{64}$", description: "Revision returned by get_clip_transcript_uxp." },
start_seconds: { type: "number", minimum: 0, maximum: 86400, description: "Source-time segment start." },
end_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400, description: "Source-time segment end." },
text: { type: "string", maxLength: 1000, description: "Local transcript text for this segment." },
speaker_label: { type: "string", minLength: 1, maxLength: 128, description: "Optional caller-normalized speaker label." },
}, required: ["id", "source_project_item_id", "transcript_revision", "start_seconds", "end_seconds", "text"] } },
filler_words: { type: "array", maxItems: 64, items: { type: "string", minLength: 1, maxLength: 64 }, description: "Exact normalized filler words or phrases to flag for review." },
silence_ranges: { type: "array", maxItems: 2000, description: "Optional source-time silence ranges returned by local analysis.", items: { type: "object", additionalProperties: false, properties: {
source_project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Exact Premiere source project-item ID." },
start_seconds: { type: "number", minimum: 0, maximum: 86400, description: "Silence start in source seconds." },
end_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400, description: "Silence end in source seconds." },
}, required: ["source_project_item_id", "start_seconds", "end_seconds"] } },
minimum_silence_seconds: { type: "number", minimum: 0.1, maximum: 30, description: "Minimum silence duration to return; defaults to 0.7 seconds." },
},
required: ["segments"],
},
handler: async (args: Record<string, unknown>) => {
try {
return { success: true, data: analyzeDialogueEditCandidates({ segments: args.segments, fillerWords: args.filler_words, silenceRanges: args.silence_ranges, minimumSilenceSeconds: args.minimum_silence_seconds }) };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
};
}
+453
View File
@@ -0,0 +1,453 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getDiscoveryTools(bridgeOptions: BridgeOptions) {
return {
get_project_info: {
description: "Get information about the currently open Premiere Pro project",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
var info = {
name: project.name,
path: project.path,
numSequences: project.sequences.numSequences,
numItems: project.rootItem.children.numItems,
activeSequence: null
};
var activeSeq = __getCurrentActiveSequence();
if (activeSeq) {
info.activeSequence = {
name: activeSeq.name,
id: activeSeq.sequenceID,
videoTracks: activeSeq.videoTracks.numTracks,
audioTracks: activeSeq.audioTracks.numTracks
};
}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
list_project_items: {
description: "List all items in the project panel (clips, bins, sequences)",
parameters: {
type: "object" as const,
properties: {
bin_path: {
type: "string",
description: "Optional bin path to list items from (e.g., 'Footage/Raw'). Lists root items if omitted.",
},
},
},
handler: async (args: { bin_path?: string }) => {
const binPath = args.bin_path ? `"${escapeForExtendScript(args.bin_path)}"` : "null";
const script = buildToolScript(`
var rootItem = app.project.rootItem;
var targetItem = rootItem;
var binPath = ${binPath};
if (binPath) {
var parts = binPath.split("/");
for (var p = 0; p < parts.length; p++) {
var found = false;
for (var i = 0; i < targetItem.children.numItems; i++) {
if (targetItem.children[i].name === parts[p] && targetItem.children[i].type === 2) {
targetItem = targetItem.children[i];
found = true;
break;
}
}
if (!found) return __error("Bin not found: " + binPath);
}
}
var items = [];
for (var i = 0; i < targetItem.children.numItems; i++) {
var item = targetItem.children[i];
var entry = {
nodeId: item.nodeId,
name: item.name,
type: item.type === 1 ? "clip" : item.type === 2 ? "bin" : item.type === 3 ? "sequence" : "unknown",
mediaPath: ""
};
try {
if (item.getMediaPath) entry.mediaPath = item.getMediaPath();
} catch(e) {}
items.push(entry);
}
return __result(items);
`);
return sendCommand(script, bridgeOptions);
},
},
list_sequences: {
description: "List all sequences in the project",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
var sequences = [];
for (var i = 0; i < project.sequences.numSequences; i++) {
var seq = project.sequences[i];
sequences.push({
name: seq.name,
id: seq.sequenceID,
videoTracks: seq.videoTracks.numTracks,
audioTracks: seq.audioTracks.numTracks,
inPoint: __ticksToSeconds(seq.zeroPoint.ticks),
end: __ticksToSeconds(seq.end)
});
}
return __result(sequences);
`);
return sendCommand(script, bridgeOptions);
},
},
get_active_sequence: {
description: "Get detailed information about the currently active sequence",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = __getCurrentActiveSequence();
if (!seq) return __error("No active sequence in the current project");
var videoTracks = [];
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
var clips = [];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
clips.push({
nodeId: clip.nodeId,
name: clip.name,
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
duration: __ticksToSeconds(clip.duration.ticks)
});
}
videoTracks.push({
index: t,
name: track.name,
numClips: track.clips.numItems,
clips: clips
});
}
var audioTracks = [];
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
var clips = [];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
clips.push({
nodeId: clip.nodeId,
name: clip.name,
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks)
});
}
audioTracks.push({
index: t,
name: track.name,
numClips: track.clips.numItems,
clips: clips
});
}
return __result({
name: seq.name,
id: seq.sequenceID,
frameSizeHorizontal: seq.frameSizeHorizontal,
frameSizeVertical: seq.frameSizeVertical,
end: __ticksToSeconds(seq.end),
videoTracks: videoTracks,
audioTracks: audioTracks
});
`);
return sendCommand(script, bridgeOptions);
},
},
list_sequence_tracks: {
description: "List all tracks (video and audio) in a sequence",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence ID or name. Uses active sequence if omitted.",
},
},
},
handler: async (args: { sequence_id?: string }) => {
const seqLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const script = buildToolScript(`
${seqLookup}
var tracks = [];
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
tracks.push({
index: t,
type: "video",
name: track.name,
numClips: track.clips.numItems,
isMuted: track.isMuted(),
isLocked: track.isLocked()
});
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
tracks.push({
index: t,
type: "audio",
name: track.name,
numClips: track.clips.numItems,
isMuted: track.isMuted()
});
}
return __result(tracks);
`);
return sendCommand(script, bridgeOptions);
},
},
get_clip_properties: {
description: "Get detailed properties of a specific clip by its node ID",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "The node ID of the clip",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
var props = {
nodeId: clip.nodeId,
name: clip.name,
trackIndex: result.trackIndex,
trackType: result.trackType,
clipIndex: result.clipIndex,
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
duration: __ticksToSeconds(clip.duration.ticks),
mediaType: clip.mediaType,
isSelected: clip.isSelected(),
isSpeedReversed: clip.isSpeedReversed ? clip.isSpeedReversed() : false
};
try {
if (clip.components) {
var effects = [];
for (var i = 0; i < clip.components.numItems; i++) {
effects.push({
displayName: clip.components[i].displayName,
matchName: clip.components[i].matchName
});
}
props.effects = effects;
}
} catch(e) {}
return __result(props);
`);
return sendCommand(script, bridgeOptions);
},
},
find_project_item_by_name: {
description: "Find a project item by name (searches recursively through bins)",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name of the project item to find",
},
},
required: ["name"],
},
handler: async (args: { name: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.name)}");
if (!item) return __error("Project item not found: ${escapeForExtendScript(args.name)}");
var info = {
nodeId: item.nodeId,
name: item.name,
type: item.type === 1 ? "clip" : item.type === 2 ? "bin" : item.type === 3 ? "sequence" : "unknown",
mediaPath: ""
};
try {
if (item.getMediaPath) info.mediaPath = item.getMediaPath();
} catch(e) {}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
get_sequence_settings: {
description: "Get the settings (resolution, frame rate, etc.) of a sequence",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence ID or name. Uses active sequence if omitted.",
},
},
},
handler: async (args: { sequence_id?: string }) => {
const seqLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const script = buildToolScript(`
${seqLookup}
var settings = {
name: seq.name,
id: seq.sequenceID,
frameSizeHorizontal: seq.frameSizeHorizontal,
frameSizeVertical: seq.frameSizeVertical,
timebase: seq.timebase,
end: __ticksToSeconds(seq.end),
videoTracks: seq.videoTracks.numTracks,
audioTracks: seq.audioTracks.numTracks
};
try {
var seqSettings = seq.getSettings();
if (seqSettings) {
settings.audioChannelCount = seqSettings.audioChannelCount;
settings.audioChannelType = seqSettings.audioChannelType;
settings.audioDisplayFormat = seqSettings.audioDisplayFormat;
settings.audioSampleRate = seqSettings.audioSampleRate;
settings.videoDisplayFormat = seqSettings.videoDisplayFormat;
settings.videoFieldType = seqSettings.videoFieldType;
settings.videoFrameRate = seqSettings.videoFrameRate;
}
} catch(e) {}
return __result(settings);
`);
return sendCommand(script, bridgeOptions);
},
},
get_selected_clips: {
description: "Get the currently selected clips in the active sequence",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var selection = seq.getSelection();
var clips = [];
for (var i = 0; i < selection.length; i++) {
var clip = selection[i];
clips.push({
nodeId: clip.nodeId,
name: clip.name,
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks),
mediaType: clip.mediaType
});
}
return __result(clips);
`);
return sendCommand(script, bridgeOptions);
},
},
get_clip_at_position: {
description: "Get the clip at a specific time position on a track",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
description: "Time position in seconds",
},
track_index: {
type: "number",
description: "Track index (0-based)",
},
track_type: {
type: "string",
enum: ["video", "audio"],
description: "Track type",
},
},
required: ["time_seconds", "track_index", "track_type"],
},
handler: async (args: { time_seconds: number; track_index: number; track_type: string }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var tracks = ${args.track_type === "video" ? "seq.videoTracks" : "seq.audioTracks"};
if (${args.track_index} >= tracks.numTracks) return __error("Track index out of range");
var track = tracks[${args.track_index}];
var targetTicks = __secondsToTicks(${args.time_seconds});
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
var startTicks = parseFloat(clip.start.ticks);
var endTicks = parseFloat(clip.end.ticks);
if (targetTicks >= startTicks && targetTicks < endTicks) {
return __result({
nodeId: clip.nodeId,
name: clip.name,
trackIndex: ${args.track_index},
trackType: "${args.track_type}",
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks)
});
}
}
return __error("No clip found at position " + ${args.time_seconds} + "s on ${args.track_type} track " + ${args.track_index});
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import { BridgeOptions, sendCommand } from "../bridge/file-bridge.js";
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import {
AuditSink,
CapabilityConfig,
createOperationId,
emitAudit,
requireCapability,
resolveCapabilities,
stderrAuditSink,
} from "../security/index.js";
type InsertClip = {
type: "insert_clip";
item_id: string;
start_seconds: number;
video_track_index?: number;
audio_track_index?: number;
};
type RemoveClip = { type: "remove_clip"; node_id: string; ripple?: boolean };
export type EditPlanOperation = InsertClip | RemoveClip;
export interface EditPlan { sequence_id?: string; operations: EditPlanOperation[] }
export interface EditPlanDependencies {
capabilities?: CapabilityConfig;
auditSink?: AuditSink;
operationIdFactory?: () => string;
}
function canonicalPlan(plan: EditPlan): string {
return JSON.stringify(plan);
}
export function confirmationToken(plan: EditPlan): string {
return createHash("sha256").update(canonicalPlan(plan)).digest("hex");
}
export function validateEditPlan(value: unknown): EditPlan {
if (!value || typeof value !== "object") throw new Error("plan must be an object");
const plan = value as Partial<EditPlan>;
if (!Array.isArray(plan.operations) || plan.operations.length === 0) {
throw new Error("plan.operations must contain at least one operation");
}
if (plan.operations.length > 100) throw new Error("edit plans are limited to 100 operations");
for (const [index, operation] of plan.operations.entries()) {
if (!operation || typeof operation !== "object") throw new Error(`operation ${index} must be an object`);
if (operation.type === "insert_clip") {
if (!operation.item_id) throw new Error(`operation ${index} requires item_id`);
if (!Number.isFinite(operation.start_seconds) || operation.start_seconds < 0) {
throw new Error(`operation ${index} start_seconds must be a non-negative number`);
}
for (const key of ["video_track_index", "audio_track_index"] as const) {
const n = operation[key];
if (n !== undefined && (!Number.isInteger(n) || n < 0)) throw new Error(`operation ${index} ${key} must be a non-negative integer`);
}
} else if (operation.type === "remove_clip") {
if (!operation.node_id) throw new Error(`operation ${index} requires node_id`);
} else {
throw new Error(`operation ${index} has unsupported type`);
}
}
return plan as EditPlan;
}
function describe(plan: EditPlan) {
return plan.operations.map((operation, index) => ({
index,
type: operation.type,
target: operation.type === "insert_clip" ? operation.item_id : operation.node_id,
destructive: operation.type === "remove_clip",
}));
}
function buildApplyScript(plan: EditPlan): string {
const sequence = plan.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(plan.sequence_id)}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const validation: string[] = [];
const mutations: string[] = [];
const needsClipLookup = plan.operations.some((operation) => operation.type === "remove_clip");
const clipLookup = needsClipLookup ? `
function __planFindClip(sequence, nodeId) {
var groups = [sequence.videoTracks, sequence.audioTracks];
for (var g = 0; g < groups.length; g++) {
for (var t = 0; t < groups[g].numTracks; t++) {
for (var c = 0; c < groups[g][t].clips.numItems; c++) {
if (String(groups[g][t].clips[c].nodeId) === String(nodeId)) return groups[g][t].clips[c];
}
}
}
return null;
}
` : "";
plan.operations.forEach((operation, index) => {
if (operation.type === "insert_clip") {
validation.push(`var item${index} = __findProjectItem("${escapeForExtendScript(operation.item_id)}"); if (!item${index}) return __error("Project item not found for operation ${index}");`);
mutations.push(`seq.insertClip(item${index}, __secondsToTicks(${operation.start_seconds}).toString(), ${operation.video_track_index ?? 0}, ${operation.audio_track_index ?? 0}); results.push({index:${index}, type:"insert_clip", applied:true});`);
} else {
validation.push(`var found${index} = __planFindClip(seq, "${escapeForExtendScript(operation.node_id)}"); if (!found${index}) return __error("Clip not found for operation ${index}");`);
mutations.push(`found${index}.remove(${operation.ripple === true ? "true" : "false"}, true); results.push({index:${index}, type:"remove_clip", applied:true});`);
}
});
return buildToolScript(`${sequence}\n${clipLookup}\n${validation.join("\n")}\nvar results = [];\n${mutations.join("\n")}\nreturn __result({applied:true, operations:results});`);
}
export function getEditPlanTools(bridgeOptions: BridgeOptions, dependencies: EditPlanDependencies = {}) {
const capabilities = dependencies.capabilities ?? resolveCapabilities();
const auditSink = dependencies.auditSink ?? stderrAuditSink;
const nextId = dependencies.operationIdFactory ?? createOperationId;
const planParameter = {
type: "object",
description: "An edit plan containing insert_clip and remove_clip operations (maximum 100)",
};
return {
preview_edit_plan: {
description: "Validate and preview a compound timeline edit without changing Premiere. Returns a confirmation token required by apply_edit_plan.",
parameters: { type: "object" as const, properties: { plan: planParameter }, required: ["plan"] },
handler: async (args: { plan: unknown }) => {
const operationId = nextId();
requireCapability(capabilities, "inspect", operationId);
const plan = validateEditPlan(args.plan);
return { success: true, data: { operationId, changes: describe(plan), confirmationToken: confirmationToken(plan), applied: false } };
},
},
apply_edit_plan: {
description: "Apply a previously previewed compound edit after revalidating every target. Requires the edit capability and exact preview confirmation token.",
parameters: {
type: "object" as const,
properties: { plan: planParameter, confirmation_token: { type: "string", description: "Exact token returned by preview_edit_plan" } },
required: ["plan", "confirmation_token"],
},
handler: async (args: { plan: unknown; confirmation_token: string }) => {
const operationId = nextId();
try {
requireCapability(capabilities, "edit", operationId);
const plan = validateEditPlan(args.plan);
if (args.confirmation_token !== confirmationToken(plan)) throw new Error("Confirmation token does not match this edit plan; preview it again");
emitAudit(auditSink, { operationId, action: "apply_edit_plan", outcome: "started", details: { operationCount: plan.operations.length } });
const result = await sendCommand(buildApplyScript(plan), bridgeOptions);
emitAudit(auditSink, { operationId, action: "apply_edit_plan", outcome: result.success ? "succeeded" : "failed" });
return result.success ? { ...result, data: { ...(result.data as object), operationId } } : { ...result, error: `${result.error ?? "Edit plan failed"} (operation ${operationId})` };
} catch (error) {
emitAudit(auditSink, { operationId, action: "apply_edit_plan", outcome: error instanceof Error && error.name === "CapabilityDeniedError" ? "denied" : "failed" });
throw error;
}
},
},
};
}
+159
View File
@@ -0,0 +1,159 @@
import {
buildEditorialContextPack,
DEFAULT_EDITORIAL_CONTEXT_PACK_CHARACTERS,
DEFAULT_EDITORIAL_CONTEXT_PACK_ENTRIES,
MAX_EDITORIAL_CONTEXT_PACK_CHARACTERS,
MAX_EDITORIAL_CONTEXT_PACK_ENTRIES,
MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS,
} from "../ai/editorial-context-pack.js";
import {
countProjectContextMatches,
ProjectContextRepository,
searchProjectContext,
type ProjectContextKind,
} from "../context/project-context-store.js";
export interface EditorialContextPackToolDependencies {
repository?: ProjectContextRepository;
}
function projectId(value: unknown): string {
if (typeof value !== "string" || !value.trim()) throw new Error("project_id is required");
const normalized = value.trim();
if (normalized.length > 512) throw new Error("project_id must be a non-empty string of at most 512 characters");
return normalized;
}
function optionalText(value: unknown, label: string, maximum: number): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !value.trim() || value.trim().length > maximum) {
throw new Error(`${label} must be a non-empty string of at most ${maximum} characters`);
}
return value.trim();
}
function optionalKinds(value: unknown): ProjectContextKind[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length < 1 || value.length > 8) {
throw new Error("kinds must contain between 1 and 8 context kinds");
}
const allowed = new Set<ProjectContextKind>(["project", "sequence", "source", "timeline", "transcript", "shot", "audio", "note"]);
const kinds = value.map((entry, index) => {
if (typeof entry !== "string" || !allowed.has(entry as ProjectContextKind)) {
throw new Error(`kinds[${index}] must be a supported project-context kind`);
}
return entry as ProjectContextKind;
});
if (new Set(kinds).size !== kinds.length) throw new Error("kinds must not contain duplicates");
return kinds;
}
function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number, label: string): number {
if (value === undefined) return fallback;
if (!Number.isInteger(value) || typeof value !== "number" || value < minimum || value > maximum) {
throw new Error(`${label} must be an integer from ${minimum} through ${maximum}`);
}
return value;
}
/**
* A transcript-first reading surface for agents. The tool only compacts local
* project-context evidence; it does not invoke transcription, a model, a
* provider, the Premiere bridge, or any timeline mutation.
*/
export function getEditorialContextPackTools(dependencies: EditorialContextPackToolDependencies = {}) {
const repository = dependencies.repository ?? new ProjectContextRepository();
return {
create_editorial_context_pack: {
description: "Create a compact Markdown reading view from already captured local transcript, shot, audio, note, source, or timeline context. It returns stable evidence IDs and context revisions for review, never calls an AI/provider or Premiere, and cannot change the project.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
project_id: { type: "string", minLength: 1, maxLength: 512, description: "Project context ID returned by manage_project_context capture." },
intent: { type: "string", minLength: 1, maxLength: 1_000, description: "The editorial question used to retrieve and compact local evidence." },
sequence_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional exact sequence ID filter." },
kinds: {
type: "array",
minItems: 1,
maxItems: 8,
uniqueItems: true,
items: { type: "string", enum: ["project", "sequence", "source", "timeline", "transcript", "shot", "audio", "note"] },
description: "Optional context-kind filter. Omit to retrieve all matching local evidence.",
},
max_entries: { type: "integer", minimum: 1, maximum: MAX_EDITORIAL_CONTEXT_PACK_ENTRIES, default: DEFAULT_EDITORIAL_CONTEXT_PACK_ENTRIES, description: "Maximum matching evidence entries to include." },
max_characters: { type: "integer", minimum: MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS, maximum: MAX_EDITORIAL_CONTEXT_PACK_CHARACTERS, default: DEFAULT_EDITORIAL_CONTEXT_PACK_CHARACTERS, description: "Strict maximum length of the Markdown reading view." },
},
required: ["project_id", "intent"],
},
operationalCapability: {
backend: "local" as const,
backends: ["local" as const],
status: "supported" as const,
minimumPremiereVersion: null,
authority: "inspect" as const,
verificationBoundary: "static_metadata_only" as const,
hostVerificationRequired: false,
notes: [
"Reads only existing local project-context evidence and emits a bounded Markdown view.",
"Does not parse an undocumented Premiere transcript schema, invoke a provider, or contact the Premiere bridge.",
"Returned revisions identify the captured context state; they are not licensed-host or editorial-quality verification.",
],
},
handler: async (args: {
project_id: string;
intent: string;
sequence_id?: string;
kinds?: ProjectContextKind[];
max_entries?: number;
max_characters?: number;
}) => {
try {
const id = projectId(args.project_id);
const intent = optionalText(args.intent, "intent", 1_000);
if (!intent) throw new Error("intent is required");
const sequenceId = optionalText(args.sequence_id, "sequence_id", 512);
const kinds = optionalKinds(args.kinds);
const maxEntries = boundedInteger(
args.max_entries,
DEFAULT_EDITORIAL_CONTEXT_PACK_ENTRIES,
1,
MAX_EDITORIAL_CONTEXT_PACK_ENTRIES,
"max_entries",
);
const maxCharacters = boundedInteger(
args.max_characters,
DEFAULT_EDITORIAL_CONTEXT_PACK_CHARACTERS,
MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS,
MAX_EDITORIAL_CONTEXT_PACK_CHARACTERS,
"max_characters",
);
const document = await repository.get(id);
if (!document) return { success: false, error: "Project context not found; capture it before creating an editorial context pack" };
const searchOptions = {
query: intent,
...(sequenceId ? { sequenceId } : {}),
...(kinds ? { kinds } : {}),
};
const results = searchProjectContext(document, {
...searchOptions,
limit: maxEntries,
}).filter((result) => result.matchedTerms.length > 0);
const totalResultCount = countProjectContextMatches(document, searchOptions);
return {
success: true,
data: buildEditorialContextPack(document, {
intent,
results,
totalResultCount,
maxEntries,
maxCharacters,
}),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
};
}
+601
View File
@@ -0,0 +1,601 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import {
buildEditorialPlan,
validateEditorialPlan,
type EditorialPlan,
type EditorialWorkflow,
type OrganizationRule,
type PlatformCutdownTarget,
} from "../ai/editorial-plan.js";
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
import { ProjectContextRepository } from "../context/project-context-store.js";
export interface EditorialPlanToolDependencies {
repository?: ProjectContextRepository;
uxpBridge?: UxpWebSocketBridge;
}
interface OrganizationSourceGuard {
evidenceId: string;
projectItemId: string;
expectedParentId: string;
}
interface ResolvedOrganizationOperation {
recommendationId: string;
destinationBinId?: string;
parentBinId?: string;
proposedBinName: string;
proposedColorIndex?: number;
sourceGuards: OrganizationSourceGuard[];
}
const MAX_ORGANIZATION_APPLICATIONS = 16;
const MAX_ORGANIZATION_SOURCE_GUARDS = 64;
const MAX_ISSUED_EDITORIAL_PLANS = 128;
interface IssuedEditorialPlan {
plan: EditorialPlan;
confirmationToken?: string;
}
type OrganizationAction = "create_bin" | "move" | "set_color";
interface VerifiedOrganizationAction {
recommendationId: string;
action: OrganizationAction;
projectItemId?: string;
verified: true;
}
interface UnverifiedOrganizationAttempt {
recommendationId: string;
action: OrganizationAction;
projectItemId?: string;
verified: false;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function projectId(value: unknown): string {
if (typeof value !== "string" || !value.trim()) throw new Error("project_id is required");
return value.trim();
}
function requiredText(value: unknown, field: string, maxLength = 512): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
const normalized = value.trim();
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function optionalText(value: unknown, field: string, maxLength = 512): string | undefined {
if (value === undefined) return undefined;
return requiredText(value, field, maxLength);
}
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function textValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/**
* A UXP mutation may claim success only when the panel returned the documented
* readback contract for that exact command. A bare `verified: true` is not
* enough: it would turn a malformed or stale panel response into a false
* successful editorial workflow.
*
* This validates a panel response, not a licensed Premiere host. The latter
* remains a separate release gate.
*/
function verifiedReadback(value: unknown, boundary: string): Record<string, unknown> | undefined {
const result = objectValue(value);
const operation = objectValue(result?.operation);
const verification = objectValue(operation?.verification);
const evidence = verification?.evidence;
const hasMatchingEvidence = Array.isArray(evidence) && evidence.some((entry) => {
const item = objectValue(entry);
return item?.type === boundary && item.verified === true;
});
if (
result?.verified !== true
|| result.outcome !== "verified"
|| result.verificationBoundary !== boundary
|| verification?.status !== "verified"
|| verification.boundary !== boundary
|| !hasMatchingEvidence
) {
return undefined;
}
return result;
}
function createdBinId(value: unknown): string | undefined {
const result = objectValue(value);
const itemRecord = objectValue(result?.item);
if (!itemRecord) return undefined;
const id = itemRecord.id ?? itemRecord.projectItemId;
return textValue(id);
}
function verifiedCreatedBin(value: unknown): string | undefined {
const result = verifiedReadback(value, "bin_child_id_readback");
if (!result || result.created !== true) return undefined;
return createdBinId(result);
}
function verifiedMove(value: unknown, destinationBinId: string): boolean {
const result = verifiedReadback(value, "project_item_parent_readback");
const after = objectValue(result?.after);
return result?.moved === true
&& textValue(result.destinationBinId) === destinationBinId
&& textValue(after?.parentId) === destinationBinId;
}
function verifiedColor(value: unknown, colorIndex: number): boolean {
const result = verifiedReadback(value, "project_item_color_readback");
const after = objectValue(result?.after);
return result?.updated === true && after?.colorLabelIndex === colorIndex;
}
function childOperationId(operationId: string, action: string, stableId: string): string {
const digest = createHash("sha256").update(`${operationId}:${action}:${stableId}`).digest("hex").slice(0, 16);
return `${operationId}:${action}:${digest}`;
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map((entry) => stableJson(entry)).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "\"__undefined__\"";
}
function planFingerprint(plan: EditorialPlan): string {
return createHash("sha256").update(stableJson(plan)).digest("hex");
}
function issuePlan(issuedPlans: Map<string, IssuedEditorialPlan>, plan: EditorialPlan): void {
const serverPlan = structuredClone(plan);
const fingerprint = planFingerprint(serverPlan);
const existing = issuedPlans.get(fingerprint);
if (existing && isDeepStrictEqual(existing.plan, serverPlan)) return;
if (issuedPlans.size >= MAX_ISSUED_EDITORIAL_PLANS) {
const oldestFingerprint = issuedPlans.keys().next().value;
if (oldestFingerprint) issuedPlans.delete(oldestFingerprint);
}
issuedPlans.set(fingerprint, { plan: serverPlan });
}
/**
* Plans are returned to clients for review, but only an exact, server-issued
* plan can be previewed or applied. The hash is an internal map index only;
* deep equality against the stored server plan is the authority check.
*/
function requireIssuedPlan(issuedPlans: ReadonlyMap<string, IssuedEditorialPlan>, plan: EditorialPlan): IssuedEditorialPlan {
const issued = issuedPlans.get(planFingerprint(plan));
if (!issued || !isDeepStrictEqual(issued.plan, plan)) {
throw new Error("Editorial plan was not issued by this server instance or its contents changed; create and preview a new plan");
}
return issued;
}
function confirmationTokenMatches(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
}
function resolveOrganizationOperations(plan: EditorialPlan, value: unknown): ResolvedOrganizationOperation[] {
if (!Array.isArray(value) || !value.length || value.length > MAX_ORGANIZATION_APPLICATIONS) {
throw new Error(`organization_operations must contain between 1 and ${MAX_ORGANIZATION_APPLICATIONS} entries`);
}
const recommendations = new Map(plan.recommendations
.filter((recommendation) => recommendation.kind === "organize_source")
.map((recommendation) => [recommendation.id, recommendation]));
const candidates = new Map(plan.candidates.map((candidate) => [candidate.evidenceId, candidate]));
const seenRecommendations = new Set<string>();
const seenEvidence = new Set<string>();
const seenProjectItemIds = new Set<string>();
let sourceGuardCount = 0;
return value.map((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`organization_operations[${index}] must be an object`);
}
const raw = entry as Record<string, unknown>;
const recommendationId = requiredText(raw.recommendation_id, `organization_operations[${index}].recommendation_id`, 128);
if (seenRecommendations.has(recommendationId)) throw new Error(`organization_operations contains duplicate recommendation_id: ${recommendationId}`);
seenRecommendations.add(recommendationId);
const recommendation = recommendations.get(recommendationId);
if (!recommendation) throw new Error(`organization_operations[${index}] must reference an organize_source recommendation`);
const proposedBinName = requiredText(recommendation.details.proposedBinName, `plan recommendation ${recommendationId}.details.proposedBinName`, 255);
const proposedColorIndex = recommendation.details.proposedColorIndex;
if (proposedColorIndex !== undefined && (!Number.isInteger(proposedColorIndex) || typeof proposedColorIndex !== "number" || proposedColorIndex < 0 || proposedColorIndex > 14)) {
throw new Error(`plan recommendation ${recommendationId} has an invalid proposedColorIndex`);
}
const destinationBinId = optionalText(raw.destination_bin_id, `organization_operations[${index}].destination_bin_id`);
const parentBinId = optionalText(raw.parent_bin_id, `organization_operations[${index}].parent_bin_id`);
if (!Array.isArray(raw.source_guards) || !raw.source_guards.length) {
throw new Error(`organization_operations[${index}].source_guards must contain at least one source guard`);
}
sourceGuardCount += raw.source_guards.length;
if (sourceGuardCount > MAX_ORGANIZATION_SOURCE_GUARDS) {
throw new Error(`organization_operations may contain at most ${MAX_ORGANIZATION_SOURCE_GUARDS} source guards`);
}
const sourceGuards = raw.source_guards.map((guard, guardIndex) => {
if (!guard || typeof guard !== "object" || Array.isArray(guard)) {
throw new Error(`organization_operations[${index}].source_guards[${guardIndex}] must be an object`);
}
const rawGuard = guard as Record<string, unknown>;
const evidenceId = requiredText(rawGuard.evidence_id, `organization_operations[${index}].source_guards[${guardIndex}].evidence_id`, 128);
if (seenEvidence.has(evidenceId)) throw new Error(`organization_operations contains duplicate evidence_id: ${evidenceId}`);
seenEvidence.add(evidenceId);
const candidate = candidates.get(evidenceId);
if (!recommendation.candidateEvidenceIds.includes(evidenceId) || candidate?.kind !== "source") {
throw new Error(`organization_operations[${index}].source_guards[${guardIndex}] must reference a source selected by its recommendation`);
}
const projectItemId = requiredText(rawGuard.project_item_id, `organization_operations[${index}].source_guards[${guardIndex}].project_item_id`);
if (seenProjectItemIds.has(projectItemId)) {
throw new Error(`organization_operations contains duplicate project_item_id: ${projectItemId}`);
}
seenProjectItemIds.add(projectItemId);
if (candidate.sourceId !== projectItemId) {
throw new Error(`organization_operations[${index}].source_guards[${guardIndex}].project_item_id must match the planned source ID`);
}
return {
evidenceId,
projectItemId,
expectedParentId: requiredText(rawGuard.expected_parent_id, `organization_operations[${index}].source_guards[${guardIndex}].expected_parent_id`),
};
});
return {
recommendationId,
...(destinationBinId ? { destinationBinId } : {}),
...(parentBinId ? { parentBinId } : {}),
proposedBinName,
...(proposedColorIndex === undefined ? {} : { proposedColorIndex }),
sourceGuards,
};
});
}
async function revalidatePlan(
repository: ProjectContextRepository,
plan: EditorialPlan,
): Promise<string | undefined> {
const document = await repository.get(plan.projectId);
if (!document) return "Project context not found; capture it before previewing an editorial plan";
if (document.revision !== plan.expectedContextRevision || document.timelineRevision !== plan.expectedTimelineRevision) {
return "Editorial plan is stale; capture project context and create a new plan before reviewing or mutating Premiere";
}
const activeEvidence = new Set(document.records.map((record) => record.id));
const missingEvidence = plan.candidates.map((candidate) => candidate.evidenceId).filter((id) => !activeEvidence.has(id));
return missingEvidence.length ? `Editorial plan references unavailable evidence: ${missingEvidence.join(", ")}` : undefined;
}
export function getEditorialPlanTools(dependencies: EditorialPlanToolDependencies = {}) {
const repository = dependencies.repository ?? new ProjectContextRepository();
const issuedPlans = new Map<string, IssuedEditorialPlan>();
const tools = {
create_editorial_plan: {
description: "Create a local, evidence-backed editorial workflow plan from captured project context. It never calls an LLM, uploads media, or changes Premiere.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
project_id: { type: "string", minLength: 1, maxLength: 512, description: "Project context ID returned by manage_project_context capture." },
workflow: { type: "string", enum: ["organize", "stringout", "rough_cut", "caption_review", "platform_cutdown"], description: "The workflow to plan. Every workflow remains review-only." },
intent: { type: "string", minLength: 1, maxLength: 1000, description: "The editorial goal used to retrieve relevant local evidence." },
sequence_id: { type: "string", minLength: 1, maxLength: 128, description: "Optional exact sequence ID filter for evidence retrieval." },
max_candidates: { type: "integer", minimum: 1, maximum: 32, description: "Maximum evidence candidates to include; defaults to 8." },
organization_rules: {
type: "array",
maxItems: 16,
description: "Required only for organize. Rules are supplied by the editor or MCP client; the server does not infer categories from filenames alone.",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string", minLength: 1, maxLength: 255 },
keywords: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1, maxLength: 512 } },
color_index: { type: "integer", minimum: 0, maximum: 14 },
},
required: ["name", "keywords"],
},
},
platform_targets: {
type: "array",
minItems: 1,
maxItems: 8,
description: "Required only for platform_cutdown. These are proposed derivative sequence dimensions; this tool does not create or reframe a sequence.",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string", minLength: 1, maxLength: 64 },
width: { type: "integer", minimum: 16, maximum: 8192 },
height: { type: "integer", minimum: 16, maximum: 8192 },
sequence_name: { type: "string", minLength: 1, maxLength: 255 },
include_captions: { type: "boolean" },
},
required: ["name", "width", "height"],
},
},
},
required: ["project_id", "workflow", "intent"],
},
handler: async (args: {
project_id: string;
workflow: EditorialWorkflow;
intent: string;
sequence_id?: string;
max_candidates?: number;
organization_rules?: OrganizationRule[];
platform_targets?: Array<{
name: string;
width: number;
height: number;
sequence_name?: string;
include_captions?: boolean;
}>;
}) => {
const document = await repository.get(projectId(args.project_id));
if (!document) return { success: false, error: "Project context not found; capture it before creating an editorial plan" };
try {
const plan = buildEditorialPlan(document, {
workflow: args.workflow,
intent: args.intent,
...(args.sequence_id ? { sequenceId: args.sequence_id } : {}),
...(args.max_candidates === undefined ? {} : { maxCandidates: args.max_candidates }),
...(args.organization_rules === undefined ? {} : { organizationRules: args.organization_rules }),
...(args.platform_targets === undefined ? {} : {
platformTargets: args.platform_targets.map((target): PlatformCutdownTarget => ({
name: target.name,
width: target.width,
height: target.height,
...(target.sequence_name === undefined ? {} : { sequenceName: target.sequence_name }),
...(target.include_captions === undefined ? {} : { includeCaptions: target.include_captions }),
})),
}),
});
issuePlan(issuedPlans, plan);
return { success: true, data: plan };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
preview_editorial_plan: {
description: "Revalidate an exact server-issued editorial plan against the saved project-context revisions and return an opaque confirmation token. This tool is read-only and cannot apply the plan.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
plan: { type: "object", description: "Unchanged plan returned by create_editorial_plan from this running server instance." },
},
required: ["plan"],
},
handler: async (args: { plan: unknown }) => {
try {
const plan = validateEditorialPlan(args.plan);
const issuedPlan = requireIssuedPlan(issuedPlans, plan);
const error = await revalidatePlan(repository, plan);
if (error) return { success: false, error };
issuedPlan.confirmationToken ??= randomBytes(32).toString("base64url");
return {
success: true,
data: {
applied: false,
confirmationToken: issuedPlan.confirmationToken,
workflow: plan.workflow,
expectedContextRevision: plan.expectedContextRevision,
expectedTimelineRevision: plan.expectedTimelineRevision,
recommendationCount: plan.recommendations.length,
nextSteps: [
"Review each recommendation and resolve stable Premiere IDs.",
"Capture project context again immediately before a mutation.",
"Use the route named in the recommendation; each mutation has its own authority and host verification boundary.",
],
},
};
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
},
};
if (!dependencies.uxpBridge) return tools;
const bridge = dependencies.uxpBridge;
return {
...tools,
apply_editorial_organization_plan: {
description: "Apply selected organization recommendations through documented UXP bin transactions only. Requires the unchanged server-issued plan, its opaque preview confirmation token, and stable source/parent guards; individual host transactions may be partially committed and are never silently retried or rolled back.",
operationalCapability: {
backend: "orchestrator" as const,
backends: ["orchestrator" as const],
status: "limited" as const,
minimumPremiereVersion: "25.6",
authority: "edit" as const,
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: [
"Available only when the authenticated UXP bridge is registered; it never falls back to CEP or QE.",
"Coordinates independently committed UXP bin transactions. A failure after an earlier transaction is reported as partial and is not automatically rolled back.",
"Stops after any mutation that lacks the command-specific UXP readback contract; automated contract coverage is not licensed-host evidence.",
],
},
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
plan: { type: "object", description: "Unchanged organize plan returned by create_editorial_plan from this running server instance." },
confirmation_token: { type: "string", minLength: 1, maxLength: 256, description: "Opaque confirmationToken returned by preview_editorial_plan for this unchanged server-issued plan." },
operation_id: { type: "string", minLength: 1, maxLength: 64, description: "Caller-supplied idempotency/audit identifier used to derive individual UXP transaction IDs." },
organization_operations: {
type: "array",
minItems: 1,
maxItems: 16,
items: {
type: "object",
additionalProperties: false,
properties: {
recommendation_id: { type: "string", minLength: 1, maxLength: 128 },
destination_bin_id: { type: "string", minLength: 1, maxLength: 512, description: "An existing destination bin. Omit to create the plan's proposed bin name." },
parent_bin_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional parent for a newly created destination bin; omit for the project root." },
source_guards: {
type: "array",
minItems: 1,
maxItems: 64,
items: {
type: "object",
additionalProperties: false,
properties: {
evidence_id: { type: "string", minLength: 1, maxLength: 128 },
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
expected_parent_id: { type: "string", minLength: 1, maxLength: 512 },
},
required: ["evidence_id", "project_item_id", "expected_parent_id"],
},
},
},
required: ["recommendation_id", "source_guards"],
},
},
},
required: ["plan", "confirmation_token", "operation_id", "organization_operations"],
},
handler: async (args: {
plan: unknown;
confirmation_token: unknown;
operation_id: unknown;
organization_operations: unknown;
}) => {
try {
const plan = validateEditorialPlan(args.plan);
if (plan.workflow !== "organize") throw new Error("apply_editorial_organization_plan only accepts an organize workflow plan");
const issuedPlan = requireIssuedPlan(issuedPlans, plan);
const confirmationToken = requiredText(args.confirmation_token, "confirmation_token", 256);
if (!issuedPlan.confirmationToken || !confirmationTokenMatches(confirmationToken, issuedPlan.confirmationToken)) {
throw new Error("confirmation_token must be the exact opaque token returned by preview_editorial_plan for this server-issued plan");
}
const operationId = requiredText(args.operation_id, "operation_id", 64);
const revalidationError = await revalidatePlan(repository, plan);
if (revalidationError) return { success: false, error: revalidationError };
const operations = resolveOrganizationOperations(plan, args.organization_operations);
const committed: VerifiedOrganizationAction[] = [];
const unverifiedAttempts: UnverifiedOrganizationAttempt[] = [];
try {
for (const operation of operations) {
let destinationBinId = operation.destinationBinId;
if (!destinationBinId) {
let result: unknown;
try {
result = await bridge.request("bins.create", {
...(operation.parentBinId ? { parentBinId: operation.parentBinId } : {}),
name: operation.proposedBinName,
makeUnique: true,
operationId: childOperationId(operationId, "create", operation.recommendationId),
});
} catch (error) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "create_bin", verified: false });
throw error;
}
destinationBinId = verifiedCreatedBin(result);
if (!destinationBinId) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "create_bin", verified: false });
throw new Error("UXP bins.create did not return the required verified stable-bin readback");
}
committed.push({ recommendationId: operation.recommendationId, action: "create_bin", verified: true });
}
for (const guard of operation.sourceGuards) {
let moveResult: unknown;
try {
moveResult = await bridge.request("bins.move", {
projectItemId: guard.projectItemId,
destinationBinId,
expectedParentId: guard.expectedParentId,
operationId: childOperationId(operationId, "move", guard.evidenceId),
});
} catch (error) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "move", projectItemId: guard.projectItemId, verified: false });
throw error;
}
const moveVerified = verifiedMove(moveResult, destinationBinId);
if (!moveVerified) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "move", projectItemId: guard.projectItemId, verified: false });
throw new Error("UXP bins.move did not return the required verified destination-parent readback");
}
committed.push({ recommendationId: operation.recommendationId, action: "move", projectItemId: guard.projectItemId, verified: true });
if (operation.proposedColorIndex !== undefined) {
let colorResult: unknown;
try {
colorResult = await bridge.request("bins.color", {
projectItemId: guard.projectItemId,
colorIndex: operation.proposedColorIndex,
operationId: childOperationId(operationId, "color", guard.evidenceId),
});
} catch (error) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "set_color", projectItemId: guard.projectItemId, verified: false });
throw error;
}
const colorVerified = verifiedColor(colorResult, operation.proposedColorIndex);
if (!colorVerified) {
unverifiedAttempts.push({ recommendationId: operation.recommendationId, action: "set_color", projectItemId: guard.projectItemId, verified: false });
throw new Error("UXP bins.color did not return the required verified color-label readback");
}
committed.push({ recommendationId: operation.recommendationId, action: "set_color", projectItemId: guard.projectItemId, verified: true });
}
}
}
} catch (error) {
const hasVerifiedCommit = committed.length > 0;
if (!hasVerifiedCommit) {
const attemptedActions = unverifiedAttempts.map((attempt) => attempt.action).join(", ");
return {
success: false,
error: `${errorMessage(error)} No project change was verified; inspect Premiere before retrying because the attempted UXP action${unverifiedAttempts.length === 1 ? "" : "s"} (${attemptedActions}) may have reached the host.`,
};
}
return {
success: true,
data: {
applied: true,
outcome: "partial",
verified: false,
committedActions: committed,
unverifiedAttempts,
error: errorMessage(error),
nextSteps: ["Inspect project items and decide whether to undo the verified completed host transactions.", "Capture project context again before retrying with a new reviewed plan."],
},
};
}
return {
success: true,
data: {
applied: true,
outcome: "verified",
verified: true,
committedActions: committed,
nextSteps: ["Inspect the project organization in Premiere before continuing editorial work."],
},
};
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
},
};
}
+613
View File
@@ -0,0 +1,613 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getEffectsTools(bridgeOptions: BridgeOptions) {
return {
apply_effect: {
description: "Apply a video effect to a clip. Uses QE DOM catalog lookup, with an exact-name QE probe when Premiere's catalog enumeration is empty.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to apply the effect to",
},
effect_name: {
type: "string",
description: "Name of the effect (e.g., 'Gaussian Blur', 'Lumetri Color')",
},
},
required: ["node_id", "effect_name"],
},
handler: async (args: { node_id: string; effect_name: string }) => {
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
// Find the effect in QE
var effectName = "${escapeForExtendScript(args.effect_name)}";
var qeTrack = result.trackType === "video"
? qeSeq.getVideoTrackAt(result.trackIndex)
: qeSeq.getAudioTrackAt(result.trackIndex);
if (!qeTrack) return __error("QE track not found");
var qeClip = qeTrack.getItemAt(result.clipIndex);
if (!qeClip) return __error("QE clip not found");
// Premiere 26 can expose direct by-name lookup while returning an
// empty QE catalog. Probe the requested name before consulting the
// catalog so a known installed effect remains usable.
var qeEffect = null;
try { qeEffect = qe.project.getVideoEffectByName(effectName); } catch (byNameError) {}
var lookupSource = qeEffect ? "qe.byName" : "qe.catalog";
if (!qeEffect) {
var effectCatalog = __getQeEffectCatalog("video");
if (!effectCatalog.ok) return __error(effectCatalog.error + " Direct QE lookup for \"" + effectName + "\" also did not resolve an effect.");
var effects = effectCatalog.effects;
for (var i = 0; i < effects.numItems; i++) {
if (effects[i].name === effectName) {
qeEffect = effects[i];
break;
}
}
}
if (!qeEffect) return __error("Effect not found: " + effectName);
qeClip.addVideoEffect(qeEffect);
return __result({ applied: true, effect: effectName, lookupSource: lookupSource, clipName: result.clip.name });
`);
return sendCommand(script, bridgeOptions);
},
},
apply_audio_effect: {
description: "Apply an audio effect to a clip. Uses QE catalog lookup, with an exact-name QE probe when enumeration is empty.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Name of the audio effect",
},
},
required: ["node_id", "effect_name"],
},
handler: async (args: { node_id: string; effect_name: string }) => {
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var effectName = "${escapeForExtendScript(args.effect_name)}";
var qeTrack = qeSeq.getAudioTrackAt(result.trackIndex);
if (!qeTrack) return __error("QE audio track not found");
var qeClip = qeTrack.getItemAt(result.clipIndex);
if (!qeClip) return __error("QE clip not found");
var qeEffect = null;
try { qeEffect = qe.project.getAudioEffectByName(effectName); } catch (byNameError) {}
var lookupSource = qeEffect ? "qe.byName" : "qe.catalog";
if (!qeEffect) {
var effectCatalog = __getQeEffectCatalog("audio");
if (!effectCatalog.ok) return __error(effectCatalog.error + " Direct QE lookup for \"" + effectName + "\" also did not resolve an effect.");
var effects = effectCatalog.effects;
for (var i = 0; i < effects.numItems; i++) {
if (effects[i].name === effectName) {
qeEffect = effects[i];
break;
}
}
}
if (!qeEffect) return __error("Audio effect not found: " + effectName);
qeClip.addAudioEffect(qeEffect);
return __result({ applied: true, effect: effectName, lookupSource: lookupSource });
`);
return sendCommand(script, bridgeOptions);
},
},
remove_effect: {
description: "Remove an effect from a clip by its index or name. Returns a capability error when the host cannot remove an individual component.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_index: {
type: "number",
minimum: 0,
description: "Index of the effect to remove (0-based). Use get_clip_properties to see effects list.",
},
effect_name: {
type: "string",
description: "Name of the effect to remove (alternative to effect_index)",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string; effect_index?: number; effect_name?: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
// Component.remove() is not present on every CEP Component object in
// Premiere 26.x (notably Essential Sound's Amplify component). QE only
// exposes an all-effects removal, which is not a safe fallback here.
function removeComponent(component, effectName) {
try {
if (!component || typeof component.remove !== "function") {
return {
removed: false,
error: "Premiere does not expose Component.remove() for \"" + effectName + "\". The effect was not removed. No safe targeted QE fallback exists; remove it manually in Effect Controls."
};
}
component.remove();
return { removed: true };
} catch (e) {
return {
removed: false,
error: "Premiere could not remove \"" + effectName + "\": " + e.toString() + ". The effect may still be present; inspect Effect Controls."
};
}
}
${args.effect_index !== undefined ? `
if (${args.effect_index} < 0 || ${args.effect_index} >= clip.components.numItems) return __error("Effect index out of range");
var component = clip.components[${args.effect_index}];
var effectName = component.displayName;
var removal = removeComponent(component, effectName);
if (!removal.removed) return __error(removal.error);
return __result({ removed: true, effect: effectName });
` : `
var effectName = "${escapeForExtendScript(args.effect_name || "")}";
var component = null;
for (var i = clip.components.numItems - 1; i >= 0; i--) {
if (clip.components[i].displayName === effectName) {
component = clip.components[i];
break;
}
}
if (!component) return __error("Effect not found: " + effectName);
var removal = removeComponent(component, effectName);
if (!removal.removed) return __error(removal.error);
return __result({ removed: true, effect: effectName });
`}
`);
return sendCommand(script, bridgeOptions);
},
},
list_available_effects: {
description: "List available video effects in Premiere Pro. Uses the full QE catalog when exposed; otherwise returns a verified, explicitly partial set of common effects resolved by exact name.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
var list = [];
var effectCatalog = __getQeEffectCatalog("video");
if (effectCatalog.ok) {
var effects = effectCatalog.effects;
for (var i = 0; i < effects.numItems; i++) {
list.push({ name: effects[i].name, index: i, source: "qe.catalog" });
}
return __result(list);
}
// Keep this bounded and label every result as partial. It is a
// usability fallback, not a claim that these are all installed
// effects or that their localized display names are exhaustive.
var commonNames = ["Transform", "Gaussian Blur", "Lumetri Color", "Crop", "Warp Stabilizer", "Tint", "Brightness & Contrast", "Ultra Key"];
for (var probeIndex = 0; probeIndex < commonNames.length; probeIndex++) {
var candidate = null;
try { candidate = qe.project.getVideoEffectByName(commonNames[probeIndex]); } catch (probeError) {}
if (candidate) list.push({ name: commonNames[probeIndex], index: null, source: "qe.byName.partial" });
}
if (list.length === 0) return __error(effectCatalog.error + " Direct lookup did not resolve any bounded fallback effects.");
return __result(list);
`);
return sendCommand(script, bridgeOptions);
},
},
list_available_audio_effects: {
description: "List all available audio effects in Premiere Pro. Uses QE DOM.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
var effectCatalog = __getQeEffectCatalog("audio");
if (!effectCatalog.ok) return __error(effectCatalog.error);
var effects = effectCatalog.effects;
var list = [];
for (var i = 0; i < effects.numItems; i++) {
list.push({ name: effects[i].name, index: i });
}
return __result(list);
`);
return sendCommand(script, bridgeOptions);
},
},
color_correct: {
description: "Apply basic color correction to a clip using Lumetri Color",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
exposure: { type: "number", description: "Exposure adjustment (-4.0 to 4.0)" },
contrast: { type: "number", description: "Contrast adjustment (-100 to 100)" },
highlights: { type: "number", description: "Highlights adjustment (-100 to 100)" },
shadows: { type: "number", description: "Shadows adjustment (-100 to 100)" },
whites: { type: "number", description: "Whites adjustment (-100 to 100)" },
blacks: { type: "number", description: "Blacks adjustment (-100 to 100)" },
temperature: { type: "number", description: "Color temperature adjustment" },
tint: { type: "number", description: "Tint adjustment" },
saturation: { type: "number", description: "Saturation (0-200, 100 = normal)" },
},
required: ["node_id"],
},
handler: async (args: {
node_id: string;
exposure?: number;
contrast?: number;
highlights?: number;
shadows?: number;
whites?: number;
blacks?: number;
temperature?: number;
tint?: number;
saturation?: number;
}) => {
// Lumetri repeats display names across sub-sections (Basic Correction, Creative,
// HSL Secondary, ...) and not every match is writable, so each setValue is
// guarded and the first writable match per control wins.
const controls: Array<{ key: string; label: string; value: number }> = (
[
{ key: "exposure", label: "Exposure", value: args.exposure },
{ key: "contrast", label: "Contrast", value: args.contrast },
{ key: "highlights", label: "Highlights", value: args.highlights },
{ key: "shadows", label: "Shadows", value: args.shadows },
{ key: "whites", label: "Whites", value: args.whites },
{ key: "blacks", label: "Blacks", value: args.blacks },
{ key: "temperature", label: "Temperature", value: args.temperature },
{ key: "tint", label: "Tint", value: args.tint },
{ key: "saturation", label: "Saturation", value: args.saturation },
] as Array<{ key: string; label: string; value: number | undefined }>
).filter((c): c is { key: string; label: string; value: number } => c.value !== undefined);
const setters = controls
.map(
(c) => `
if (!taken.${c.key} && name === "${c.label}") {
try {
prop.setValue(${c.value}, true);
taken.${c.key} = true;
changes.${c.key} = ${c.value};
} catch(e) {
errors.${c.key} = e.toString();
}
}`
)
.join("");
// First apply Lumetri Color effect, then set its properties
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
// Apply Lumetri Color if not already present
var clip = result.clip;
var hasLumetri = false;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "Lumetri Color") {
hasLumetri = true;
break;
}
}
if (!hasLumetri) {
var qeTrack = qeSeq.getVideoTrackAt(result.trackIndex);
var qeClip = qeTrack.getItemAt(result.clipIndex);
var effectCatalog = __getQeEffectCatalog("video");
if (!effectCatalog.ok) return __error(effectCatalog.error);
var effects = effectCatalog.effects;
for (var i = 0; i < effects.numItems; i++) {
if (effects[i].name === "Lumetri Color") {
qeClip.addVideoEffect(effects[i]);
break;
}
}
}
// A single unsettable property must not abort the script with "Invalid
// parameter" and lose every other change, so each write is guarded.
// The taken map is a separate set of flags rather than a truthiness check on
// changes: 0 is a legitimate value (saturation 0 is a valid full desaturate),
// and a falsy check would re-fire it on every later sub-section that repeats
// the same display name.
var changes = {};
var errors = {};
var taken = {};
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.displayName !== "Lumetri Color") continue;
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
var name = prop.displayName;
${setters}
}
break;
}
return __result({ colorCorrected: true, clipName: clip.name, changes: changes, errors: errors });
`);
return sendCommand(script, bridgeOptions);
},
},
apply_lut: {
description: "Apply a LUT file to a clip via Lumetri Color",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
lut_path: {
type: "string",
description: "Full path to the .cube or .3dl LUT file",
},
},
required: ["node_id", "lut_path"],
},
handler: async (args: { node_id: string; lut_path: string }) => {
const script = buildToolScript(`
app.enableQE();
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
// Find or apply Lumetri Color
var lumetriComp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "Lumetri Color") {
lumetriComp = clip.components[i];
break;
}
}
if (!lumetriComp) {
var qeSeq = qe.project.getActiveSequence();
var qeTrack = qeSeq.getVideoTrackAt(result.trackIndex);
var qeClip = qeTrack.getItemAt(result.clipIndex);
var effectCatalog = __getQeEffectCatalog("video");
if (!effectCatalog.ok) return __error(effectCatalog.error);
var effects = effectCatalog.effects;
for (var i = 0; i < effects.numItems; i++) {
if (effects[i].name === "Lumetri Color") {
qeClip.addVideoEffect(effects[i]);
break;
}
}
// Re-find the component
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "Lumetri Color") {
lumetriComp = clip.components[i];
break;
}
}
}
if (!lumetriComp) return __error("Could not apply Lumetri Color effect");
// Set the LUT path
for (var p = 0; p < lumetriComp.properties.numItems; p++) {
var prop = lumetriComp.properties[p];
if (prop.displayName === "Input LUT") {
prop.setValue("${escapeForExtendScript(args.lut_path)}", true);
break;
}
}
return __result({ lutApplied: true, clipName: clip.name, lutPath: "${escapeForExtendScript(args.lut_path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
inspect_stabilizer_status: {
description: "Read Warp Stabilizer presence, exposed status properties, and conservative analysis state for one clip or every video clip in the active sequence. Read-only: unknown or localized host values remain unknown rather than being reported as solved.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
node_id: { type: "string", description: "Optional clip node ID. Omit to inspect every video clip in the active sequence." },
},
},
handler: async (args: { node_id?: string }) => {
const nodeId = args.node_id ? escapeForExtendScript(args.node_id) : "";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var requestedNodeId = "${nodeId}";
var clips = [];
if (requestedNodeId) {
var found = __findClip(requestedNodeId);
if (!found || found.trackType !== "video") return __error("Video clip not found: " + requestedNodeId);
clips.push({ clip: found.clip, trackIndex: found.trackIndex, clipIndex: found.clipIndex });
} else {
for (var trackIndex = 0; trackIndex < seq.videoTracks.numTracks; trackIndex++) {
var track = seq.videoTracks[trackIndex];
for (var clipIndex = 0; clipIndex < track.clips.numItems; clipIndex++) {
clips.push({ clip: track.clips[clipIndex], trackIndex: trackIndex, clipIndex: clipIndex });
}
}
}
function primitiveValue(value) {
if (value === null || typeof value === "number" || typeof value === "string" || typeof value === "boolean") return value;
return String(value);
}
var results = [];
for (var clipCursor = 0; clipCursor < clips.length; clipCursor++) {
var entry = clips[clipCursor];
var stabilizer = null;
for (var componentIndex = 0; componentIndex < entry.clip.components.numItems; componentIndex++) {
var component = entry.clip.components[componentIndex];
var componentName = String(component.displayName || "");
var matchName = String(component.matchName || "");
var identity = (componentName + " " + matchName).toLowerCase();
if (identity.indexOf("warp stabilizer") >= 0 || identity.indexOf("warp stabiliser") >= 0) {
stabilizer = component;
break;
}
}
if (!stabilizer) continue;
var properties = [];
var stateEvidence = [];
for (var propertyIndex = 0; propertyIndex < stabilizer.properties.numItems; propertyIndex++) {
var property = stabilizer.properties[propertyIndex];
var propertyName = String(property.displayName || "");
var propertyValue = null;
var readable = true;
try { propertyValue = primitiveValue(property.getValue()); } catch (readError) { readable = false; }
properties.push({ name: propertyName, value: propertyValue, readable: readable });
var stateName = propertyName.toLowerCase();
if (stateName.indexOf("status") >= 0 || stateName.indexOf("result") >= 0 || stateName.indexOf("analy") >= 0) {
stateEvidence.push({ name: propertyName, value: propertyValue, readable: readable });
}
}
var analysisStatus = "unknown";
for (var evidenceIndex = 0; evidenceIndex < stateEvidence.length; evidenceIndex++) {
var text = String(stateEvidence[evidenceIndex].value).toLowerCase();
if (text.indexOf("not analy") >= 0 || text.indexOf("unanaly") >= 0) analysisStatus = "not_analyzed";
else if (text.indexOf("analyzing") >= 0 || text.indexOf("analysing") >= 0 || text.indexOf("progress") >= 0) analysisStatus = "analyzing";
else if (text.indexOf("stabilized") >= 0 || text.indexOf("stabilised") >= 0 || text.indexOf("complete") >= 0 || text.indexOf("solved") >= 0) analysisStatus = "analyzed";
}
results.push({
nodeId: String(entry.clip.nodeId), clipName: String(entry.clip.name),
trackIndex: entry.trackIndex, clipIndex: entry.clipIndex,
componentName: String(stabilizer.displayName || "Warp Stabilizer"),
analysisStatus: analysisStatus, stateEvidence: stateEvidence, properties: properties
});
}
return __result({
inspectedClipCount: clips.length,
stabilizerClipCount: results.length,
clips: results,
verificationScope: "Read-only host property inspection. analysisStatus is conservative and remains unknown when Premiere exposes numeric, localized, absent, or unreadable state values; unknown is not proof of a completed solve."
});
`);
return sendCommand(script, bridgeOptions);
},
},
stabilize_clip: {
description: "Apply the Warp Stabilizer effect to a clip for video stabilization. Uses QE DOM.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to stabilize",
},
smoothness: {
type: "number",
description: "Stabilization smoothness percentage (default: 50). Higher = smoother but more cropping.",
},
method: {
type: "string",
enum: ["Subspace Warp", "Position", "Position, Scale, Rotation"],
description: "Stabilization method (default: 'Subspace Warp')",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string; smoothness?: number; method?: string }) => {
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var qeTrack = result.trackType === "video"
? qeSeq.getVideoTrackAt(result.trackIndex)
: null;
if (!qeTrack) return __error("Warp Stabilizer can only be applied to video clips");
var qeClip = qeTrack.getItemAt(result.clipIndex);
if (!qeClip) return __error("QE clip not found");
// Find and apply Warp Stabilizer
var effectCatalog = __getQeEffectCatalog("video");
if (!effectCatalog.ok) return __error(effectCatalog.error);
var effects = effectCatalog.effects;
var found = false;
for (var i = 0; i < effects.numItems; i++) {
if (effects[i].name === "Warp Stabilizer") {
qeClip.addVideoEffect(effects[i]);
found = true;
break;
}
}
if (!found) return __error("Warp Stabilizer effect not found");
// Set properties if specified
var clip = result.clip;
var changes = { stabilized: true };
${args.smoothness !== undefined || args.method !== undefined ? `
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.displayName === "Warp Stabilizer") {
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
${args.smoothness !== undefined ? `
if (prop.displayName === "Smoothness") {
prop.setValue(${args.smoothness}, true);
changes.smoothness = ${args.smoothness};
}` : ""}
${args.method !== undefined ? `
if (prop.displayName === "Method") {
prop.setValue("${escapeForExtendScript(args.method)}", true);
changes.method = "${escapeForExtendScript(args.method)}";
}` : ""}
}
break;
}
}
` : ""}
return __result({ clipName: clip.name, info: "Warp Stabilizer applied. Analysis will begin automatically.", changes: changes });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+1600
View File
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
import { buildToolScript } from "../bridge/script-builder.js";
import { getTempDir, sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
import { resolveCapabilities, type CapabilityConfig } from "../security/capabilities.js";
import { isToolPermitted } from "../security/index.js";
import { buildPlatformCapabilityReport } from "../platform-capabilities.js";
import { buildAdvancedFeatureSupport, type AdvancedFeatureBackend } from "../advanced-feature-support.js";
import type { CatalogToolDefinition } from "../tool-capability-report.js";
import { buildFirstRunReport, type FirstRunReport } from "../diagnostics.js";
import { captureActivationEvent, type Telemetry } from "../telemetry.js";
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
import {
buildToolPackReport,
isToolInSelectedPacks,
resolveToolPacks,
type ToolPackSelection,
} from "../workflows/tool-packs.js";
export interface HealthToolOptions {
telemetry?: Telemetry;
uxpBridge?: UxpWebSocketBridge;
toolPacks?: ToolPackSelection;
}
const disabledTelemetry: Telemetry = {
enabled: false,
capture: () => {},
shutdown: async () => {},
};
export function getHealthTools(
bridgeOptions: BridgeOptions,
capabilities: CapabilityConfig = resolveCapabilities(),
getToolCatalog: () => Record<string, CatalogToolDefinition> = () => ({}),
options: HealthToolOptions = {},
) {
return {
get_advanced_feature_support: {
description:
"Report public-API support, prerequisites, entitlements, and user-assisted boundaries for Premiere collaboration and AI features",
parameters: {
type: "object" as const,
properties: {
backend: {
type: "string",
enum: ["cep", "uxp"],
description: "Backend being evaluated (default: cep, the current production MCP transport)",
},
premiere_version: {
type: "string",
description: "Optional Premiere version such as 26.3.0 for version-specific eligibility",
},
frameio_entitled: {
type: "boolean",
description: "Whether the operator has confirmed Frame.io account/project access",
},
generative_ai_entitled: {
type: "boolean",
description: "Whether the operator has confirmed Adobe generative AI entitlement",
},
network_available: {
type: "boolean",
description: "Whether required Adobe/cloud services are reachable",
},
},
},
handler: async (args: {
backend?: AdvancedFeatureBackend;
premiere_version?: string;
frameio_entitled?: boolean;
generative_ai_entitled?: boolean;
network_available?: boolean;
}) => {
try {
return {
success: true,
data: buildAdvancedFeatureSupport({
backend: args.backend,
premiereVersion: args.premiere_version,
frameIoEntitled: args.frameio_entitled,
generativeAiEntitled: args.generative_ai_entitled,
networkAvailable: args.network_available,
}),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
get_capabilities: {
description: "Discover Premiere operations and report backend coverage, authority, and verification requirements. Use tool_query with task keywords (for example transcript, captions, or review frames) for ranked, bounded matches available in this session. Use tool_names for exact lookups or tool_offset/tool_limit for paging. Discovery never grants authority or proves a live host is ready.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
tool_query: {
type: "string",
minLength: 1,
maxLength: 256,
pattern: "\\S",
description: "Optional case-insensitive keywords matched against tool names and descriptions. Exact names rank first. Search defaults to 20 results with descriptions and session availability; page with tool_offset/tool_limit.",
},
available_only: {
type: "boolean",
description: "Filter to tools registered under this session's authority and tool packs. Defaults to true for tool_query, false otherwise. Set false to diagnose unavailable matches; this does not enable them.",
},
tool_names: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 256 },
minItems: 1,
maxItems: 128,
uniqueItems: true,
description: "Optional exact tool-name allowlist. Returns only those catalog entries while retaining the overall capability summary.",
},
tool_offset: {
type: "integer",
minimum: 0,
description: "Zero-based offset into the filtered tool catalog. Pair with tool_limit for an explicitly sized page.",
},
tool_limit: {
type: "integer",
minimum: 1,
maximum: 128,
description: "Maximum tool catalog entries to return. Omit with tool_offset to preserve the complete legacy response.",
},
},
},
handler: async (args: { tool_query?: string; available_only?: boolean; tool_names?: string[]; tool_offset?: number; tool_limit?: number } = {}) => {
if (args.tool_query !== undefined && (
typeof args.tool_query !== "string" || !args.tool_query.trim() || args.tool_query.length > 256
)) {
return { success: false, error: "tool_query must contain 1 through 256 characters and at least one non-whitespace character" };
}
const catalog = getToolCatalog();
const selection = options.toolPacks ?? resolveToolPacks();
const report = buildPlatformCapabilityReport(
capabilities,
process.platform,
getTempDir(bridgeOptions),
catalog,
buildToolPackReport(selection),
);
const query = args.tool_query?.trim().toLowerCase();
const terms = query?.split(/[\s_\-]+/u).filter(Boolean) ?? [];
const availableOnly = args.available_only ?? (query !== undefined);
const names = Array.isArray(args.tool_names) ? new Set(args.tool_names) : undefined;
const matchingTools = report.tools.tools.flatMap((tool) => {
if (names && !names.has(tool.name)) return [];
const registered = isToolPermitted(tool.name, capabilities) && isToolInSelectedPacks(tool.name, selection);
if (availableOnly && !registered) return [];
const description = catalog[tool.name]?.description ?? "";
const name = tool.name.toLowerCase();
const searchableDescription = description.toLowerCase();
const score = query === name ? 1000 : terms.reduce((sum, term) =>
sum + (name.includes(term) ? 3 : searchableDescription.includes(term) ? 1 : 0), 0);
if (query !== undefined && score === 0) return [];
return [{ tool, description, registered, score }];
});
if (query !== undefined) {
// Deterministic tie ordering keeps offset pagination stable.
matchingTools.sort((a, b) => b.score - a.score ||
(a.tool.name < b.tool.name ? -1 : a.tool.name > b.tool.name ? 1 : 0));
}
const offset = Number.isInteger(args.tool_offset) && (args.tool_offset as number) >= 0 ? args.tool_offset as number : 0;
const hasExplicitPage = query !== undefined || args.tool_limit !== undefined || args.tool_offset !== undefined;
const limit = Number.isInteger(args.tool_limit) && (args.tool_limit as number) > 0
? Math.min(args.tool_limit as number, 128)
: query !== undefined ? 20 : matchingTools.length;
const page = (hasExplicitPage ? matchingTools.slice(offset, offset + limit) : matchingTools)
.map(({ tool, description, registered }) => query !== undefined || args.available_only !== undefined
? { ...tool, description, registered }
: tool);
return {
success: true,
data: {
...report,
...(query !== undefined ? {
// The full Adobe inventories dominate the legacy response. A
// task search needs backend prerequisites, not every API symbol.
backends: {
cep: {
status: report.backends.cep.status,
platforms: report.backends.cep.platforms,
premiereVersions: report.backends.cep.premiereVersions,
hostVerificationRequired: report.premiere.hostVerificationRequired,
},
uxp: {
status: report.backends.uxp.status,
platforms: report.backends.uxp.platforms,
premiereVersions: report.backends.uxp.premiereVersions,
hostVerificationRequired: report.backends.uxp.hostVerificationRequired,
},
},
discovery: {
mode: "keyword_search",
detail: "summary",
note: "Detailed backend inventories are omitted from search. Omit tool_query to request the full report. Registered tools still require live prerequisites and action-level authority.",
},
} : {}),
tools: {
...report.tools,
tools: page,
...(names || hasExplicitPage || args.available_only !== undefined
? {
pagination: {
offset,
limit,
returned: page.length,
totalMatching: matchingTools.length,
hasMore: offset + page.length < matchingTools.length,
nextOffset: offset + page.length < matchingTools.length ? offset + page.length : null,
},
}
: {}),
},
},
};
},
},
ping: {
description: "Health check — verify the CEP plugin is running and connected to Premiere Pro. Call this before other tools to confirm connectivity.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var version = app.version;
var projectName = app.project && app.project.name ? app.project.name : "No project open";
var activeSequence = app.project ? __getCurrentActiveSequence() : null;
var activeSeq = activeSequence ? activeSequence.name : "None";
return __result({
connected: true,
premiereVersion: version,
projectName: projectName,
activeSequence: activeSeq
});
`);
return sendCommand(script, {
...bridgeOptions,
timeoutMs: 5000,
failFastOnUnreadyHeartbeat: true,
});
},
},
verify_premiere_connection: {
description:
"Run a safe, read-only first-run check. It proves that this MCP server, the selected Premiere bridge, an active project, and an active sequence are connected without returning project names, paths, or media details.",
parameters: {
type: "object" as const,
properties: {
backend: {
type: "string",
enum: ["cep", "uxp"],
description: "Bridge to check. Defaults to CEP. This check never falls back to a different bridge.",
},
},
},
handler: async (args: { backend?: "cep" | "uxp" }) => {
const backend = args.backend ?? "cep";
const telemetry = options.telemetry ?? disabledTelemetry;
let report: FirstRunReport;
if (backend === "uxp") {
if (!options.uxpBridge) {
report = buildFirstRunReport("uxp", { reachable: false });
} else {
try {
// This is an explicit read-only UXP request. Do not try CEP if it
// fails: even diagnostics should accurately name their backend.
const result = await options.uxpBridge.request("state.get");
const state = result && typeof result === "object"
? result as Record<string, unknown>
: {};
report = buildFirstRunReport("uxp", {
reachable: true,
projectOpen: state.projectOpen === true,
sequenceOpen: state.sequenceOpen === true,
});
} catch {
report = buildFirstRunReport("uxp", { reachable: false });
}
}
} else {
try {
const script = buildToolScript(`
var projectOpen = !!(app && app.project && typeof app.project.name !== "undefined");
var sequenceOpen = !!(projectOpen && __getCurrentActiveSequence());
return __result({ projectOpen: projectOpen, sequenceOpen: sequenceOpen });
`);
const response = await sendCommand(script, {
...bridgeOptions,
timeoutMs: 5000,
failFastOnUnreadyHeartbeat: true,
});
const data = response.success && response.data && typeof response.data === "object"
? response.data as Record<string, unknown>
: {};
report = buildFirstRunReport("cep", {
reachable: response.success === true,
projectOpen: data.projectOpen === true,
sequenceOpen: data.sequenceOpen === true,
});
} catch {
report = buildFirstRunReport("cep", { reachable: false });
}
}
// This is the only repository-owned activation signal. A tool call
// that finds an unavailable bridge, project, or sequence remains a
// useful diagnostic, but is not activation.
if (report.overall === "ready") {
captureActivationEvent(telemetry, { backend });
}
// A completed diagnostic remains a successful tool call even if it
// identifies a problem. The structured report tells the user what to fix.
return { success: true, data: report };
},
},
};
}
+1427
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { BridgeOptions } from "../bridge/file-bridge.js";
const MAX_INTERCHANGE_BYTES = 10 * 1024 * 1024;
const MAX_EVENTS = 2_000;
const MAX_ISSUES = 256;
const MAX_ASSETS = 512;
export type CmxFrameRate = 24 | 25 | 29.97 | 30 | 50 | 59.94 | 60;
export interface CmxEvent {
eventNumber: number;
reel: string;
track: string;
transition: "C" | "D" | "W";
transitionDurationFrames: number | null;
sourceIn: string;
sourceOut: string;
recordIn: string;
recordOut: string;
}
export interface CmxEdl {
title: string | null;
frameCodeMode: string | null;
events: CmxEvent[];
unrecognizedEventLines: Array<{ line: number; text: string }>;
}
const EVENT_PATTERN = /^\s*(\d{1,4})\s+(\S+)\s+(\S+)\s+([CDW])(?:\s+(\d{1,3}))?\s+(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\d{2}:\d{2}:\d{2}[:;]\d{2})\s*$/i;
export function parseCmx3600Edl(contents: string): CmxEdl {
const events: CmxEvent[] = [];
const unrecognizedEventLines: CmxEdl["unrecognizedEventLines"] = [];
let title: string | null = null;
let frameCodeMode: string | null = null;
for (const [index, sourceLine] of contents.split(/\r?\n/).entries()) {
const line = sourceLine.trimEnd();
if (/^TITLE\s*:/i.test(line)) { title = line.replace(/^TITLE\s*:\s*/i, "").trim() || null; continue; }
if (/^FCM\s*:/i.test(line)) { frameCodeMode = line.replace(/^FCM\s*:\s*/i, "").trim() || null; continue; }
if (!/^\s*\d/.test(line)) continue;
const match = line.match(EVENT_PATTERN);
if (!match) {
if (unrecognizedEventLines.length < MAX_ISSUES) unrecognizedEventLines.push({ line: index + 1, text: line.slice(0, 500) });
continue;
}
if (events.length >= MAX_EVENTS) throw new Error(`CMX 3600 EDL exceeds the ${MAX_EVENTS}-event limit`);
events.push({
eventNumber: Number(match[1]), reel: match[2], track: match[3], transition: match[4].toUpperCase() as CmxEvent["transition"],
transitionDurationFrames: match[5] ? Number(match[5]) : null,
sourceIn: match[6], sourceOut: match[7], recordIn: match[8], recordOut: match[9],
});
}
return { title, frameCodeMode, events, unrecognizedEventLines };
}
function nominalRate(rate: CmxFrameRate): number { return rate === 29.97 ? 30 : rate === 59.94 ? 60 : rate; }
export function timecodeToFrames(timecode: string, frameRate: CmxFrameRate): number {
const match = timecode.match(/^(\d{2}):(\d{2}):(\d{2})([:;])(\d{2})$/);
if (!match) throw new Error(`Invalid timecode: ${timecode}`);
const [, hoursText, minutesText, secondsText, separator, framesText] = match;
const hours = Number(hoursText), minutes = Number(minutesText), seconds = Number(secondsText), frames = Number(framesText);
const nominal = nominalRate(frameRate);
if (minutes > 59 || seconds > 59 || frames >= nominal) throw new Error(`Invalid timecode fields: ${timecode}`);
if (separator === ";") {
if (frameRate !== 29.97 && frameRate !== 59.94) throw new Error(`Drop-frame separator is only supported at 29.97 or 59.94 fps: ${timecode}`);
const droppedPerMinute = frameRate === 29.97 ? 2 : 4;
if (seconds === 0 && minutes % 10 !== 0 && frames < droppedPerMinute) throw new Error(`Invalid dropped frame number: ${timecode}`);
const totalMinutes = hours * 60 + minutes;
return ((hours * 3600 + minutes * 60 + seconds) * nominal + frames) - droppedPerMinute * (totalMinutes - Math.floor(totalMinutes / 10));
}
return (hours * 3600 + minutes * 60 + seconds) * nominal + frames;
}
export interface CmxValidation { valid: boolean; errors: string[]; warnings: string[]; totalRecordFrames: number | null }
export function validateCmx3600Edl(edl: CmxEdl, frameRate: CmxFrameRate): CmxValidation {
const errors: string[] = [];
const warnings: string[] = [];
if (edl.events.length === 0) errors.push("No CMX 3600 event records were found");
if (edl.unrecognizedEventLines.length) warnings.push(`${edl.unrecognizedEventLines.length} numeric line(s) did not match the supported CMX 3600 event grammar`);
const seen = new Set<number>();
let previousRecordOut: number | null = null;
let firstRecordIn: number | null = null;
let finalRecordOut: number | null = null;
for (const event of edl.events) {
if (seen.has(event.eventNumber)) errors.push(`Event ${event.eventNumber} is duplicated`);
seen.add(event.eventNumber);
try {
const sourceIn = timecodeToFrames(event.sourceIn, frameRate), sourceOut = timecodeToFrames(event.sourceOut, frameRate);
const recordIn = timecodeToFrames(event.recordIn, frameRate), recordOut = timecodeToFrames(event.recordOut, frameRate);
if (sourceOut <= sourceIn) errors.push(`Event ${event.eventNumber} has a non-positive source duration`);
if (recordOut <= recordIn) errors.push(`Event ${event.eventNumber} has a non-positive record duration`);
if (previousRecordOut !== null && recordIn < previousRecordOut) errors.push(`Event ${event.eventNumber} overlaps the previous record interval`);
if (previousRecordOut !== null && recordIn > previousRecordOut) warnings.push(`Event ${event.eventNumber} begins after a record gap of ${recordIn - previousRecordOut} frames`);
previousRecordOut = recordOut;
firstRecordIn ??= recordIn;
finalRecordOut = recordOut;
} catch (error) { errors.push(`Event ${event.eventNumber}: ${(error as Error).message}`); }
}
return { valid: errors.length === 0, errors: errors.slice(0, MAX_ISSUES), warnings: warnings.slice(0, MAX_ISSUES), totalRecordFrames: firstRecordIn !== null && finalRecordOut !== null ? finalRecordOut - firstRecordIn : null };
}
function eventFingerprint(event: CmxEvent): string { return JSON.stringify({ reel: event.reel, track: event.track, transition: event.transition, transitionDurationFrames: event.transitionDurationFrames, sourceIn: event.sourceIn, sourceOut: event.sourceOut, recordIn: event.recordIn, recordOut: event.recordOut }); }
export function compareCmx3600Edls(before: CmxEdl, after: CmxEdl) {
const beforeByEvent = new Map(before.events.map(event => [event.eventNumber, event]));
const afterByEvent = new Map(after.events.map(event => [event.eventNumber, event]));
const changes: Array<{ eventNumber: number; type: "added" | "removed" | "changed" }> = [];
for (const [eventNumber, event] of beforeByEvent) {
const candidate = afterByEvent.get(eventNumber);
if (!candidate) changes.push({ eventNumber, type: "removed" });
else if (eventFingerprint(event) !== eventFingerprint(candidate)) changes.push({ eventNumber, type: "changed" });
}
for (const eventNumber of afterByEvent.keys()) if (!beforeByEvent.has(eventNumber)) changes.push({ eventNumber, type: "added" });
return { beforeEventCount: before.events.length, afterEventCount: after.events.length, changes: changes.slice(0, MAX_ISSUES), truncated: changes.length > MAX_ISSUES };
}
export interface FcpxmlAsset { id: string | null; name: string | null; source: string | null }
function attributes(value: string): Record<string, string> {
const result: Record<string, string> = {};
for (const match of value.matchAll(/([\w:-]+)\s*=\s*(["'])(.*?)\2/g)) result[match[1]] = match[3];
return result;
}
export function inspectFcpxml(contents: string) {
const root = contents.match(/<fcpxml\b([^>]*)>/i);
const assets: FcpxmlAsset[] = [];
for (const match of contents.matchAll(/<(?:asset(?![-\w])|media-rep(?![-\w]))\b([^>]*)\/?>(?:<\/[^>]+>)?/gi)) {
if (assets.length >= MAX_ASSETS) break;
const attrs = attributes(match[1]);
if (attrs.src || match[0].toLowerCase().startsWith("<asset")) assets.push({ id: attrs.id ?? null, name: attrs.name ?? null, source: attrs.src ?? null });
}
const clips = [...contents.matchAll(/<(?:asset-clip|ref-clip|video|audio)\b/gi)].length;
const sequences = [...contents.matchAll(/<sequence\b/gi)].length;
return {
format: "FCPXML", version: root ? attributes(root[1]).version ?? null : null, sequenceCount: sequences, clipElementCount: clips,
assetCount: assets.length, assets, assetsTruncated: assets.length === MAX_ASSETS,
warnings: [
...(root ? [] : ["No <fcpxml> root element was found"]),
...( /<!DOCTYPE/i.test(contents) ? ["DOCTYPE is present; this tool inspects text only and never resolves entities"] : []),
],
};
}
function readInterchangeFile(value: unknown, extensions: readonly string[]): { path: string; contents: string } {
if (typeof value !== "string" || !value.trim()) throw new Error("path must be a non-empty string");
const path = resolve(value);
if (!extensions.some(extension => path.toLowerCase().endsWith(extension))) throw new Error(`path must have one of: ${extensions.join(", ")}`);
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Interchange file does not exist: ${path}`);
if (statSync(path).size > MAX_INTERCHANGE_BYTES) throw new Error(`Interchange file exceeds the ${MAX_INTERCHANGE_BYTES}-byte limit`);
return { path, contents: readFileSync(path, "utf8") };
}
function fileUrlPath(value: string | null): string | null {
if (!value || !/^file:\/\//i.test(value)) return null;
try { return fileURLToPath(value); }
catch { return null; }
}
function insideRoot(path: string, root: string): boolean {
const fromRoot = relative(root, path);
return fromRoot === "" || (!fromRoot.startsWith("..") && !isAbsolute(fromRoot));
}
function parseAllowedRoots(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 16) throw new Error("allowed_roots must contain 1 through 16 absolute directories");
return value.map((entry, index) => {
if (typeof entry !== "string" || !isAbsolute(entry)) throw new Error(`allowed_roots[${index}] must be an absolute directory`);
const root = resolve(entry);
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`allowed_roots[${index}] is not an existing directory`);
return root;
});
}
export function getInterchangeAnalysisTools(_bridgeOptions: BridgeOptions) {
return {
inspect_cmx3600_edl: {
description: "Parse a local CMX 3600 EDL into bounded event, reel, track, transition, and timecode facts without importing it into Premiere.",
parameters: { type: "object", properties: { path: { type: "string", description: "Existing local .edl file" } }, required: ["path"] },
handler: async (args: { path?: string }) => {
try { const file = readInterchangeFile(args.path, [".edl"]); const edl = parseCmx3600Edl(file.contents); return { success: true, data: { path: file.path, ...edl, eventCount: edl.events.length } }; }
catch (error) { return { success: false, error: (error as Error).message }; }
},
},
validate_cmx3600_edl: {
description: "Validate a local CMX 3600 EDL's supported event grammar, timecodes, durations, duplicate event IDs, record overlaps, and record gaps before user-assisted Premiere interchange.",
parameters: { type: "object", properties: { path: { type: "string", description: "Existing local .edl file" }, frame_rate: { type: "number", description: "CMX timecode rate: 24, 25, 29.97, 30, 50, 59.94, or 60 (default: 24)" } }, required: ["path"] },
handler: async (args: { path?: string; frame_rate?: CmxFrameRate }) => {
const rate = args.frame_rate ?? 24;
if (![24, 25, 29.97, 30, 50, 59.94, 60].includes(rate)) return { success: false, error: "frame_rate must be 24, 25, 29.97, 30, 50, 59.94, or 60" };
try { const file = readInterchangeFile(args.path, [".edl"]); const edl = parseCmx3600Edl(file.contents); return { success: true, data: { path: file.path, frameRate: rate, ...validateCmx3600Edl(edl, rate) } }; }
catch (error) { return { success: false, error: (error as Error).message }; }
},
},
compare_cmx3600_edls: {
description: "Compare two local CMX 3600 EDLs by event number and report bounded added, removed, and changed editorial events. Read-only; it does not alter either interchange file or Premiere.",
parameters: { type: "object", properties: { before_path: { type: "string", description: "Existing baseline .edl file" }, after_path: { type: "string", description: "Existing revised .edl file" } }, required: ["before_path", "after_path"] },
handler: async (args: { before_path?: string; after_path?: string }) => {
try {
const before = readInterchangeFile(args.before_path, [".edl"]), after = readInterchangeFile(args.after_path, [".edl"]);
return { success: true, data: { beforePath: before.path, afterPath: after.path, ...compareCmx3600Edls(parseCmx3600Edl(before.contents), parseCmx3600Edl(after.contents)) } };
} catch (error) { return { success: false, error: (error as Error).message }; }
},
},
inspect_fcpxml_interchange: {
description: "Inspect a local FCPXML document's root version, sequence/clip counts, bounded asset declarations, and text-only parser warnings before deliberate Premiere import.",
parameters: { type: "object", properties: { path: { type: "string", description: "Existing local .fcpxml or .xml file" } }, required: ["path"] },
handler: async (args: { path?: string }) => {
try { const file = readInterchangeFile(args.path, [".fcpxml", ".xml"]); return { success: true, data: { path: file.path, ...inspectFcpxml(file.contents) } }; }
catch (error) { return { success: false, error: (error as Error).message }; }
},
},
verify_fcpxml_media_references: {
description: "Verify FCPXML file:// media references only inside caller-approved existing roots. References outside those roots are never statted or exposed as local paths.",
parameters: { type: "object", properties: { path: { type: "string", description: "Existing local .fcpxml or .xml file" }, allowed_roots: { type: "array", items: { type: "string" }, description: "One to sixteen existing absolute roots that may be inspected" } }, required: ["path", "allowed_roots"] },
handler: async (args: { path?: string; allowed_roots?: unknown }) => {
try {
const file = readInterchangeFile(args.path, [".fcpxml", ".xml"]), roots = parseAllowedRoots(args.allowed_roots), document = inspectFcpxml(file.contents);
const references = document.assets.map(asset => {
const localPath = fileUrlPath(asset.source);
if (!localPath) return { assetId: asset.id, name: asset.name, status: asset.source ? "non_file_url" : "missing_source" };
const resolved = resolve(localPath);
if (!roots.some(root => insideRoot(resolved, root))) return { assetId: asset.id, name: asset.name, status: "outside_allowed_roots" };
return { assetId: asset.id, name: asset.name, status: existsSync(resolved) && statSync(resolved).isFile() ? "available" : "missing", path: resolved };
});
return { success: true, data: { path: file.path, allowedRoots: roots, checkedReferenceCount: references.length, references } };
} catch (error) { return { success: false, error: (error as Error).message }; }
},
},
};
}
+598
View File
@@ -0,0 +1,598 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getKeyframeTools(bridgeOptions: BridgeOptions) {
return {
get_effect_properties: {
description: "List all properties of a specific effect on a clip, including current values",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect (e.g., 'Motion', 'Opacity', 'Lumetri Color')",
},
},
required: ["node_id", "effect_name"],
},
handler: async (args: { node_id: string; effect_name: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var effectName = "${escapeForExtendScript(args.effect_name)}";
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === effectName || clip.components[i].matchName === effectName) {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found: " + effectName);
var props = [];
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
var info = {
index: p,
displayName: prop.displayName,
isTimeVarying: false,
keyframesSupported: false
};
try { info.isTimeVarying = prop.isTimeVarying(); } catch(e) {}
try { info.keyframesSupported = prop.areKeyframesSupported(); } catch(e) {}
try { info.value = prop.getValue(0, 0); } catch(e) {}
props.push(info);
}
return __result({
effect: comp.displayName,
matchName: comp.matchName,
properties: props
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_effect_property: {
description: "Set the value of a specific effect property on a clip",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect (e.g., 'Motion', 'Opacity')",
},
property_name: {
type: "string",
description: "Display name of the property (e.g., 'Scale', 'Position', 'Opacity')",
},
value: {
type: ["number", "string"],
maxLength: 8192,
description: "Number or string value to set. Use the exact JSON string reported for a MOGRT text or graphic parameter.",
},
},
required: ["node_id", "effect_name", "property_name", "value"],
},
handler: async (args: { node_id: string; effect_name: string; property_name: string; value: number | string }) => {
const requestedValue = typeof args.value === "string"
? `"${escapeForExtendScript(args.value)}"`
: String(args.value);
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found: ${escapeForExtendScript(args.effect_name)}");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found: ${escapeForExtendScript(args.property_name)}");
var requestedValue = ${requestedValue};
try {
prop.setValue(requestedValue, true);
} catch (e) {
return __error("Premiere could not set the requested effect property: " + e.toString());
}
var readbackValue;
var readbackAvailable = true;
try {
readbackValue = prop.getValue();
} catch (eReadback) {
readbackAvailable = false;
}
return __result({
set: true,
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
value: readbackAvailable ? readbackValue : requestedValue,
requestedValue: requestedValue,
readbackVerified: readbackAvailable && readbackValue === requestedValue,
verification: readbackAvailable
? "Premiere parameter readback only; verify playback or exported frames before delivery."
: "Premiere accepted the parameter write, but this property did not expose a readback value. Verify playback or exported frames before delivery."
});
`);
return sendCommand(script, bridgeOptions);
},
},
get_keyframes: {
description: "Get all keyframes for a specific effect property on a clip",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
},
required: ["node_id", "effect_name", "property_name"],
},
handler: async (args: { node_id: string; effect_name: string; property_name: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
var isTimeVarying = false;
try { isTimeVarying = prop.isTimeVarying(); } catch(e) {}
if (!isTimeVarying) {
return __result({ keyframes: [], isTimeVarying: false, message: "Property has no keyframes" });
}
var keys = prop.getKeys();
var keyframes = [];
if (keys) {
for (var k = 0; k < keys.length; k++) {
var time = keys[k];
var val = null;
try { val = prop.getValueAtKey(time); } catch(e) {}
keyframes.push({
time: __ticksToSeconds(time.ticks),
value: val
});
}
}
return __result({
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
isTimeVarying: true,
keyframes: keyframes
});
`);
return sendCommand(script, bridgeOptions);
},
},
add_keyframe: {
description:
"Add and read back a keyframe on an effect property. This verifies stored parameter data only; render/playback verification remains host-dependent.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
time_seconds: {
type: "number",
description: "Time in seconds relative to clip start where to add keyframe",
},
value: {
type: "number",
description: "Value at the keyframe",
},
},
required: ["node_id", "effect_name", "property_name", "time_seconds", "value"],
},
handler: async (args: {
node_id: string;
effect_name: string;
property_name: string;
time_seconds: number;
value: number;
}) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
try {
if (!prop.areKeyframesSupported()) return __error("Property does not support keyframes");
} catch(eSupports) {}
// Enable keyframes if not already
try {
if (!prop.isTimeVarying()) {
prop.setTimeVarying(true);
}
} catch(e) {}
var time = new Time();
time.ticks = __secondsToTicks(${args.time_seconds}).toString();
prop.addKey(time);
prop.setValueAtKey(time, ${args.value}, true);
var readBack = null;
try { readBack = prop.getValueAtKey(time); } catch(eReadBack) {}
if (readBack === null || readBack === undefined) {
return __error("Premiere did not return the keyframe value after writing it; storage is not reported as verified.");
}
if (typeof readBack === "number" && Math.abs(readBack - ${args.value}) > 0.0001) {
return __error("Premiere returned " + readBack + " after writing keyframe value ${args.value}; storage is not reported as verified.");
}
return __result({
added: true,
stored: true,
renderVerified: false,
verificationScope: "Premiere parameter readback only; verify playback or exported frames before relying on visual output.",
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
time: ${args.time_seconds},
value: ${args.value},
readBackValue: readBack
});
`);
return sendCommand(script, bridgeOptions);
},
},
remove_keyframe: {
description: "Remove a keyframe at a specific time from an effect property",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
time_seconds: {
type: "number",
description: "Time in seconds of the keyframe to remove",
},
},
required: ["node_id", "effect_name", "property_name", "time_seconds"],
},
handler: async (args: { node_id: string; effect_name: string; property_name: string; time_seconds: number }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
var time = new Time();
time.ticks = __secondsToTicks(${args.time_seconds}).toString();
prop.removeKey(time);
return __result({
removed: true,
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
time: ${args.time_seconds}
});
`);
return sendCommand(script, bridgeOptions);
},
},
remove_keyframe_range: {
description: "Remove all keyframes in a time range from an effect property",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
start_seconds: {
type: "number",
description: "Start of the range in seconds",
},
end_seconds: {
type: "number",
description: "End of the range in seconds",
},
},
required: ["node_id", "effect_name", "property_name", "start_seconds", "end_seconds"],
},
handler: async (args: {
node_id: string;
effect_name: string;
property_name: string;
start_seconds: number;
end_seconds: number;
}) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
var startTime = new Time();
startTime.ticks = __secondsToTicks(${args.start_seconds}).toString();
var endTime = new Time();
endTime.ticks = __secondsToTicks(${args.end_seconds}).toString();
prop.removeKeyRange(startTime, endTime);
return __result({
removed: true,
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
range: { start: ${args.start_seconds}, end: ${args.end_seconds} }
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_keyframe_interpolation: {
description: "Set the interpolation type of a keyframe (Linear, Hold, or Bezier)",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
time_seconds: {
type: "number",
description: "Time in seconds of the keyframe",
},
interpolation: {
type: "string",
enum: ["linear", "hold", "bezier"],
description: "Interpolation type",
},
},
required: ["node_id", "effect_name", "property_name", "time_seconds", "interpolation"],
},
handler: async (args: {
node_id: string;
effect_name: string;
property_name: string;
time_seconds: number;
interpolation: string;
}) => {
const interpMap: Record<string, number> = { linear: 0, hold: 4, bezier: 5 };
const interpType = interpMap[args.interpolation] ?? 0;
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
var time = new Time();
time.ticks = __secondsToTicks(${args.time_seconds}).toString();
prop.setInterpolationTypeAtKey(time, ${interpType}, true);
return __result({
set: true,
interpolation: "${args.interpolation}",
time: ${args.time_seconds}
});
`);
return sendCommand(script, bridgeOptions);
},
},
get_value_at_time: {
description: "Get the interpolated value of an effect property at a specific time",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
effect_name: {
type: "string",
description: "Display name of the effect",
},
property_name: {
type: "string",
description: "Display name of the property",
},
time_seconds: {
type: "number",
description: "Time in seconds to query the value at",
},
},
required: ["node_id", "effect_name", "property_name", "time_seconds"],
},
handler: async (args: { node_id: string; effect_name: string; property_name: string; time_seconds: number }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
var clip = result.clip;
var comp = null;
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].displayName === "${escapeForExtendScript(args.effect_name)}" || clip.components[i].matchName === "${escapeForExtendScript(args.effect_name)}") {
comp = clip.components[i];
break;
}
}
if (!comp) return __error("Effect not found");
var prop = null;
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "${escapeForExtendScript(args.property_name)}") {
prop = comp.properties[p];
break;
}
}
if (!prop) return __error("Property not found");
var time = new Time();
time.ticks = __secondsToTicks(${args.time_seconds}).toString();
var value = prop.getValueAtTime(time);
return __result({
effect: "${escapeForExtendScript(args.effect_name)}",
property: "${escapeForExtendScript(args.property_name)}",
time: ${args.time_seconds},
value: value
});
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getMarkerTools(bridgeOptions: BridgeOptions) {
return {
add_marker: {
description: "Add a marker to the active sequence or a clip",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
description: "Time position in seconds for the marker",
},
name: {
type: "string",
description: "Name/label for the marker",
},
comments: {
type: "string",
description: "Comments for the marker",
},
color: {
type: "number",
description: "Marker color index (0=Green, 1=Red, 2=Purple, 3=Orange, 4=Yellow, 5=White, 6=Blue, 7=Cyan)",
},
duration_seconds: {
type: "number",
description: "Duration of the marker in seconds (0 for point marker)",
},
node_id: {
type: "string",
description: "Optional clip node ID to add marker to clip instead of sequence",
},
},
required: ["time_seconds"],
},
handler: async (args: {
time_seconds: number;
name?: string;
comments?: string;
color?: number;
duration_seconds?: number;
node_id?: string;
}) => {
const markerTarget = args.node_id
? `var clipResult = __findClip("${escapeForExtendScript(args.node_id)}");
if (!clipResult) return __error("Clip not found");
var markers = clipResult.clip.markers;`
: `var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var markers = seq.markers;`;
const script = buildToolScript(`
${markerTarget}
// createMarker() and the marker.end setter both take seconds, not ticks.
var marker = markers.createMarker(${args.time_seconds});
${args.name ? `marker.name = "${escapeForExtendScript(args.name)}";` : ""}
${args.comments ? `marker.comments = "${escapeForExtendScript(args.comments)}";` : ""}
${args.color !== undefined ? `marker.setColorByIndex(${args.color});` : ""}
${args.duration_seconds ? `marker.end = ${args.time_seconds + args.duration_seconds};` : ""}
return __result({
added: true,
timeSeconds: ${args.time_seconds},
name: marker.name,
comments: marker.comments
});
`);
return sendCommand(script, bridgeOptions);
},
},
delete_marker: {
description: "Delete a marker at a specific time position",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
description: "Time position of the marker to delete",
},
node_id: {
type: "string",
description: "Optional clip node ID (deletes from sequence if omitted)",
},
},
required: ["time_seconds"],
},
handler: async (args: { time_seconds: number; node_id?: string }) => {
const markerTarget = args.node_id
? `var clipResult = __findClip("${escapeForExtendScript(args.node_id)}");
if (!clipResult) return __error("Clip not found");
var markers = clipResult.clip.markers;`
: `var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var markers = seq.markers;`;
const script = buildToolScript(`
${markerTarget}
var targetTicks = __secondsToTicks(${args.time_seconds});
var marker = markers.getFirstMarker();
var deleted = false;
while (marker) {
var markerTicks = parseFloat(marker.start.ticks);
if (Math.abs(markerTicks - targetTicks) < TICKS_PER_SECOND * 0.01) {
markers.deleteMarker(marker);
deleted = true;
break;
}
marker = markers.getNextMarker(marker);
}
if (!deleted) return __error("No marker found at " + ${args.time_seconds} + "s");
return __result({ deleted: true, timeSeconds: ${args.time_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
update_marker: {
description: "Update an existing marker's properties",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
description: "Time position of the marker to update",
},
name: { type: "string", description: "New name" },
comments: { type: "string", description: "New comments" },
color: { type: "number", description: "New color index" },
},
required: ["time_seconds"],
},
handler: async (args: { time_seconds: number; name?: string; comments?: string; color?: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var targetTicks = __secondsToTicks(${args.time_seconds});
var marker = seq.markers.getFirstMarker();
var found = false;
while (marker) {
var markerTicks = parseFloat(marker.start.ticks);
if (Math.abs(markerTicks - targetTicks) < TICKS_PER_SECOND * 0.01) {
${args.name ? `marker.name = "${escapeForExtendScript(args.name)}";` : ""}
${args.comments ? `marker.comments = "${escapeForExtendScript(args.comments)}";` : ""}
${args.color !== undefined ? `marker.setColorByIndex(${args.color});` : ""}
found = true;
break;
}
marker = seq.markers.getNextMarker(marker);
}
if (!found) return __error("No marker found at " + ${args.time_seconds} + "s");
return __result({ updated: true, timeSeconds: ${args.time_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
list_markers: {
description: "List all markers on the active sequence or a specific clip",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Optional clip node ID to list clip markers instead of sequence markers",
},
},
},
handler: async (args: { node_id?: string }) => {
const markerTarget = args.node_id
? `var clipResult = __findClip("${escapeForExtendScript(args.node_id)}");
if (!clipResult) return __error("Clip not found");
var markers = clipResult.clip.markers;`
: `var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var markers = seq.markers;`;
const script = buildToolScript(`
${markerTarget}
var list = [];
var marker = markers.getFirstMarker();
while (marker) {
list.push({
name: marker.name,
comments: marker.comments,
startSeconds: marker.start.seconds,
endSeconds: marker.end.seconds,
type: marker.type
});
marker = markers.getNextMarker(marker);
}
return __result(list);
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+371
View File
@@ -0,0 +1,371 @@
import { execFile } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { dirname, extname, resolve } from "node:path";
import { promisify } from "node:util";
import type { BridgeOptions } from "../bridge/file-bridge.js";
const execFileAsync = promisify(execFile);
const ANALYSIS_TIMEOUT_MS = 300_000;
type ExecFailure = Error & { killed?: boolean; stderr?: string | Buffer };
function inputPath(value: unknown): string | null {
if (typeof value !== "string" || value.trim() === "") return null;
const path = resolve(value);
return existsSync(path) && statSync(path).isFile() ? path : null;
}
function failureMessage(error: unknown, operation: string, timeoutSeconds = 300): string {
const failure = error as ExecFailure;
if (failure.killed) return `${operation} timed out after ${timeoutSeconds} seconds`;
const rawDetail = failure.stderr ?? failure.message ?? "unknown error";
const detail = (Buffer.isBuffer(rawDetail) ? rawDetail.toString("utf8") : String(rawDetail))
.split(/\r?\n/).filter(Boolean).slice(-3).join(" ");
return `${operation} failed: ${detail}`;
}
export interface MediaProbeResult {
format: Record<string, unknown>;
streams: Array<Record<string, unknown>>;
chapters: Array<Record<string, unknown>>;
}
export function parseMediaProbeJson(stdout: string): MediaProbeResult {
const parsed = JSON.parse(stdout) as Record<string, unknown>;
return {
format: parsed.format && typeof parsed.format === "object" ? parsed.format as Record<string, unknown> : {},
streams: Array.isArray(parsed.streams) ? parsed.streams.filter((v): v is Record<string, unknown> => !!v && typeof v === "object") : [],
chapters: Array.isArray(parsed.chapters) ? parsed.chapters.filter((v): v is Record<string, unknown> => !!v && typeof v === "object") : [],
};
}
export interface TransientCandidate { timeSeconds: number; peakDbfs: number }
export function parseTransientCandidates(output: string, thresholdDbfs: number, minimumIntervalSeconds: number): TransientCandidate[] {
const candidates: TransientCandidate[] = [];
let time: number | null = null;
for (const line of output.split(/\r?\n/)) {
const timeMatch = line.match(/\bpts_time:([\d.]+)/);
if (timeMatch) time = Number(timeMatch[1]);
const peakMatch = line.match(/lavfi\.astats\.Overall\.Peak_level=(-?[\d.]+)/);
if (!peakMatch || time === null) continue;
const peakDbfs = Number(peakMatch[1]);
if (peakDbfs >= thresholdDbfs) {
const previous = candidates.at(-1);
const candidate = { timeSeconds: time, peakDbfs };
if (!previous || time - previous.timeSeconds >= minimumIntervalSeconds) candidates.push(candidate);
else if (peakDbfs > previous.peakDbfs) candidates[candidates.length - 1] = candidate;
}
time = null;
}
return candidates;
}
export interface MotionPeakCandidate { timeSeconds: number; difference: number }
export function parseMotionPeakCandidates(output: string, threshold: number, minimumIntervalSeconds: number): MotionPeakCandidate[] {
const samples: MotionPeakCandidate[] = [];
let time: number | null = null;
for (const line of output.split(/\r?\n/)) {
const timeMatch = line.match(/\bpts_time:([\d.]+)/);
if (timeMatch) time = Number(timeMatch[1]);
const differenceMatch = line.match(/lavfi\.signalstats\.YAVG=([\d.]+)/);
if (!differenceMatch || time === null) continue;
samples.push({ timeSeconds: time, difference: Number(differenceMatch[1]) });
time = null;
}
const peaks: MotionPeakCandidate[] = [];
for (let index = 0; index < samples.length; index++) {
const sample = samples[index];
if (!Number.isFinite(sample.difference) || sample.difference < threshold) continue;
if (index > 0 && sample.difference <= samples[index - 1].difference) continue;
if (index + 1 < samples.length && sample.difference < samples[index + 1].difference) continue;
const previous = peaks.at(-1);
if (!previous || sample.timeSeconds - previous.timeSeconds >= minimumIntervalSeconds) peaks.push(sample);
else if (sample.difference > previous.difference) peaks[peaks.length - 1] = sample;
}
return peaks;
}
export interface VideoScopeReading {
pixels: number;
waveform: { black: number; shadows: number; median: number; highlights: number; white: number };
rgbParade: Record<"red" | "green" | "blue", { low: number; median: number; high: number }>;
saturation: { mean: number; high: number };
rgbExtremes: { nearBlackPercent: number; nearWhitePercent: number };
}
export function analyzeRgbScopes(bytes: Uint8Array): VideoScopeReading {
if (bytes.length < 3 || bytes.length % 3 !== 0) throw new Error("RGB scope analysis requires complete RGB24 pixels");
const red: number[] = [], green: number[] = [], blue: number[] = [], luma: number[] = [], saturation: number[] = [];
let below = 0, above = 0;
for (let index = 0; index < bytes.length; index += 3) {
const r = bytes[index] / 255, g = bytes[index + 1] / 255, b = bytes[index + 2] / 255;
const y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const maximum = Math.max(r, g, b), minimum = Math.min(r, g, b);
red.push(r * 100); green.push(g * 100); blue.push(b * 100); luma.push(y * 100);
saturation.push(maximum > 0 ? (maximum - minimum) / maximum * 100 : 0);
if (y < 16 / 255) below++;
if (y > 235 / 255) above++;
}
for (const values of [red, green, blue, luma, saturation]) values.sort((a, b) => a - b);
const percentile = (values: number[], fraction: number) => Number(values[Math.min(values.length - 1, Math.max(0, Math.round((values.length - 1) * fraction)))].toFixed(2));
const channel = (values: number[]) => ({ low: percentile(values, 0.1), median: percentile(values, 0.5), high: percentile(values, 0.9) });
const pixels = luma.length;
return {
pixels,
waveform: { black: percentile(luma, 0.01), shadows: percentile(luma, 0.1), median: percentile(luma, 0.5), highlights: percentile(luma, 0.9), white: percentile(luma, 0.99) },
rgbParade: { red: channel(red), green: channel(green), blue: channel(blue) },
saturation: { mean: Number((saturation.reduce((sum, value) => sum + value, 0) / pixels).toFixed(2)), high: percentile(saturation, 0.9) },
rgbExtremes: { nearBlackPercent: Number((below / pixels * 100).toFixed(2)), nearWhitePercent: Number((above / pixels * 100).toFixed(2)) },
};
}
export function compareScopeReadings(reference: VideoScopeReading, target: VideoScopeReading) {
const delta = (targetValue: number, referenceValue: number) => Number((targetValue - referenceValue).toFixed(2));
const redBlueBalance = (reading: VideoScopeReading) => {
const red = reading.rgbParade.red.median, blue = reading.rgbParade.blue.median;
return red + blue > 0 ? (red - blue) / (red + blue) * 100 : 0;
};
const referenceBalance = redBlueBalance(reference);
const targetBalance = redBlueBalance(target);
const balanceDelta = delta(targetBalance, referenceBalance);
return {
targetMinusReference: {
medianLuma: delta(target.waveform.median, reference.waveform.median),
shadowLuma: delta(target.waveform.shadows, reference.waveform.shadows),
highlightLuma: delta(target.waveform.highlights, reference.waveform.highlights),
redMedian: delta(target.rgbParade.red.median, reference.rgbParade.red.median),
greenMedian: delta(target.rgbParade.green.median, reference.rgbParade.green.median),
blueMedian: delta(target.rgbParade.blue.median, reference.rgbParade.blue.median),
saturationMean: delta(target.saturation.mean, reference.saturation.mean),
redBlueBalance: balanceDelta,
},
suggestedDirections: {
exposure: target.waveform.median < reference.waveform.median ? "raise" : target.waveform.median > reference.waveform.median ? "lower" : "hold",
warmth: Math.abs(balanceDelta) < 0.25 ? "hold" : balanceDelta < 0 ? "warmer" : "cooler",
saturation: target.saturation.mean < reference.saturation.mean ? "raise" : target.saturation.mean > reference.saturation.mean ? "lower" : "hold",
},
};
}
async function decodeScopeFrame(path: string, time: number): Promise<VideoScopeReading> {
const result = await execFileAsync("ffmpeg", ["-v", "error", "-ss", String(time), "-i", path, "-frames:v", "1", "-vf", "scale=320:180:flags=area", "-pix_fmt", "rgb24", "-f", "rawvideo", "pipe:1"], { encoding: "buffer", timeout: 60_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }) as unknown as { stdout: Buffer };
if (result.stdout.length !== 320 * 180 * 3) throw new Error("ffmpeg did not return one complete RGB scope frame");
return analyzeRgbScopes(result.stdout);
}
export interface InterlaceAnalysis { tff: number; bff: number; progressive: number; undetermined: number; classification: "tff" | "bff" | "progressive" | "mixed" | "undetermined" }
export function parseIdetOutput(output: string): InterlaceAnalysis {
const matches = [...output.matchAll(/Multi frame detection:\s*TFF:\s*(\d+)\s+BFF:\s*(\d+)\s+Progressive:\s*(\d+)\s+Undetermined:\s*(\d+)/g)];
const match = matches.at(-1);
const tff = Number(match?.[1] ?? 0);
const bff = Number(match?.[2] ?? 0);
const progressive = Number(match?.[3] ?? 0);
const undetermined = Number(match?.[4] ?? 0);
const known = tff + bff + progressive;
let classification: InterlaceAnalysis["classification"] = "undetermined";
if (known > 0) {
const dominant = Math.max(tff, bff, progressive);
if (dominant / known < 0.8) classification = "mixed";
else classification = dominant === progressive ? "progressive" : dominant === tff ? "tff" : "bff";
}
return { tff, bff, progressive, undetermined, classification };
}
export interface PictureBounds { width: number; height: number; x: number; y: number; samples: number }
export function parseCropDetectOutput(output: string): PictureBounds | null {
const counts = new Map<string, number>();
for (const match of output.matchAll(/\bcrop=(\d+):(\d+):(\d+):(\d+)/g)) {
const key = `${match[1]}:${match[2]}:${match[3]}:${match[4]}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const winner = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
if (!winner) return null;
const [width, height, x, y] = winner[0].split(":").map(Number);
return { width, height, x, y, samples: winner[1] };
}
export function getMediaAnalysisTools(_bridgeOptions: BridgeOptions) {
return {
inspect_media_streams: {
description: "Inspect a local media file with ffprobe and return container, stream, codec, time-base, channel, and chapter metadata. Read-only and independent of Premiere.",
parameters: { type: "object", properties: { media_path: { type: "string", description: "Existing local media file" } }, required: ["media_path"] },
handler: async (args: { media_path?: string }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
try {
const { stdout } = await execFileAsync("ffprobe", ["-v", "error", "-show_format", "-show_streams", "-show_chapters", "-of", "json", path], { timeout: 60_000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
const data = parseMediaProbeJson(stdout);
return { success: true, data: { mediaPath: path, ...data, streamCount: data.streams.length } };
} catch (error) { return { success: false, error: failureMessage(error, "ffprobe media inspection") }; }
},
},
generate_media_contact_sheet: {
description: "Generate a new, disk-verified PNG contact sheet from evenly sampled source frames. Refuses to overwrite an existing output and does not modify Premiere or the source.",
parameters: { type: "object", properties: {
media_path: { type: "string", description: "Existing local video file" },
output_path: { type: "string", description: "New .png output path" },
columns: { type: "integer", description: "Grid columns from 2 through 8 (default: 4)" },
rows: { type: "integer", description: "Grid rows from 2 through 8 (default: 3)" },
thumbnail_width: { type: "integer", description: "Thumbnail width from 160 through 1280 pixels (default: 320)" },
}, required: ["media_path", "output_path"] },
handler: async (args: { media_path?: string; output_path?: string; columns?: number; rows?: number; thumbnail_width?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
if (typeof args.output_path !== "string" || extname(args.output_path).toLowerCase() !== ".png") return { success: false, error: "output_path must be a new .png file" };
const outputPath = resolve(args.output_path);
if (existsSync(outputPath)) return { success: false, error: "output_path already exists; contact sheets never overwrite files" };
const columns = args.columns ?? 4, rows = args.rows ?? 3, width = args.thumbnail_width ?? 320;
if (![columns, rows].every(v => Number.isInteger(v) && v >= 2 && v <= 8)) return { success: false, error: "columns and rows must be integers from 2 through 8" };
if (!Number.isInteger(width) || width < 160 || width > 1280) return { success: false, error: "thumbnail_width must be an integer from 160 through 1280" };
try {
const probe = await execFileAsync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", path], { timeout: 60_000, windowsHide: true });
const duration = Number(probe.stdout.trim());
if (!Number.isFinite(duration) || duration <= 0) return { success: false, error: "ffprobe did not return a positive media duration" };
const frames = columns * rows;
const interval = Math.max(duration / frames, 0.04);
await execFileAsync("ffmpeg", ["-v", "error", "-i", path, "-vf", `fps=1/${interval},scale=${width}:-1,tile=${columns}x${rows}`, "-frames:v", "1", outputPath], { timeout: ANALYSIS_TIMEOUT_MS, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
if (!existsSync(outputPath) || !statSync(outputPath).isFile() || statSync(outputPath).size < 1) return { success: false, error: "ffmpeg completed without creating a non-empty contact sheet" };
return { success: true, data: { mediaPath: path, outputPath, grid: { columns, rows, requestedFrames: frames }, thumbnailWidth: width, sizeBytes: statSync(outputPath).size, outputDirectory: dirname(outputPath), verified: true } };
} catch (error) { return { success: false, error: failureMessage(error, "contact-sheet generation") }; }
},
},
detect_audio_transients: {
description: "Find probable beat or edit-point transients from decoded audio peaks. Returns candidates for editorial review; it does not claim musical beat-grid accuracy or change a timeline.",
parameters: { type: "object", properties: {
media_path: { type: "string", description: "Existing local audio or video file" },
threshold_dbfs: { type: "number", description: "Minimum transient peak from -60 through 0 dBFS (default: -12)" },
minimum_interval_seconds: { type: "number", description: "Minimum spacing from 0.05 through 10 seconds (default: 0.25)" },
maximum_events: { type: "integer", description: "Maximum returned candidates from 1 through 1000 (default: 200)" },
}, required: ["media_path"] },
handler: async (args: { media_path?: string; threshold_dbfs?: number; minimum_interval_seconds?: number; maximum_events?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
const threshold = args.threshold_dbfs ?? -12, interval = args.minimum_interval_seconds ?? 0.25, maximum = args.maximum_events ?? 200;
if (!Number.isFinite(threshold) || threshold < -60 || threshold > 0) return { success: false, error: "threshold_dbfs must be from -60 through 0" };
if (!Number.isFinite(interval) || interval < 0.05 || interval > 10) return { success: false, error: "minimum_interval_seconds must be from 0.05 through 10" };
if (!Number.isInteger(maximum) || maximum < 1 || maximum > 1000) return { success: false, error: "maximum_events must be an integer from 1 through 1000" };
try {
const result = await execFileAsync("ffmpeg", ["-v", "info", "-i", path, "-vn", "-af", "astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.Peak_level", "-f", "null", "-"], { timeout: ANALYSIS_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
const all = parseTransientCandidates(`${result.stdout}\n${result.stderr}`, threshold, interval);
return { success: true, data: { mediaPath: path, thresholdDbfs: threshold, minimumIntervalSeconds: interval, totalDetected: all.length, truncated: all.length > maximum, candidates: all.slice(0, maximum), verificationScope: "Peak-derived transient candidates only; confirm rhythm and editorial suitability by listening." } };
} catch (error) { return { success: false, error: failureMessage(error, "audio transient analysis") }; }
},
},
detect_motion_peaks: {
description: "Find probable high-motion moments in a bounded local video sample from decoded frame differences. Read-only editorial candidates; camera movement, flashes, cuts, and subject motion are not semantically distinguished.",
parameters: { type: "object", properties: {
media_path: { type: "string", description: "Existing local video file" },
sample_seconds: { type: "number", description: "Decode duration from 1 through 300 seconds (default: 60)" },
samples_per_second: { type: "number", description: "Frame samples per second from 1 through 10 (default: 4)" },
threshold: { type: "number", description: "Minimum mean luma-frame difference from 0 through 255 (default: 12)" },
minimum_interval_seconds: { type: "number", description: "Minimum peak spacing from 0.1 through 30 seconds (default: 1)" },
maximum_events: { type: "integer", description: "Maximum returned candidates from 1 through 1000 (default: 200)" },
}, required: ["media_path"] },
handler: async (args: { media_path?: string; sample_seconds?: number; samples_per_second?: number; threshold?: number; minimum_interval_seconds?: number; maximum_events?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
const seconds = args.sample_seconds ?? 60, rate = args.samples_per_second ?? 4;
const threshold = args.threshold ?? 12, interval = args.minimum_interval_seconds ?? 1, maximum = args.maximum_events ?? 200;
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 300) return { success: false, error: "sample_seconds must be from 1 through 300" };
if (!Number.isFinite(rate) || rate < 1 || rate > 10) return { success: false, error: "samples_per_second must be from 1 through 10" };
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 255) return { success: false, error: "threshold must be from 0 through 255" };
if (!Number.isFinite(interval) || interval < 0.1 || interval > 30) return { success: false, error: "minimum_interval_seconds must be from 0.1 through 30" };
if (!Number.isInteger(maximum) || maximum < 1 || maximum > 1000) return { success: false, error: "maximum_events must be an integer from 1 through 1000" };
try {
const filter = `fps=${rate},scale=160:-2:flags=area,format=gray,tblend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG`;
const result = await execFileAsync("ffmpeg", ["-v", "info", "-i", path, "-t", String(seconds), "-vf", filter, "-an", "-f", "null", "-"], { timeout: ANALYSIS_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
const all = parseMotionPeakCandidates(`${result.stdout}\n${result.stderr}`, threshold, interval);
return { success: true, data: {
mediaPath: path, sampleSeconds: seconds, samplesPerSecond: rate, threshold,
minimumIntervalSeconds: interval, totalDetected: all.length, truncated: all.length > maximum,
candidates: all.slice(0, maximum),
verificationScope: "Mean luma frame-difference peaks from a downscaled decoded sample only. Camera movement, flashes, edits, and subject motion can produce similar scores; confirm candidates visually before editing."
} };
} catch (error) { return { success: false, error: failureMessage(error, "motion-peak analysis") }; }
},
},
read_video_scopes: {
description: "Read waveform percentiles, RGB parade percentiles, saturation, and near-black/near-white RGB occupancy from one bounded decoded local-media frame. Read-only; this is a sampled analytical proxy, not Premiere's rendered scopes.",
parameters: { type: "object", properties: {
media_path: { type: "string", description: "Existing local video file" },
time_seconds: { type: "number", description: "Source-relative frame time from 0 through 86400 seconds (default: 0)" },
}, required: ["media_path"] },
handler: async (args: { media_path?: string; time_seconds?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
const time = args.time_seconds ?? 0;
if (!Number.isFinite(time) || time < 0 || time > 86_400) return { success: false, error: "time_seconds must be from 0 through 86400" };
try {
const scopes = await decodeScopeFrame(path, time);
return { success: true, data: {
mediaPath: path, timeSeconds: time, sampleSize: { width: 320, height: 180 }, ...scopes,
verificationScope: "One source-relative frame decoded and downscaled to 320x180. Values are post-conversion RGB/luma sample statistics; near-black/near-white occupancy does not prove source-domain legal-range violations. This is not a Premiere program-monitor render, HDR interpretation, vectorscope trace, or visual grade approval."
} };
} catch (error) { return { success: false, error: failureMessage(error, "video scope analysis", 60) }; }
},
},
plan_shot_match: {
description: "Compare two bounded local-media frame samples and return measured waveform/parade/saturation deltas plus coarse correction directions. Read-only planning only; it does not grade Premiere or claim that primaries alone can match the shots.",
parameters: { type: "object", properties: {
reference_media_path: { type: "string", description: "Existing local reference video file" },
target_media_path: { type: "string", description: "Existing local target video file" },
reference_time_seconds: { type: "number", description: "Reference source time from 0 through 86400 seconds (default: 0)" },
target_time_seconds: { type: "number", description: "Target source time from 0 through 86400 seconds (default: 0)" },
}, required: ["reference_media_path", "target_media_path"] },
handler: async (args: { reference_media_path?: string; target_media_path?: string; reference_time_seconds?: number; target_time_seconds?: number }) => {
const referencePath = inputPath(args.reference_media_path), targetPath = inputPath(args.target_media_path);
if (!referencePath) return { success: false, error: "reference_media_path must identify an existing regular file" };
if (!targetPath) return { success: false, error: "target_media_path must identify an existing regular file" };
const referenceTime = args.reference_time_seconds ?? 0, targetTime = args.target_time_seconds ?? 0;
if (!Number.isFinite(referenceTime) || referenceTime < 0 || referenceTime > 86_400) return { success: false, error: "reference_time_seconds must be from 0 through 86400" };
if (!Number.isFinite(targetTime) || targetTime < 0 || targetTime > 86_400) return { success: false, error: "target_time_seconds must be from 0 through 86400" };
try {
const reference = await decodeScopeFrame(referencePath, referenceTime);
const target = await decodeScopeFrame(targetPath, targetTime);
return { success: true, data: {
reference: { mediaPath: referencePath, timeSeconds: referenceTime, scopes: reference },
target: { mediaPath: targetPath, timeSeconds: targetTime, scopes: target },
comparison: compareScopeReadings(reference, target),
verificationScope: "Two source-relative 320x180 post-conversion RGB samples. Directions are coarse planning hints, not numeric Lumetri settings, a Premiere-render comparison, semantic shot equivalence, or proof that the shots can be matched with primary corrections."
} };
} catch (error) { return { success: false, error: failureMessage(error, "shot-match planning", 60) }; }
},
},
analyze_video_interlacing: {
description: "Classify decoded video frames as progressive, top-field-first, bottom-field-first, mixed, or undetermined using FFmpeg idet. Read-only delivery preflight.",
parameters: { type: "object", properties: { media_path: { type: "string", description: "Existing local video file" }, sample_seconds: { type: "number", description: "Decode sample duration from 1 through 300 seconds (default: 30)" } }, required: ["media_path"] },
handler: async (args: { media_path?: string; sample_seconds?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
const seconds = args.sample_seconds ?? 30;
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 300) return { success: false, error: "sample_seconds must be from 1 through 300" };
try {
const result = await execFileAsync("ffmpeg", ["-v", "info", "-i", path, "-t", String(seconds), "-vf", "idet", "-an", "-f", "null", "-"], { timeout: ANALYSIS_TIMEOUT_MS, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
const analysis = parseIdetOutput(`${result.stdout}\n${result.stderr}`);
return { success: true, data: { mediaPath: path, sampleSeconds: seconds, ...analysis, passesProgressiveDelivery: analysis.classification === "progressive", verificationScope: "Decoded sample only; mixed and undetermined results require visual or scope review." } };
} catch (error) { return { success: false, error: failureMessage(error, "interlace analysis") }; }
},
},
detect_active_picture_bounds: {
description: "Detect the most frequent active-picture crop rectangle in decoded video, exposing probable letterbox or pillarbox bars without modifying the source.",
parameters: { type: "object", properties: { media_path: { type: "string", description: "Existing local video file" }, sample_seconds: { type: "number", description: "Decode sample duration from 1 through 300 seconds (default: 30)" }, limit: { type: "integer", description: "Cropdetect black threshold from 0 through 255 (default: 24)" } }, required: ["media_path"] },
handler: async (args: { media_path?: string; sample_seconds?: number; limit?: number }) => {
const path = inputPath(args.media_path);
if (!path) return { success: false, error: "media_path must identify an existing regular file" };
const seconds = args.sample_seconds ?? 30, limit = args.limit ?? 24;
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 300) return { success: false, error: "sample_seconds must be from 1 through 300" };
if (!Number.isInteger(limit) || limit < 0 || limit > 255) return { success: false, error: "limit must be an integer from 0 through 255" };
try {
const result = await execFileAsync("ffmpeg", ["-v", "info", "-i", path, "-t", String(seconds), "-vf", `cropdetect=limit=${limit}:round=2:reset=0`, "-an", "-f", "null", "-"], { timeout: ANALYSIS_TIMEOUT_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
const bounds = parseCropDetectOutput(`${result.stdout}\n${result.stderr}`);
if (!bounds) return { success: false, error: "cropdetect returned no active-picture measurements" };
return { success: true, data: { mediaPath: path, sampleSeconds: seconds, limit, activePicture: bounds, verificationScope: "Most frequent decoded crop candidate; intentional borders and dark scenes require visual review." } };
} catch (error) { return { success: false, error: failureMessage(error, "active-picture detection") }; }
},
},
};
}
+171
View File
@@ -0,0 +1,171 @@
import { createHash } from "node:crypto";
import { readdirSync, realpathSync, statSync, watch, type FSWatcher } from "node:fs";
import path from "node:path";
const MAX_PENDING_EVENTS = 1_000;
const MAX_SCAN_FILES = 5_000;
type Snapshot = { relativePath: string; pathHash: string; size: number; modifiedMs: number; extension: string };
type WatchState = {
id: string;
workspaceRoot: string;
watchRoot: string;
extensions: Set<string>;
recursive: boolean;
targetBinId?: string;
baseline: Map<string, Snapshot>;
pending: Set<string>;
overflow: boolean;
watcher: FSWatcher;
};
function hash(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
function requiredPath(value: unknown, name: string): string {
if (typeof value !== "string" || !value.trim() || value.length > 4096 || !path.isAbsolute(value)) throw new Error(`${name} must be an absolute path of at most 4096 characters`);
return value;
}
function contained(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
}
function extensionSet(value: unknown): Set<string> {
if (!Array.isArray(value) || value.length === 0 || value.length > 64) throw new Error("allowed_extensions must contain between 1 and 64 entries");
const values = value.map((entry, index) => {
if (typeof entry !== "string") throw new Error(`allowed_extensions[${index}] must be a string`);
const normalized = entry.trim().replace(/^\.+/, "").toLocaleLowerCase();
if (!/^[a-z0-9]{1,16}$/.test(normalized)) throw new Error(`allowed_extensions[${index}] is invalid`);
return normalized;
});
if (new Set(values).size !== values.length) throw new Error("allowed_extensions contains duplicates");
return new Set(values);
}
function scan(root: string, extensions: Set<string>, recursive: boolean): Map<string, Snapshot> {
const output = new Map<string, Snapshot>();
const queue = [root];
while (queue.length) {
const directory = queue.shift() as string;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
const resolved = realpathSync(candidate);
if (!contained(root, resolved)) continue;
if (entry.isDirectory()) { if (recursive) queue.push(resolved); continue; }
if (!entry.isFile()) continue;
const extension = path.extname(entry.name).slice(1).toLocaleLowerCase();
if (!extensions.has(extension)) continue;
const details = statSync(resolved);
const relativePath = path.relative(root, resolved).replace(/\\/g, "/");
output.set(relativePath, { relativePath, pathHash: hash(resolved.normalize("NFC").toLocaleLowerCase()), size: details.size, modifiedMs: details.mtimeMs, extension });
if (output.size > MAX_SCAN_FILES) throw new Error(`watched folder exceeds the ${MAX_SCAN_FILES} file scan limit`);
}
}
return output;
}
export class MediaWatchRegistry {
private state?: WatchState;
start(args: Record<string, unknown>) {
if (this.state) throw new Error("a media watch is already active; stop it before starting another");
const workspaceRoot = realpathSync(requiredPath(args.approved_workspace_path, "approved_workspace_path"));
const watchRoot = realpathSync(requiredPath(args.watch_path, "watch_path"));
if (!statSync(workspaceRoot).isDirectory() || !statSync(watchRoot).isDirectory()) throw new Error("approved workspace and watch path must be directories");
if (!contained(workspaceRoot, watchRoot)) throw new Error("watch_path must be contained within approved_workspace_path");
const extensions = extensionSet(args.allowed_extensions);
const recursive = args.recursive === true;
if (args.recursive !== undefined && typeof args.recursive !== "boolean") throw new Error("recursive must be a boolean");
const targetBinId = args.target_bin_id === undefined ? undefined : String(args.target_bin_id);
if (targetBinId !== undefined && (!targetBinId.trim() || targetBinId.length > 512)) throw new Error("target_bin_id must be 1-512 characters");
const baseline = scan(watchRoot, extensions, recursive);
const pending = new Set<string>();
const state = { id: hash(`${watchRoot}:${Date.now()}`).slice(7, 39), workspaceRoot, watchRoot, extensions, recursive, targetBinId, baseline, pending, overflow: false, watcher: undefined as unknown as FSWatcher };
const watcher = watch(watchRoot, { recursive }, (_event, filename) => {
if (!filename) state.overflow = true;
else if (state.pending.size >= MAX_PENDING_EVENTS) state.overflow = true;
else state.pending.add(String(filename).replace(/\\/g, "/"));
});
watcher.on("error", () => { state.overflow = true; });
watcher.unref();
state.watcher = watcher;
this.state = state;
return this.status();
}
status() {
const state = this.state;
return state ? { active: true, watch_id: state.id, recursive: state.recursive, allowed_extensions: [...state.extensions].sort(), baseline_file_count: state.baseline.size, pending_event_count: state.pending.size, overflow: state.overflow, target_bin_id: state.targetBinId ?? null, paths_redacted: true } : { active: false, paths_redacted: true };
}
preview(args: Record<string, unknown>) {
const state = this.state;
if (!state) throw new Error("no media watch is active");
if (args.watch_id !== state.id) throw new Error("watch_id does not match the active media watch");
const includePaths = args.include_paths === true;
if (args.include_paths !== undefined && typeof args.include_paths !== "boolean") throw new Error("include_paths must be a boolean");
const known = new Set<string>();
if (args.known_media_path_hashes !== undefined) {
if (!Array.isArray(args.known_media_path_hashes) || args.known_media_path_hashes.length > 5_000) throw new Error("known_media_path_hashes must contain at most 5000 entries");
for (const [index, value] of args.known_media_path_hashes.entries()) {
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`known_media_path_hashes[${index}] must be a sha256 hash`);
known.add(value);
}
}
const current = scan(state.watchRoot, state.extensions, state.recursive);
const proposed = [...current.values()].filter((item) => {
const prior = state.baseline.get(item.relativePath);
return (!prior || prior.size !== item.size || prior.modifiedMs !== item.modifiedMs) && !known.has(item.pathHash);
}).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
const plan = proposed.map((item) => ({ path_hash: item.pathHash, extension: item.extension, size: item.size, modified_ms: item.modifiedMs, target_bin_id: state.targetBinId ?? null, ...(includePaths ? { media_path: path.join(state.watchRoot, item.relativePath) } : {}) }));
return { watch_id: state.id, plan_digest: hash(JSON.stringify(plan)), proposed_count: plan.length, proposed_imports: plan, incomplete: state.overflow, pending_event_count: state.pending.size, applied: false, paths_disclosed: includePaths, limitations: ["This preview does not import media.", "Files may still change after preview; revalidate them immediately before import."] };
}
rescan() {
const state = this.state;
if (!state) throw new Error("no media watch is active");
state.baseline = scan(state.watchRoot, state.extensions, state.recursive);
state.pending.clear(); state.overflow = false;
return this.status();
}
close() {
if (this.state) this.state.watcher.close();
this.state = undefined;
}
}
export function getMediaWatchTools(registry: MediaWatchRegistry) {
return {
manage_media_watch: {
description: "Start, inspect, rescan, or stop one session-scoped local media-folder monitor. It records bounded file-change signals and never imports media automatically.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
action: { type: "string", enum: ["start", "status", "scan", "stop"], description: "Watcher action." },
approved_workspace_path: { type: "string", maxLength: 4096, description: "Absolute approved workspace root; required for start." },
watch_path: { type: "string", maxLength: 4096, description: "Absolute contained directory to monitor; required for start." },
allowed_extensions: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1, maxLength: 17 }, description: "File extensions eligible for proposals; required for start." },
recursive: { type: "boolean", description: "Monitor contained subdirectories; defaults to false." },
target_bin_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional proposed Premiere destination-bin ID." },
}, required: ["action"] },
handler: async (args: Record<string, unknown>) => {
try {
if (args.action === "start") return { success: true, data: registry.start(args) };
if (args.action === "status") return { success: true, data: registry.status() };
if (args.action === "scan") return { success: true, data: registry.rescan() };
if (args.action === "stop") { registry.close(); return { success: true, data: registry.status() }; }
return { success: false, error: `Unsupported media-watch action: ${String(args.action)}` };
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
preview_watched_media_import: {
description: "Compare the active watch baseline with a fresh contained scan and return a path-redacted import proposal. It never imports or changes Premiere.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
watch_id: { type: "string", minLength: 1, maxLength: 64, description: "ID returned when the session watch started." },
known_media_path_hashes: { type: "array", maxItems: 5000, items: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" }, description: "Optional hashes already represented in the Premiere project." },
include_paths: { type: "boolean", description: "Explicitly disclose contained paths for selected imports; defaults to false." },
}, required: ["watch_id"] },
handler: async (args: Record<string, unknown>) => {
try { return { success: true, data: registry.preview(args) }; }
catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
};
}
+489
View File
@@ -0,0 +1,489 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getMediaTools(bridgeOptions: BridgeOptions) {
return {
import_media: {
description: "Import media files into the project",
parameters: {
type: "object" as const,
properties: {
file_paths: {
type: "array",
items: { type: "string" },
description: "Array of file paths to import",
},
target_bin: {
type: "string",
description: "Optional bin name or node ID to import into. Imports to root if omitted.",
},
suppress_ui: {
type: "boolean",
description: "Suppress import dialogs (default: true)",
},
},
required: ["file_paths"],
},
handler: async (args: { file_paths: string[]; target_bin?: string; suppress_ui?: boolean }) => {
const paths = args.file_paths.map((p) => `"${escapeForExtendScript(p)}"`).join(", ");
const suppress = args.suppress_ui !== false ? "true" : "false";
const binLookup = args.target_bin
? `var targetBin = __findProjectItem("${escapeForExtendScript(args.target_bin)}");
if (!targetBin) return __error("Bin not found: ${escapeForExtendScript(args.target_bin)}");`
: `var targetBin = app.project.rootItem;`;
const script = buildToolScript(`
${binLookup}
var filePaths = [${paths}];
var importSuccess = app.project.importFiles(filePaths, ${suppress}, targetBin, false);
if (!importSuccess) return __error("Import failed");
return __result({ imported: filePaths.length, files: filePaths });
`);
return sendCommand(script, bridgeOptions);
},
},
import_folder: {
description: "Import an entire folder of media into the project",
parameters: {
type: "object" as const,
properties: {
folder_path: {
type: "string",
description: "Path to the folder to import",
},
},
required: ["folder_path"],
},
handler: async (args: { folder_path: string }) => {
const script = buildToolScript(`
var folder = new Folder("${escapeForExtendScript(args.folder_path)}");
if (!folder.exists) return __error("Folder not found: ${escapeForExtendScript(args.folder_path)}");
var files = folder.getFiles();
var filePaths = [];
for (var i = 0; i < files.length; i++) {
if (files[i] instanceof File) {
filePaths.push(files[i].fsName);
}
}
if (filePaths.length === 0) return __error("No files found in folder");
var importSuccess = app.project.importFiles(filePaths, true, app.project.rootItem, false);
if (!importSuccess) return __error("Import failed");
return __result({ imported: filePaths.length, folder: "${escapeForExtendScript(args.folder_path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
create_bin: {
description: "Create a new bin (folder) in the project panel",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name of the new bin",
},
parent_bin: {
type: "string",
description: "Optional parent bin name or node ID. Creates in root if omitted.",
},
},
required: ["name"],
},
handler: async (args: { name: string; parent_bin?: string }) => {
const parentLookup = args.parent_bin
? `var parent = __findProjectItem("${escapeForExtendScript(args.parent_bin)}");
if (!parent) return __error("Parent bin not found");`
: `var parent = app.project.rootItem;`;
const script = buildToolScript(`
${parentLookup}
var newBin = parent.createBin("${escapeForExtendScript(args.name)}");
return __result({ created: true, name: "${escapeForExtendScript(args.name)}", nodeId: newBin.nodeId });
`);
return sendCommand(script, bridgeOptions);
},
},
move_item_to_bin: {
description: "Move a project item to a different bin",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the item to move",
},
target_bin: {
type: "string",
description: "Name or node ID of the target bin",
},
},
required: ["item_id", "target_bin"],
},
handler: async (args: { item_id: string; target_bin: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
var targetBin = __findProjectItem("${escapeForExtendScript(args.target_bin)}");
if (!targetBin) return __error("Target bin not found: ${escapeForExtendScript(args.target_bin)}");
item.moveBin(targetBin);
return __result({ moved: true, item: item.name, toBin: targetBin.name });
`);
return sendCommand(script, bridgeOptions);
},
},
relink_media: {
description: "Relink an offline media item to a new file path",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the item to relink",
},
new_path: {
type: "string",
description: "New file path for the media",
},
},
required: ["item_id", "new_path"],
},
handler: async (args: { item_id: string; new_path: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
var success = item.changeMediaPath("${escapeForExtendScript(args.new_path)}", true);
return __result({ relinked: success, item: item.name, newPath: "${escapeForExtendScript(args.new_path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
refresh_media: {
description: "Refresh a project item to pick up changes to the source file",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the item to refresh",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
item.refreshMedia();
return __result({ refreshed: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
check_offline_media: {
description: "Check for offline (missing) media in the project",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var offlineItems = [];
function checkItem(item) {
if (item.type === 1) {
try {
if (item.isOffline && item.isOffline()) {
offlineItems.push({
nodeId: item.nodeId,
name: item.name,
mediaPath: item.getMediaPath ? item.getMediaPath() : ""
});
}
} catch(e) {}
}
if (item.type === 2 && item.children) {
for (var i = 0; i < item.children.numItems; i++) {
checkItem(item.children[i]);
}
}
}
var root = app.project.rootItem;
for (var i = 0; i < root.children.numItems; i++) {
checkItem(root.children[i]);
}
return __result({ offlineCount: offlineItems.length, items: offlineItems });
`);
return sendCommand(script, bridgeOptions);
},
},
set_offline: {
description: "Set a project item offline, or ask Premiere to refresh it back online when offline is false.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
offline: {
type: "boolean",
description: "true to take media offline (default); false to refresh an existing offline item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string; offline?: boolean }) => {
const offline = args.offline !== false;
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found: ${escapeForExtendScript(args.item_id)}");
${offline
? `item.setOffline();`
: `item.refreshMedia();`
}
var observedOffline = !!item.isOffline();
if (observedOffline !== ${offline}) {
return __error(${offline
? `"Premiere did not take the project item offline."`
: `"Premiere could not refresh this item online. Use relink_media with the known media path if the source has moved."`
});
}
return __result({ offline: observedOffline, item: item.name, verified: true });
`);
return sendCommand(script, bridgeOptions);
},
},
has_proxy: {
description: "Check if a project item has a proxy attached",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var info = { item: item.name };
try { info.hasProxy = item.hasProxy(); } catch(e) { info.hasProxy = false; }
try { info.canProxy = item.canProxy(); } catch(e) { info.canProxy = false; }
try { info.proxyPath = item.getProxyPath(); } catch(e) {}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
detach_proxy: {
description: "Detach/remove the proxy from a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.detachProxy();
return __result({ detached: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
set_override_frame_rate: {
description: "Override the frame rate of a project item (useful for image sequences or misinterpreted media)",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
frame_rate: {
type: "number",
description: "Frame rate to set (e.g., 23.976, 24, 29.97, 30, 60)",
},
},
required: ["item_id", "frame_rate"],
},
handler: async (args: { item_id: string; frame_rate: number }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.setOverrideFrameRate(${args.frame_rate});
return __result({ set: true, item: item.name, frameRate: ${args.frame_rate} });
`);
return sendCommand(script, bridgeOptions);
},
},
set_override_pixel_aspect_ratio: {
description: "Override the pixel aspect ratio of a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
numerator: {
type: "number",
description: "PAR numerator (e.g., 1 for square pixels)",
},
denominator: {
type: "number",
description: "PAR denominator (e.g., 1 for square pixels)",
},
},
required: ["item_id", "numerator", "denominator"],
},
handler: async (args: { item_id: string; numerator: number; denominator: number }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.setOverridePixelAspectRatio(${args.numerator}, ${args.denominator});
return __result({ set: true, item: item.name, par: "${args.numerator}:${args.denominator}" });
`);
return sendCommand(script, bridgeOptions);
},
},
set_scale_to_frame_size: {
description: "Enable 'Scale to Frame Size' on a project item so it fills the sequence frame",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.setScaleToFrameSize();
return __result({ set: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
get_item_info: {
description: "Get detailed type info about a project item (is it a sequence, multicam, merged clip, etc.)",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var info = {
name: item.name,
nodeId: item.nodeId,
type: item.type === 1 ? "clip" : item.type === 2 ? "bin" : item.type === 3 ? "sequence" : "unknown",
treePath: item.treePath
};
try { info.isSequence = item.isSequence(); } catch(e) {}
try { info.isMulticamClip = item.isMulticamClip(); } catch(e) {}
try { info.isMergedClip = item.isMergedClip(); } catch(e) {}
try { info.isOffline = item.isOffline(); } catch(e) {}
try { info.mediaPath = item.getMediaPath(); } catch(e) {}
try { info.hasProxy = item.hasProxy(); } catch(e) {}
try { info.canProxy = item.canProxy(); } catch(e) {}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
select_item: {
description: "Select a project item in the Project panel",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item to select",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.select();
return __result({ selected: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
set_start_time: {
description: "Set the start time (timecode offset) for a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
start_seconds: {
type: "number",
description: "Start time in seconds",
},
},
required: ["item_id", "start_seconds"],
},
handler: async (args: { item_id: string; start_seconds: number }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var ticks = __secondsToTicks(${args.start_seconds}).toString();
item.setStartTime(ticks);
return __result({ set: true, item: item.name, startSeconds: ${args.start_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+375
View File
@@ -0,0 +1,375 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getMetadataTools(bridgeOptions: BridgeOptions) {
return {
get_metadata: {
description: "Get metadata for a project item. Disable either XML payload when a bounded identity/path response is sufficient.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
include_project_metadata: {
type: "boolean",
description: "Include the potentially large Project Metadata XML payload (default: true). Set false for a bounded identity/path response.",
},
include_xmp_metadata: {
type: "boolean",
description: "Include the potentially large XMP XML payload (default: true). Set false for a bounded identity/path response.",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string; include_project_metadata?: boolean; include_xmp_metadata?: boolean }) => {
const includeProjectMetadata = args.include_project_metadata !== false;
const includeXmpMetadata = args.include_xmp_metadata !== false;
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var metadata = {};
${includeProjectMetadata ? `try {
var xmpBlob = item.getProjectMetadata();
metadata.projectMetadata = xmpBlob;
} catch(e) {}` : ""}
${includeXmpMetadata ? `try {
var xmpBlob2 = item.getXMPMetadata();
metadata.xmpMetadata = xmpBlob2;
} catch(e) {}` : ""}
metadata.name = item.name;
metadata.nodeId = item.nodeId;
try {
metadata.mediaPath = item.getMediaPath();
} catch(e) {}
return __result(metadata);
`);
return sendCommand(script, bridgeOptions);
},
},
set_metadata: {
description:
"Replace project metadata XML on a project item and verify the exact readback. Partial field/value writes are intentionally rejected because Premiere requires a complete Project Metadata XML payload.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
field_name: {
type: "string",
description:
"Legacy partial-write argument. It is no longer executed because it cannot form a valid Project Metadata XML payload; use metadata_xml and updated_fields instead.",
},
value: {
type: "string",
description:
"Legacy partial-write argument. It is no longer executed; read projectMetadata first, update the complete XML, then supply metadata_xml and updated_fields.",
},
metadata_xml: {
type: "string",
description:
"Complete Project Metadata XML previously read from get_metadata, with the intended field values applied.",
},
updated_fields: {
type: "array",
items: { type: "string" },
minItems: 1,
description:
"Exact Project Metadata field paths changed in metadata_xml (for example, Column.Intrinsic.Description).",
},
},
required: ["item_id"],
},
handler: async (args: {
item_id: string;
field_name?: string;
value?: string;
metadata_xml?: string;
updated_fields?: string[];
}) => {
if (!args.metadata_xml || !args.metadata_xml.trim()) {
return {
success: false,
error:
"set_metadata no longer accepts partial field_name/value writes because Premiere does not persist that form reliably. Call get_metadata, modify its complete projectMetadata XML, then pass metadata_xml with updated_fields; or use manage_metadata_uxp when the authenticated UXP bridge is connected.",
};
}
if (!Array.isArray(args.updated_fields) || args.updated_fields.length === 0 ||
args.updated_fields.some((field) => typeof field !== "string" || !field.trim())) {
return {
success: false,
error: "updated_fields must contain one or more non-empty Project Metadata field paths.",
};
}
const updatedFields = JSON.stringify(args.updated_fields);
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var requestedMetadata = "${escapeForExtendScript(args.metadata_xml)}";
var updatedFields = ${updatedFields};
var accepted = item.setProjectMetadata(requestedMetadata, updatedFields);
if (accepted === false) return __error("Premiere rejected the project metadata update");
var readback = item.getProjectMetadata();
if (String(readback) !== requestedMetadata) {
return __error(
"Premiere did not return the requested Project Metadata XML after the write. " +
"The update is not reported as successful; inspect get_metadata before retrying."
);
}
return __result({ updated: true, verified: true, item: item.name, updatedFields: updatedFields });
`);
return sendCommand(script, bridgeOptions);
},
},
set_color_label: {
description: "Set the color label on a project item or clip",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
color_index: {
type: "number",
description: "Label color index (0=Violet, 1=Iris, 2=Caribbean, 3=Lavender, 4=Cerulean, 5=Forest, 6=Rose, 7=Mango, 8=Purple, 9=Blue, 10=Teal, 11=Magenta, 12=Tan, 13=Green, 14=Brown, 15=Yellow)",
},
},
required: ["item_id", "color_index"],
},
handler: async (args: { item_id: string; color_index: number }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
item.setColorLabel(${args.color_index});
return __result({ updated: true, item: item.name, colorIndex: ${args.color_index} });
`);
return sendCommand(script, bridgeOptions);
},
},
get_color_label: {
description: "Get the color label of a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var colorIndex = item.getColorLabel();
return __result({ item: item.name, colorIndex: colorIndex });
`);
return sendCommand(script, bridgeOptions);
},
},
get_footage_interpretation: {
description: "Get footage interpretation settings for a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var interp = item.getFootageInterpretation();
if (!interp) return __error("No footage interpretation available");
return __result({
item: item.name,
alphaUsage: interp.alphaUsage,
fieldType: interp.fieldType,
frameRate: interp.frameRate,
ignoreAlpha: interp.ignoreAlpha,
invertAlpha: interp.invertAlpha,
pixelAspectRatio: interp.pixelAspectRatio
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_footage_interpretation: {
description: "Set footage interpretation settings for a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
frame_rate: {
type: "number",
description: "Override frame rate",
},
pixel_aspect_ratio: {
type: "number",
description: "Pixel aspect ratio (1.0 = square pixels)",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string; frame_rate?: number; pixel_aspect_ratio?: number }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var interp = item.getFootageInterpretation();
if (!interp) return __error("No footage interpretation available");
${args.frame_rate !== undefined ? `interp.frameRate = ${args.frame_rate};` : ""}
${args.pixel_aspect_ratio !== undefined ? `interp.pixelAspectRatio = ${args.pixel_aspect_ratio};` : ""}
item.setFootageInterpretation(interp);
return __result({ updated: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
get_xmp_metadata: {
description: "Get the raw XMP metadata for a project item (includes EXIF, IPTC, Dublin Core, etc.)",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var xmp = item.getXMPMetadata();
return __result({ item: item.name, xmpMetadata: xmp });
`);
return sendCommand(script, bridgeOptions);
},
},
set_xmp_metadata: {
description:
"Merge a raw XMP XML patch into a project item's existing XMP metadata without removing unrelated fields.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
xmp_xml: {
type: "string",
description: "Well-formed XMP XML containing only the fields to add or replace",
},
},
required: ["item_id", "xmp_xml"],
},
handler: async (args: { item_id: string; xmp_xml: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
try {
if (ExternalObject.AdobeXMPScript === undefined) {
ExternalObject.AdobeXMPScript = new ExternalObject("lib:AdobeXMPScript");
}
} catch (e) {
return __error("Premiere could not load the Adobe XMP library; no metadata was changed: " + e.toString());
}
if (typeof XMPMeta !== "function" || typeof XMPUtils === "undefined" || typeof XMPUtils.appendProperties !== "function") {
return __error("Premiere's XMP merge APIs are unavailable; no metadata was changed.");
}
try {
var existingPacket = String(item.getXMPMetadata() || "");
if (!existingPacket) return __error("The project item has no readable XMP packet; no metadata was changed.");
var existingXmp = new XMPMeta(existingPacket);
var patchXmp = new XMPMeta("${escapeForExtendScript(args.xmp_xml)}");
// Copy every supplied top-level field into the existing packet, replacing
// only fields named by the patch and retaining unrelated metadata.
XMPUtils.appendProperties(patchXmp, existingXmp, true, true, false);
item.setXMPMetadata(existingXmp.serialize());
// Reparse the host readback so a malformed or rejected packet never
// reports success. Exact serialized XML formatting is host-dependent.
var writtenPacket = String(item.getXMPMetadata() || "");
if (!writtenPacket) return __error("Premiere wrote no readable XMP packet; inspect the item before retrying.");
new XMPMeta(writtenPacket);
return __result({
updated: true,
merged: true,
item: item.name,
verification: "readback_xmp_packet_reparsed"
});
} catch (e) {
return __error("Premiere could not merge XMP metadata; no success is reported: " + e.toString());
}
`);
return sendCommand(script, bridgeOptions);
},
},
get_color_space: {
description: "Get the color space information for a project item",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Item not found");
var info = { item: item.name };
try { info.colorSpace = item.getColorSpace(); } catch(e) { info.colorSpace = "unknown"; }
try { info.originalColorSpace = item.getOriginalColorSpace(); } catch(e) {}
try { info.embeddedLUT = item.getEmbeddedLUTID(); } catch(e) {}
try { info.inputLUT = item.getInputLUTID(); } catch(e) {}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+583
View File
@@ -0,0 +1,583 @@
import { randomUUID } from "node:crypto";
import { existsSync, openSync, closeSync, readSync, statSync } from "node:fs";
import path from "node:path";
import type { BridgeOptions, CommandResult } from "../bridge/file-bridge.js";
import { sendAfterEffectsCommand } from "../bridge/after-effects-bridge.js";
import {
buildAfterEffectsScript,
escapeForAfterEffects,
} from "../bridge/after-effects-script-builder.js";
import {
type CapabilityConfig,
createOperationId,
requireCapability,
resolveCapabilities,
} from "../security/index.js";
export const MOGRT_RECIPES = [
"lower_third",
"title_card",
"callout",
"quote_card",
"social_end_card",
] as const;
export type Recipe = (typeof MOGRT_RECIPES)[number];
export interface MogrtBrandKit {
name: string;
name_prefix?: string;
font_family?: string;
logo_path?: string;
accent_color?: string;
text_color?: string;
safe_margin_percent: number;
}
export interface MogrtPlan {
schema_version: 1;
recipe: Recipe;
template_name: string;
headline: string;
subtitle?: string;
accent_color: string;
text_color: string;
duration_seconds: number;
width: number;
height: number;
frame_rate: number;
approved_workspace_path: string;
output_directory: string;
output_path: string;
brand_kit?: MogrtBrandKit;
}
interface IssuedPlan {
plan: MogrtPlan;
expiresAt: number;
}
export interface MogrtAuthoringDependencies {
capabilities?: CapabilityConfig;
send?: (script: string, options: BridgeOptions) => Promise<CommandResult>;
directoryExists?: (candidate: string) => boolean;
artifactStatus?: (candidate: string) => MogrtArtifactStatus;
now?: () => number;
tokenFactory?: () => string;
operationIdFactory?: () => string;
}
export interface MogrtArtifactStatus {
exists: boolean;
size_bytes: number | null;
zip_header_valid: boolean;
}
const MAX_TEXT = 160;
const MAX_PATH = 4096;
const PLAN_TTL_MS = 10 * 60 * 1000;
const FRAME_RATES = new Set([23.976, 24, 25, 29.97, 30, 50, 59.94, 60]);
const RECIPE_SET = new Set<Recipe>(MOGRT_RECIPES);
function asObject(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object`);
return value as Record<string, unknown>;
}
function assertOnlyKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
for (const key of Object.keys(value)) {
if (!keys.includes(key)) throw new Error(`${field} contains unsupported field: ${key}`);
}
}
function requiredText(value: unknown, field: string, maxLength = MAX_TEXT): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
const normalized = value.trim();
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function optionalText(value: unknown, field: string, maxLength = MAX_TEXT): string | undefined {
return value === undefined ? undefined : requiredText(value, field, maxLength);
}
function finiteNumber(value: unknown, field: string, fallback: number, min: number, max: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new Error(`${field} must be a finite number between ${min} and ${max}`);
}
return value;
}
function wholeNumber(value: unknown, field: string, fallback: number, min: number, max: number): number {
const parsed = finiteNumber(value, field, fallback, min, max);
if (!Number.isInteger(parsed)) throw new Error(`${field} must be an integer between ${min} and ${max}`);
return parsed;
}
function templateName(value: unknown): string {
const name = requiredText(value, "template_name", 80);
if (!/^[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(name) || /[. ]$/.test(name)) {
throw new Error("template_name must use letters, numbers, spaces, underscores, or hyphens and cannot end with a dot or space");
}
return name;
}
function color(value: unknown, field: string, fallback: string): string {
const selected = value === undefined ? fallback : requiredText(value, field, 7);
if (!/^#[0-9a-fA-F]{6}$/.test(selected)) throw new Error(`${field} must be a #RRGGBB color`);
return selected.toUpperCase();
}
export function validateMogrtBrandKit(value: unknown, workspacePath: string): MogrtBrandKit | undefined {
if (value === undefined) return undefined;
const input = asObject(value, "brand_kit");
assertOnlyKeys(input, ["name", "name_prefix", "font_family", "logo_path", "accent_color", "text_color", "safe_margin_percent"], "brand_kit");
const name = requiredText(input.name, "brand_kit.name", 80);
const namePrefix = optionalText(input.name_prefix, "brand_kit.name_prefix", 40);
if (namePrefix && !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(namePrefix)) {
throw new Error("brand_kit.name_prefix must use letters, numbers, underscores, or hyphens");
}
const fontFamily = optionalText(input.font_family, "brand_kit.font_family", 120);
const logoPath = optionalText(input.logo_path, "brand_kit.logo_path", MAX_PATH);
if (logoPath) {
if (!/\.(?:png|jpe?g)$/i.test(logoPath)) throw new Error("brand_kit.logo_path must point to a PNG or JPEG file");
const contained = workspaceOutput(workspacePath, resolvePathFamily(logoPath).api.dirname(logoPath), "brand-kit-logo");
if (contained.root !== resolvePathFamily(workspacePath).resolved) {
throw new Error("brand_kit.logo_path must be inside approved_workspace_path");
}
}
return {
name,
...(namePrefix ? { name_prefix: namePrefix } : {}),
...(fontFamily ? { font_family: fontFamily } : {}),
...(logoPath ? { logo_path: logoPath } : {}),
...(input.accent_color === undefined ? {} : { accent_color: color(input.accent_color, "brand_kit.accent_color", "#2563EB") }),
...(input.text_color === undefined ? {} : { text_color: color(input.text_color, "brand_kit.text_color", "#FFFFFF") }),
safe_margin_percent: finiteNumber(input.safe_margin_percent, "brand_kit.safe_margin_percent", 0.1, 0.02, 0.25),
};
}
function isAbsolutePortable(value: string): boolean {
return path.win32.isAbsolute(value) || path.posix.isAbsolute(value);
}
function resolvePathFamily(value: string): { api: typeof path.win32; resolved: string; windows: boolean } {
if (path.win32.isAbsolute(value) && !path.posix.isAbsolute(value)) {
return { api: path.win32, resolved: path.win32.resolve(value), windows: true };
}
if (path.posix.isAbsolute(value)) return { api: path.posix, resolved: path.posix.resolve(value), windows: false };
throw new Error("Path must be absolute");
}
function workspaceOutput(root: string, outputDirectory: string, name: string): { root: string; directory: string; output: string } {
if (!isAbsolutePortable(root) || !isAbsolutePortable(outputDirectory)) {
throw new Error("approved_workspace_path and output_directory must be absolute paths");
}
const rootPath = resolvePathFamily(root);
const directoryPath = resolvePathFamily(outputDirectory);
if (rootPath.windows !== directoryPath.windows) {
throw new Error("approved_workspace_path and output_directory must use the same path format");
}
const relative = rootPath.api.relative(rootPath.resolved, directoryPath.resolved);
if (relative === ".." || relative.startsWith(`..${rootPath.api.sep}`) || rootPath.api.isAbsolute(relative)) {
throw new Error("output_directory must be inside approved_workspace_path");
}
return {
root: rootPath.resolved,
directory: directoryPath.resolved,
output: rootPath.api.join(directoryPath.resolved, `${name}.mogrt`),
};
}
export function buildMogrtPlan(value: unknown, directoryExists: (candidate: string) => boolean): MogrtPlan {
const input = asObject(value, "arguments");
assertOnlyKeys(input, [
"recipe", "template_name", "headline", "subtitle", "accent_color", "text_color",
"duration_seconds", "width", "height", "frame_rate", "approved_workspace_path", "output_directory", "brand_kit",
], "arguments");
const recipe = input.recipe === undefined ? "lower_third" : input.recipe;
if (typeof recipe !== "string" || !RECIPE_SET.has(recipe as Recipe)) {
throw new Error(`recipe must be one of ${MOGRT_RECIPES.join(", ")}`);
}
const name = templateName(input.template_name);
const output = workspaceOutput(
requiredText(input.approved_workspace_path, "approved_workspace_path", MAX_PATH),
requiredText(input.output_directory, "output_directory", MAX_PATH),
name,
);
if (!directoryExists(output.directory)) {
throw new Error("output_directory must already exist; this workflow never creates output folders");
}
const brandKit = validateMogrtBrandKit(input.brand_kit, output.root);
if (brandKit?.name_prefix && !name.startsWith(brandKit.name_prefix)) {
throw new Error("template_name must begin with brand_kit.name_prefix");
}
const frameRate = finiteNumber(input.frame_rate, "frame_rate", 30, 1, 120);
if (!FRAME_RATES.has(frameRate)) {
throw new Error("frame_rate must be one of 23.976, 24, 25, 29.97, 30, 50, 59.94, or 60");
}
return {
schema_version: 1,
recipe: recipe as Recipe,
template_name: name,
headline: requiredText(input.headline, "headline"),
subtitle: optionalText(input.subtitle, "subtitle"),
accent_color: color(input.accent_color, "accent_color", brandKit?.accent_color ?? "#2563EB"),
text_color: color(input.text_color, "text_color", brandKit?.text_color ?? "#FFFFFF"),
duration_seconds: finiteNumber(input.duration_seconds, "duration_seconds", 5, 2, 30),
width: wholeNumber(input.width, "width", 1920, 320, 7680),
height: wholeNumber(input.height, "height", 1080, 240, 4320),
frame_rate: frameRate,
approved_workspace_path: output.root,
output_directory: output.directory,
output_path: output.output,
...(brandKit ? { brand_kit: brandKit } : {}),
};
}
function rgb(value: string): [number, number, number] {
return [
Number.parseInt(value.slice(1, 3), 16) / 255,
Number.parseInt(value.slice(3, 5), 16) / 255,
Number.parseInt(value.slice(5, 7), 16) / 255,
];
}
export function buildMogrtRecipeScript(plan: MogrtPlan): string {
const accent = rgb(plan.accent_color).join(", ");
const text = rgb(plan.text_color).join(", ");
const layouts: Record<Recipe, { backingWidth: number; backingHeight: number; backingX: number; backingY: number; headlineX: number; headlineY: number; subtitleX: number; subtitleY: number }> = {
lower_third: { backingWidth: 0.78, backingHeight: 0.2, backingX: 0.45, backingY: 0.79, headlineX: 0.12, headlineY: 0.75, subtitleX: 0.12, subtitleY: 0.83 },
title_card: { backingWidth: 0.86, backingHeight: 0.3, backingX: 0.5, backingY: 0.5, headlineX: 0.18, headlineY: 0.46, subtitleX: 0.18, subtitleY: 0.56 },
callout: { backingWidth: 0.42, backingHeight: 0.18, backingX: 0.76, backingY: 0.22, headlineX: 0.58, headlineY: 0.19, subtitleX: 0.58, subtitleY: 0.27 },
quote_card: { backingWidth: 0.8, backingHeight: 0.42, backingX: 0.5, backingY: 0.5, headlineX: 0.14, headlineY: 0.44, subtitleX: 0.14, subtitleY: 0.6 },
social_end_card: { backingWidth: 0.9, backingHeight: 0.9, backingX: 0.5, backingY: 0.5, headlineX: 0.15, headlineY: 0.45, subtitleX: 0.15, subtitleY: 0.56 },
};
const layout = layouts[plan.recipe];
const safeMargin = plan.brand_kit?.safe_margin_percent ?? 0.1;
const headlineX = Math.max(layout.headlineX, safeMargin);
const subtitleX = Math.max(layout.subtitleX, safeMargin);
const logoX = Math.max(safeMargin, 1 - safeMargin * 1.5);
const fontAssignment = plan.brand_kit?.font_family
? `try { headlineDocument.font = "${escapeForAfterEffects(plan.brand_kit.font_family)}"; } catch (fontError) {}`
: "";
const subtitleFontAssignment = plan.brand_kit?.font_family
? `try { subtitleDocument.font = "${escapeForAfterEffects(plan.brand_kit.font_family)}"; } catch (subtitleFontError) {}`
: "";
const logoPath = plan.brand_kit?.logo_path ?? "";
const subtitleLayer = plan.subtitle
? `
var subtitleLayer = comp.layers.addText("${escapeForAfterEffects(plan.subtitle)}");
subtitleLayer.name = "Subtitle";
var subtitleSource = subtitleLayer.property("ADBE Text Properties").property("ADBE Text Document");
var subtitleDocument = subtitleSource.value;
subtitleDocument.fontSize = ${Math.max(24, Math.round(plan.height * 0.032))};
subtitleDocument.fillColor = [${text}];
subtitleDocument.applyFill = true;
${subtitleFontAssignment}
subtitleSource.setValue(subtitleDocument);
subtitleLayer.property("ADBE Transform Group").property("ADBE Position").setValue([${Math.round(plan.width * subtitleX)}, ${Math.round(plan.height * layout.subtitleY)}]);
subtitleExposed = expose(subtitleSource);
`
: "";
return buildAfterEffectsScript(`
function normalizedPath(value) {
return String(value).replace(/\\\\/g, "/").replace(/\\/+$/, "").toLowerCase();
}
function isInside(root, candidate) {
var normalizedRoot = normalizedPath(root);
var normalizedCandidate = normalizedPath(candidate);
return normalizedCandidate === normalizedRoot || normalizedCandidate.indexOf(normalizedRoot + "/") === 0;
}
function expose(property) {
try {
if (!property || typeof property.addToMotionGraphicsTemplate !== "function") return false;
if (typeof property.canAddToMotionGraphicsTemplate === "function" && !property.canAddToMotionGraphicsTemplate()) return false;
return property.addToMotionGraphicsTemplate() === true;
} catch (exposeError) {
return false;
}
}
var workspacePath = "${escapeForAfterEffects(plan.approved_workspace_path)}";
var outputDirectory = "${escapeForAfterEffects(plan.output_directory)}";
var outputPath = "${escapeForAfterEffects(plan.output_path)}";
var logoPath = "${escapeForAfterEffects(logoPath)}";
var project = app.project;
if (!project || !project.file) return __aeError("Open a saved After Effects project inside approved_workspace_path before creating a MOGRT; this workflow never creates or replaces projects");
if (!isInside(workspacePath, project.file.fsName)) return __aeError("The open After Effects project is outside approved_workspace_path; no composition was created");
var destination = new Folder(outputDirectory);
if (!destination.exists) return __aeError("The approved output directory no longer exists; no composition was created");
var existing = new File(outputPath);
if (existing.exists) return __aeError("A MOGRT already exists at the planned output path; preview a new plan only after moving or deliberately replacing it");
var logoFile = logoPath ? new File(logoPath) : null;
if (logoFile && !logoFile.exists) return __aeError("The approved brand-kit logo no longer exists; preview the recipe again after restoring it");
app.beginUndoGroup("Create ${escapeForAfterEffects(plan.template_name)} MOGRT");
try {
var comp = project.items.addComp("${escapeForAfterEffects(plan.template_name)}", ${plan.width}, ${plan.height}, 1, ${plan.duration_seconds}, ${plan.frame_rate});
comp.motionGraphicsTemplateName = "${escapeForAfterEffects(plan.template_name)}";
var backing = comp.layers.addShape();
backing.name = "Accent bar";
var shapeContents = backing.property("ADBE Root Vectors Group");
var rect = shapeContents.addProperty("ADBE Vector Shape - Rect");
rect.property("ADBE Vector Rect Size").setValue([${Math.round(plan.width * layout.backingWidth)}, ${Math.round(plan.height * layout.backingHeight)}]);
var fill = shapeContents.addProperty("ADBE Vector Graphic - Fill");
var fillColor = fill.property("ADBE Vector Fill Color");
fillColor.setValue([${accent}]);
backing.property("ADBE Transform Group").property("ADBE Position").setValue([${Math.round(plan.width * layout.backingX)}, ${Math.round(plan.height * layout.backingY)}]);
var accentControl = backing.property("ADBE Effect Parade").addProperty("ADBE Color Control");
accentControl.name = "Accent Color";
var accentProperty = accentControl.property(1);
accentProperty.setValue([${accent}]);
try { fillColor.expression = 'effect("Accent Color")("Color")'; } catch (expressionError) {}
var headlineLayer = comp.layers.addText("${escapeForAfterEffects(plan.headline)}");
headlineLayer.name = "Headline";
var headlineSource = headlineLayer.property("ADBE Text Properties").property("ADBE Text Document");
var headlineDocument = headlineSource.value;
headlineDocument.fontSize = ${Math.max(32, Math.round(plan.height * 0.06))};
headlineDocument.fillColor = [${text}];
headlineDocument.applyFill = true;
${fontAssignment}
headlineSource.setValue(headlineDocument);
headlineLayer.property("ADBE Transform Group").property("ADBE Position").setValue([${Math.round(plan.width * headlineX)}, ${Math.round(plan.height * layout.headlineY)}]);
var headlineExposed = expose(headlineSource);
var accentExposed = expose(accentProperty);
var subtitleExposed = false;
${subtitleLayer}
var logoImported = false;
if (logoFile) {
var logoItem = project.importFile(new ImportOptions(logoFile));
var logoLayer = comp.layers.add(logoItem);
logoLayer.name = "Brand logo";
logoLayer.property("ADBE Transform Group").property("ADBE Position").setValue([${Math.round(plan.width * logoX)}, ${Math.round(plan.height * safeMargin * 1.7)}]);
logoImported = true;
}
project.save(project.file);
var hostExportReturn = comp.exportAsMotionGraphicsTemplate(false, outputDirectory);
return __aeResult({
exportRequested: true,
hostExportReturn: hostExportReturn === true,
artifactExistsAtHostReturn: new File(outputPath).exists,
outputPath: outputPath,
recipe: "${plan.recipe}",
projectMutated: true,
brandKit: ${plan.brand_kit ? `{ name: "${escapeForAfterEffects(plan.brand_kit.name)}", fontRequested: ${JSON.stringify(plan.brand_kit.font_family ?? null)}, logoImported: logoImported, safeMarginPercent: ${plan.brand_kit.safe_margin_percent} }` : "null"},
exposedControls: { headline: headlineExposed, subtitle: subtitleExposed, accentColor: accentExposed },
visualVerified: false,
verificationScope: "After Effects accepted the authoring request. Verify the local .mogrt artifact, import it into a disposable Premiere sequence, and inspect a rendered frame before delivery."
});
} finally {
app.endUndoGroup();
}
`);
}
export function inspectMogrtArtifact(candidate: string): MogrtArtifactStatus {
if (!existsSync(candidate)) return { exists: false, size_bytes: null, zip_header_valid: false };
try {
const descriptor = openSync(candidate, "r");
try {
const header = Buffer.alloc(2);
const bytes = readSync(descriptor, header, 0, header.length, 0);
return {
exists: true,
size_bytes: statSync(candidate).size,
zip_header_valid: bytes === 2 && header[0] === 0x50 && header[1] === 0x4b,
};
} finally {
closeSync(descriptor);
}
} catch {
return { exists: true, size_bytes: null, zip_header_valid: false };
}
}
function expectedArtifact(value: unknown): { workspace: string; artifact: string } {
const input = asObject(value, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "mogrt_path"], "arguments");
const workspace = requiredText(input.approved_workspace_path, "approved_workspace_path", MAX_PATH);
const artifact = requiredText(input.mogrt_path, "mogrt_path", MAX_PATH);
if (!/\.mogrt$/i.test(artifact)) throw new Error("mogrt_path must end in .mogrt");
const artifactPath = resolvePathFamily(artifact);
const plan = workspaceOutput(
workspace,
artifactPath.api.dirname(artifactPath.resolved),
artifactPath.api.basename(artifactPath.resolved, artifactPath.api.extname(artifactPath.resolved)),
);
if (plan.output !== artifactPath.resolved) throw new Error("mogrt_path must be a direct .mogrt file inside approved_workspace_path");
return { workspace: plan.root, artifact: plan.output };
}
export function getMogrtAuthoringTools(
bridgeOptions: BridgeOptions,
dependencies: MogrtAuthoringDependencies = {},
) {
const capabilities = dependencies.capabilities ?? resolveCapabilities();
const send = dependencies.send ?? sendAfterEffectsCommand;
const directoryExists = dependencies.directoryExists ?? ((candidate) => {
try { return statSync(candidate).isDirectory(); } catch { return false; }
});
const artifactStatus = dependencies.artifactStatus ?? inspectMogrtArtifact;
const now = dependencies.now ?? Date.now;
const tokenFactory = dependencies.tokenFactory ?? randomUUID;
const nextOperationId = dependencies.operationIdFactory ?? createOperationId;
const plans = new Map<string, IssuedPlan>();
const takePlan = (token: unknown): MogrtPlan => {
const key = requiredText(token, "preview_token", 128);
const issued = plans.get(key);
if (!issued || issued.expiresAt <= now()) {
plans.delete(key);
throw new Error("preview_token is missing, expired, or already used; preview the MOGRT recipe again");
}
plans.delete(key);
return issued.plan;
};
return {
verify_after_effects_connection: {
description: "Read-only check that the dedicated After Effects CEP connector is running. It never reads project names, media, or paths.",
parameters: { type: "object" as const, additionalProperties: false, properties: {} },
handler: async () => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
const result = await send(buildAfterEffectsScript(`
var project = app.project;
return __aeResult({
connected: true,
application: app.name,
version: app.version,
hasSavedProject: !!(project && project.file),
projectItemCount: project && project.items ? project.items.length : 0,
readOnly: true
});
`), { ...bridgeOptions, timeoutMs: Math.min(bridgeOptions.timeoutMs ?? 5000, 5000), failFastOnUnreadyHeartbeat: true });
return result.success
? { ...result, data: { ...(result.data as object), operationId } }
: { ...result, error: `${result.error ?? "After Effects connection check failed"} (operation ${operationId})` };
},
},
preview_mogrt_recipe: {
description: "Preview a bounded After Effects MOGRT recipe from the supported title, callout, quote, and social template library. It validates one existing workspace output directory but does not contact Adobe or write any files.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
recipe: { type: "string", enum: MOGRT_RECIPES, description: "Supported authored template recipe; defaults to lower_third." },
template_name: { type: "string", description: "Safe template file stem; the generated artifact is template_name.mogrt." },
headline: { type: "string", description: "Primary lower-third text, at most 160 characters." },
subtitle: { type: "string", description: "Optional secondary lower-third text, at most 160 characters." },
accent_color: { type: "string", description: "Optional accent fill color as #RRGGBB; defaults to #2563EB." },
text_color: { type: "string", description: "Optional text color as #RRGGBB; defaults to #FFFFFF." },
duration_seconds: { type: "number", description: "Composition duration between 2 and 30 seconds; defaults to 5." },
width: { type: "integer", description: "Composition width from 320 to 7680 pixels; defaults to 1920." },
height: { type: "integer", description: "Composition height from 240 to 4320 pixels; defaults to 1080." },
frame_rate: { type: "number", enum: [23.976, 24, 25, 29.97, 30, 50, 59.94, 60], description: "Composition frame rate; defaults to 30." },
approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root containing the saved AE project and output directory." },
output_directory: { type: "string", description: "Existing absolute output directory inside approved_workspace_path; this workflow never creates directories." },
brand_kit: {
type: "object",
additionalProperties: false,
description: "Optional approved local brand kit. It can constrain naming, request an installed font, place a workspace-contained PNG/JPEG logo, and enforce a safe margin.",
properties: {
name: { type: "string" },
name_prefix: { type: "string" },
font_family: { type: "string" },
logo_path: { type: "string" },
accent_color: { type: "string" },
text_color: { type: "string" },
safe_margin_percent: { type: "number" },
},
required: ["name"],
},
},
required: ["template_name", "headline", "approved_workspace_path", "output_directory"],
},
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const plan = buildMogrtPlan(args, directoryExists);
const token = tokenFactory();
plans.set(token, { plan, expiresAt: now() + PLAN_TTL_MS });
return {
success: true,
data: {
operationId,
applied: false,
plan,
previewToken: token,
expiresInSeconds: PLAN_TTL_MS / 1000,
changes: [
`Create one ${plan.recipe.replaceAll("_", " ")} composition in the open saved After Effects project.`,
"Expose the bounded headline, optional subtitle, and accent-color controls.",
"Save the open project and request a .mogrt export at the planned output path.",
],
verificationScope: "This is a local preview only. It does not connect to After Effects, inspect the active project, or create a composition or file.",
},
};
},
},
create_mogrt_recipe: {
description: "Create exactly one previewed MOGRT recipe in a saved After Effects project inside the approved workspace. Requires explicit export confirmation; it never creates projects or output folders.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
preview_token: { type: "string", description: "One-time token returned by preview_mogrt_recipe; it expires after 10 minutes." },
confirm_export: { type: "boolean", description: "Must be true to save the open AE project and request the planned .mogrt export." },
},
required: ["preview_token", "confirm_export"],
},
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "edit", operationId);
requireCapability(capabilities, "export", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["preview_token", "confirm_export"], "arguments");
if (input.confirm_export !== true) throw new Error("confirm_export must be true to create the MOGRT");
const plan = takePlan(input.preview_token);
const result = await send(buildMogrtRecipeScript(plan), bridgeOptions);
if (!result.success) {
return { ...result, error: `${result.error ?? "MOGRT authoring failed"} (operation ${operationId})` };
}
return {
...result,
data: {
...(result.data as object),
operationId,
artifact: artifactStatus(plan.output_path),
artifactPath: plan.output_path,
},
};
},
},
verify_mogrt_artifact: {
description: "Verify that a workspace-contained .mogrt artifact exists locally and has a ZIP header. This does not prove controls, import compatibility, playback, or visual correctness.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root containing mogrt_path." },
mogrt_path: { type: "string", description: "Absolute .mogrt artifact path inside approved_workspace_path." },
},
required: ["approved_workspace_path", "mogrt_path"],
},
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const expected = expectedArtifact(args);
const artifact = artifactStatus(expected.artifact);
return {
success: artifact.exists && artifact.zip_header_valid,
...(artifact.exists && artifact.zip_header_valid
? { data: { operationId, mogrtPath: expected.artifact, artifact, visualVerified: false, importVerified: false, verificationScope: "Local file existence and ZIP header only. Import this artifact into a disposable Premiere sequence and inspect a rendered frame before delivery." } }
: { error: artifact.exists ? "The artifact is not a recognizable ZIP-based .mogrt file" : "The MOGRT artifact does not exist" }),
};
},
},
};
}
+584
View File
@@ -0,0 +1,584 @@
import { randomUUID } from "node:crypto";
import {
constants as fsConstants,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
} from "node:fs";
import path from "node:path";
import { sendAfterEffectsCommand } from "../bridge/after-effects-bridge.js";
import { buildAfterEffectsScript, escapeForAfterEffects } from "../bridge/after-effects-script-builder.js";
import { sendCommand, type BridgeOptions, type CommandResult } from "../bridge/file-bridge.js";
import {
buildMogrtPlan,
buildMogrtRecipeScript,
inspectMogrtArtifact,
type MogrtArtifactStatus,
type MogrtBrandKit,
type MogrtPlan,
validateMogrtBrandKit,
} from "./mogrt-authoring.js";
import { type CapabilityConfig, createOperationId, requireCapability, resolveCapabilities } from "../security/index.js";
const MAX_PATH = 4096;
const MAX_BATCH_ITEMS = 20;
const PLAN_TTL_MS = 10 * 60 * 1000;
const MAX_DATA_FILE_BYTES = 512 * 1024;
interface IssuedPlan<T> {
value: T;
expiresAt: number;
}
interface BatchPlan {
workspace: string;
dataFilePath: string;
plans: MogrtPlan[];
}
interface LibraryPublishPlan {
workspace: string;
source: string;
destination: string;
templateName: string;
version: string;
}
interface RenderPlan {
workspace: string;
compositionName: string;
outputPath: string;
renderSettingsTemplate: string;
outputModuleTemplate: string;
}
interface PremiereHandoffPlan {
workspace: string;
mogrtPath: string;
sequenceId: string;
disposableSequenceName: string;
videoTrackIndex: number;
audioTrackIndex: number;
startSeconds: number;
}
export interface MogrtStudioDependencies {
capabilities?: CapabilityConfig;
sendAfterEffects?: (script: string, options: BridgeOptions) => Promise<CommandResult>;
sendPremiere?: (script: string, options: BridgeOptions) => Promise<CommandResult>;
directoryExists?: (candidate: string) => boolean;
fileExists?: (candidate: string) => boolean;
readText?: (candidate: string) => string;
copyFile?: (source: string, destination: string) => void;
makeDirectory?: (candidate: string) => void;
listDirectory?: (candidate: string) => string[];
artifactStatus?: (candidate: string) => MogrtArtifactStatus;
now?: () => number;
tokenFactory?: () => string;
operationIdFactory?: () => string;
}
function asObject(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object`);
return value as Record<string, unknown>;
}
function assertOnlyKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
for (const key of Object.keys(value)) {
if (!keys.includes(key)) throw new Error(`${field} contains unsupported field: ${key}`);
}
}
function text(value: unknown, field: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
const normalized = value.trim();
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function finiteNumber(value: unknown, field: string, fallback: number, min: number, max: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new Error(`${field} must be a finite number between ${min} and ${max}`);
}
return value;
}
function wholeNumber(value: unknown, field: string, fallback: number, min: number, max: number): number {
const parsed = finiteNumber(value, field, fallback, min, max);
if (!Number.isInteger(parsed)) throw new Error(`${field} must be an integer between ${min} and ${max}`);
return parsed;
}
function resolvePath(value: string): { api: typeof path.win32; resolved: string; windows: boolean } {
if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\")) {
return { api: path.win32, resolved: path.win32.resolve(value), windows: true };
}
if (path.posix.isAbsolute(value)) return { api: path.posix, resolved: path.posix.resolve(value), windows: false };
throw new Error("Path must be absolute");
}
function containedPath(workspace: string, candidate: string, field: string): { root: string; candidate: string; api: typeof path.win32 } {
const root = resolvePath(workspace);
const target = resolvePath(candidate);
if (root.windows !== target.windows) throw new Error(`${field} must use the same path format as approved_workspace_path`);
const relative = root.api.relative(root.resolved, target.resolved);
if (relative === ".." || relative.startsWith(`..${root.api.sep}`) || root.api.isAbsolute(relative)) {
throw new Error(`${field} must be inside approved_workspace_path`);
}
return { root: root.resolved, candidate: target.resolved, api: root.api };
}
function mogrtArtifact(workspace: unknown, mogrtPath: unknown): { root: string; artifact: string; api: typeof path.win32 } {
const root = text(workspace, "approved_workspace_path", MAX_PATH);
const artifact = text(mogrtPath, "mogrt_path", MAX_PATH);
if (!/\.mogrt$/i.test(artifact)) throw new Error("mogrt_path must end in .mogrt");
const contained = containedPath(root, artifact, "mogrt_path");
return { root: contained.root, artifact: contained.candidate, api: contained.api };
}
function safeTemplateName(value: unknown, field = "template_name"): string {
const name = text(value, field, 80);
if (!/^[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(name) || /[. ]$/.test(name)) {
throw new Error(`${field} must use letters, numbers, spaces, underscores, or hyphens and cannot end with a dot or space`);
}
return name;
}
function parseCsv(textValue: string): Record<string, unknown>[] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let index = 0; index < textValue.length; index += 1) {
const character = textValue[index];
if (character === '"') {
if (quoted && textValue[index + 1] === '"') { field += '"'; index += 1; }
else quoted = !quoted;
} else if (character === "," && !quoted) {
row.push(field); field = "";
} else if ((character === "\n" || character === "\r") && !quoted) {
if (character === "\r" && textValue[index + 1] === "\n") index += 1;
row.push(field); field = "";
if (row.some((entry) => entry.trim())) rows.push(row);
row = [];
} else field += character;
}
if (quoted) throw new Error("CSV data file has an unterminated quoted field");
row.push(field);
if (row.some((entry) => entry.trim())) rows.push(row);
if (rows.length < 2) throw new Error("CSV data file must include a header row and at least one template row");
const [headers, ...data] = rows;
const allowed = new Set(["recipe", "template_name", "headline", "subtitle", "accent_color", "text_color", "duration_seconds", "width", "height", "frame_rate"]);
for (const header of headers) {
if (!allowed.has(header.trim())) throw new Error(`CSV data file contains unsupported column: ${header}`);
}
return data.map((values) => Object.fromEntries(headers.map((header, index) => [header.trim(), values[index]?.trim() || undefined])));
}
function parseBatchRows(contents: string, dataFilePath: string): Record<string, unknown>[] {
const parsed = dataFilePath.toLowerCase().endsWith(".json")
? JSON.parse(contents) as unknown
: parseCsv(contents);
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > MAX_BATCH_ITEMS) {
throw new Error(`Batch data must contain between 1 and ${MAX_BATCH_ITEMS} rows`);
}
return parsed.map((row, index) => {
const input = asObject(row, `batch row ${index + 1}`);
assertOnlyKeys(input, ["recipe", "template_name", "headline", "subtitle", "accent_color", "text_color", "duration_seconds", "width", "height", "frame_rate"], `batch row ${index + 1}`);
const numericKeys = ["duration_seconds", "width", "height", "frame_rate"];
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
normalized[key] = numericKeys.includes(key) && typeof value === "string" && value !== "" ? Number(value) : value;
}
return normalized;
});
}
function outputDoesNotExist(fileExists: (candidate: string) => boolean, candidate: string, field: string): void {
if (fileExists(candidate)) throw new Error(`${field} already exists; this workflow never overwrites artifacts`);
}
function renderPlan(value: unknown, directoryExists: (candidate: string) => boolean, fileExists: (candidate: string) => boolean): RenderPlan {
const input = asObject(value, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "composition_name", "output_path", "render_settings_template", "output_module_template"], "arguments");
const root = text(input.approved_workspace_path, "approved_workspace_path", MAX_PATH);
const output = containedPath(root, text(input.output_path, "output_path", MAX_PATH), "output_path");
if (!directoryExists(output.api.dirname(output.candidate))) throw new Error("output_path parent directory must already exist");
outputDoesNotExist(fileExists, output.candidate, "output_path");
return {
workspace: output.root,
compositionName: safeTemplateName(input.composition_name, "composition_name"),
outputPath: output.candidate,
renderSettingsTemplate: text(input.render_settings_template, "render_settings_template", 120),
outputModuleTemplate: text(input.output_module_template, "output_module_template", 120),
};
}
function premiereHandoffPlan(value: unknown, artifactStatus: (candidate: string) => MogrtArtifactStatus): PremiereHandoffPlan {
const input = asObject(value, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "mogrt_path", "sequence_id", "disposable_sequence_name", "video_track_index", "audio_track_index", "start_seconds"], "arguments");
const artifact = mogrtArtifact(input.approved_workspace_path, input.mogrt_path);
const inspected = artifactStatus(artifact.artifact);
if (!inspected.exists || !inspected.zip_header_valid) throw new Error("mogrt_path must exist and have a ZIP header before Premiere handoff");
const sequenceName = text(input.disposable_sequence_name, "disposable_sequence_name", 120);
if (!/^MOGRT Verify - /i.test(sequenceName)) {
throw new Error("disposable_sequence_name must begin with 'MOGRT Verify - ' to make the verification target explicit");
}
return {
workspace: artifact.root,
mogrtPath: artifact.artifact,
sequenceId: text(input.sequence_id, "sequence_id", 160),
disposableSequenceName: sequenceName,
videoTrackIndex: wholeNumber(input.video_track_index, "video_track_index", 0, 0, 99),
audioTrackIndex: wholeNumber(input.audio_track_index, "audio_track_index", 0, 0, 99),
startSeconds: finiteNumber(input.start_seconds, "start_seconds", 0, 0, 86_400),
};
}
function buildPremiereHandoffScript(plan: PremiereHandoffPlan): string {
return `
var seq = __findSequence("${escapeForAfterEffects(plan.sequenceId)}");
if (!seq) return __error("The previewed verification sequence no longer exists");
if (String(seq.name) !== "${escapeForAfterEffects(plan.disposableSequenceName)}") return __error("The sequence name no longer matches the explicit disposable verification target");
var titleTrack = seq.videoTracks[${plan.videoTrackIndex}];
if (!titleTrack) return __error("The requested verification video track does not exist");
if (titleTrack.clips.numItems !== 0) return __error("The requested verification video track is not empty; no MOGRT was imported");
var before = titleTrack.clips.numItems;
var imported = seq.importMGT("${escapeForAfterEffects(plan.mogrtPath)}", __secondsToTicks(${plan.startSeconds}).toString(), ${plan.videoTrackIndex}, ${plan.audioTrackIndex});
var after = titleTrack.clips.numItems;
if (after <= before) return __error("Premiere returned from importMGT without inserting a verification track item");
var inserted = titleTrack.clips[after - 1];
var component = inserted && inserted.getMGTComponent ? inserted.getMGTComponent() : null;
var controls = [];
if (component && component.properties) {
for (var i = 0; i < component.properties.numItems && i < 32; i++) {
var property = component.properties[i];
controls.push({ displayName: String(property.displayName || ""), matchName: String(property.matchName || "") });
}
}
return __result({
imported: imported === true || after > before,
insertedTrackItems: after - before,
componentDetected: !!component,
exposedControlDescriptors: controls,
importVerified: true,
visualVerified: false,
verificationScope: "Premiere inserted the MOGRT into the explicit empty verification track and returned control descriptors. Capture and inspect a rendered frame before delivery."
});
`;
}
export function getMogrtStudioTools(bridgeOptions: BridgeOptions, dependencies: MogrtStudioDependencies = {}) {
const capabilities = dependencies.capabilities ?? resolveCapabilities();
const sendAfterEffects = dependencies.sendAfterEffects ?? sendAfterEffectsCommand;
const sendPremiere = dependencies.sendPremiere ?? sendCommand;
const directoryExists = dependencies.directoryExists ?? ((candidate) => {
try { return statSync(candidate).isDirectory(); } catch { return false; }
});
const fileExists = dependencies.fileExists ?? existsSync;
const readText = dependencies.readText ?? ((candidate) => readFileSync(candidate, "utf8"));
const copyFile = dependencies.copyFile ?? ((source, destination) => copyFileSync(source, destination, fsConstants.COPYFILE_EXCL));
const makeDirectory = dependencies.makeDirectory ?? ((candidate) => mkdirSync(candidate, { recursive: true }));
const listDirectory = dependencies.listDirectory ?? ((candidate) => readdirSync(candidate));
const artifactStatus = dependencies.artifactStatus ?? inspectMogrtArtifact;
const now = dependencies.now ?? Date.now;
const tokenFactory = dependencies.tokenFactory ?? randomUUID;
const nextOperationId = dependencies.operationIdFactory ?? createOperationId;
const batchPlans = new Map<string, IssuedPlan<BatchPlan>>();
const libraryPlans = new Map<string, IssuedPlan<LibraryPublishPlan>>();
const renderPlans = new Map<string, IssuedPlan<RenderPlan>>();
const handoffPlans = new Map<string, IssuedPlan<PremiereHandoffPlan>>();
const issue = <T>(store: Map<string, IssuedPlan<T>>, value: T) => {
const token = tokenFactory();
store.set(token, { value, expiresAt: now() + PLAN_TTL_MS });
return token;
};
const take = <T>(store: Map<string, IssuedPlan<T>>, token: unknown, field: string): T => {
const key = text(token, field, 128);
const issued = store.get(key);
if (!issued || issued.expiresAt <= now()) {
store.delete(key);
throw new Error(`${field} is missing, expired, or already used; preview again`);
}
store.delete(key);
return issued.value;
};
return {
validate_mogrt_brand_kit: {
description: "Validate an operator-approved local MOGRT brand kit before using it in a template or batch preview. It never reads font inventories, image pixels, or writes files.",
parameters: {
type: "object" as const, additionalProperties: false,
properties: {
approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." },
brand_kit: { type: "object", description: "Approved brand-kit values to validate before recipe use.", additionalProperties: false, properties: { name: { type: "string" }, name_prefix: { type: "string" }, font_family: { type: "string" }, logo_path: { type: "string" }, accent_color: { type: "string" }, text_color: { type: "string" }, safe_margin_percent: { type: "number" } }, required: ["name"] },
}, required: ["approved_workspace_path", "brand_kit"],
},
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "brand_kit"], "arguments");
const workspace = text(input.approved_workspace_path, "approved_workspace_path", MAX_PATH);
const brandKit = validateMogrtBrandKit(input.brand_kit, workspace);
return { success: true, data: { operationId, brandKit, logoPathExists: brandKit?.logo_path ? fileExists(brandKit.logo_path) : null, verificationScope: "Local path and schema validation only. Font availability is verified by After Effects only when a recipe is created." } };
},
},
preview_mogrt_batch: {
description: "Preview up to 20 bounded MOGRT recipe exports from a workspace-contained JSON or CSV data file. It does not contact Adobe or create any compositions or files.",
parameters: {
type: "object" as const, additionalProperties: false,
properties: {
approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." }, output_directory: { type: "string", description: "Existing workspace-contained directory for every batch artifact." }, data_file_path: { type: "string", description: "Existing workspace-contained .json or .csv file with recipe rows." },
brand_kit: { type: "object", description: "Optional validated brand-kit values applied to every batch recipe.", additionalProperties: false, properties: { name: { type: "string" }, name_prefix: { type: "string" }, font_family: { type: "string" }, logo_path: { type: "string" }, accent_color: { type: "string" }, text_color: { type: "string" }, safe_margin_percent: { type: "number" } }, required: ["name"] },
}, required: ["approved_workspace_path", "output_directory", "data_file_path"],
},
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "output_directory", "data_file_path", "brand_kit"], "arguments");
const workspace = text(input.approved_workspace_path, "approved_workspace_path", MAX_PATH);
const dataPath = containedPath(workspace, text(input.data_file_path, "data_file_path", MAX_PATH), "data_file_path");
if (!/\.(?:json|csv)$/i.test(dataPath.candidate)) throw new Error("data_file_path must end in .json or .csv");
if (!fileExists(dataPath.candidate)) throw new Error("data_file_path does not exist");
const raw = readText(dataPath.candidate);
if (Buffer.byteLength(raw, "utf8") > MAX_DATA_FILE_BYTES) throw new Error(`data_file_path must be at most ${MAX_DATA_FILE_BYTES} bytes`);
const rows = parseBatchRows(raw, dataPath.candidate);
const plans = rows.map((row) => buildMogrtPlan({ ...row, approved_workspace_path: workspace, output_directory: input.output_directory, ...(input.brand_kit === undefined ? {} : { brand_kit: input.brand_kit }) }, directoryExists));
const outputs = new Set<string>();
for (const plan of plans) {
if (outputs.has(plan.output_path)) throw new Error("Batch rows must have unique template_name values");
outputs.add(plan.output_path);
outputDoesNotExist(fileExists, plan.output_path, "Planned MOGRT artifact");
}
const previewToken = issue(batchPlans, { workspace: dataPath.root, dataFilePath: dataPath.candidate, plans });
return { success: true, data: { operationId, previewToken, expiresInSeconds: PLAN_TTL_MS / 1000, plans, batchAtomic: false, verificationScope: "This is a local preview. Batch creation exports recipes serially and stops at the first host failure; it cannot roll back already-created AE compositions or artifacts." } };
},
},
create_mogrt_batch: {
description: "Create the exact MOGRT recipes from a one-time batch preview. Requires explicit export confirmation and stops on the first After Effects failure without claiming rollback.",
parameters: { type: "object" as const, additionalProperties: false, properties: { preview_token: { type: "string", description: "One-time token returned by preview_mogrt_batch." }, confirm_export: { type: "boolean", description: "Must be true to request every planned export." } }, required: ["preview_token", "confirm_export"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "edit", operationId);
requireCapability(capabilities, "export", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["preview_token", "confirm_export"], "arguments");
if (input.confirm_export !== true) throw new Error("confirm_export must be true to create the batch");
const batch = take(batchPlans, input.preview_token, "preview_token");
const completed: Array<Record<string, unknown>> = [];
for (const plan of batch.plans) {
const result = await sendAfterEffects(buildMogrtRecipeScript(plan), bridgeOptions);
if (!result.success) {
return { success: false, error: `${result.error ?? "MOGRT batch creation failed"} (operation ${operationId})`, data: { operationId, completed, failedTemplate: plan.template_name, batchAtomic: false } };
}
completed.push({ templateName: plan.template_name, artifactPath: plan.output_path, artifact: artifactStatus(plan.output_path) });
}
return { success: true, data: { operationId, completed, batchAtomic: false, visualVerified: false, verificationScope: "After Effects accepted each serial export request. Verify every artifact and use the Premiere handoff workflow before delivery." } };
},
},
inspect_after_effects_template_source: {
description: "Inspect a saved After Effects source composition for MOGRT-relevant dimensions, duration, text fonts, layer-source kinds, and Essential Graphics controller names when the host exposes the AE 16.1+ readback API. It never creates a composition or returns asset paths.",
parameters: { type: "object" as const, additionalProperties: false, properties: { composition_name: { type: "string", description: "Optional exact composition name; omit to inspect the active composition." } } },
handler: async (args: { composition_name?: unknown } = {}) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
const compositionName = args.composition_name === undefined ? "" : safeTemplateName(args.composition_name, "composition_name");
const result = await sendAfterEffects(buildAfterEffectsScript(`
var project = app.project;
if (!project) return __aeError("No After Effects project is open");
var comp = null;
if ("${escapeForAfterEffects(compositionName)}") {
for (var i = 1; i <= project.numItems; i++) {
if (project.item(i) instanceof CompItem && String(project.item(i).name) === "${escapeForAfterEffects(compositionName)}") { comp = project.item(i); break; }
}
} else if (project.activeItem instanceof CompItem) comp = project.activeItem;
if (!comp) return __aeError("No matching active or named composition was found");
var fonts = {}; var layers = [];
for (var layerIndex = 1; layerIndex <= comp.numLayers && layerIndex <= 100; layerIndex++) {
var layer = comp.layer(layerIndex);
var descriptor = { name: String(layer.name), kind: String(layer.matchName || "layer"), hasSource: !!layer.source };
if (layer.matchName === "ADBE Text Layer") {
try { var documentValue = layer.property("ADBE Text Properties").property("ADBE Text Document").value; if (documentValue && documentValue.font) fonts[String(documentValue.font)] = true; } catch (fontReadError) {}
}
layers.push(descriptor);
}
var fontNames = []; for (var fontName in fonts) if (fonts.hasOwnProperty(fontName)) fontNames.push(fontName);
var controllerNames = [];
var supportsControllerReadback = typeof comp.motionGraphicsTemplateControllerCount === "number" && typeof comp.getMotionGraphicsTemplateControllerName === "function";
if (supportsControllerReadback) {
for (var controllerIndex = 1; controllerIndex <= comp.motionGraphicsTemplateControllerCount && controllerIndex <= 100; controllerIndex++) {
try { controllerNames.push(String(comp.getMotionGraphicsTemplateControllerName(controllerIndex))); } catch (controllerReadError) { controllerNames.push("<unreadable controller>"); }
}
}
return __aeResult({
compositionName: String(comp.name), width: comp.width, height: comp.height, durationSeconds: comp.duration, frameRate: comp.frameRate,
motionGraphicsTemplateName: String(comp.motionGraphicsTemplateName || ""), layerCount: comp.numLayers, layers: layers, requiredFonts: fontNames,
exposedControlNames: controllerNames, exposedControlsReadbackAvailable: supportsControllerReadback,
verificationScope: "Controller names are read from the After Effects source composition only when its AE 16.1+ API is available. This does not establish exported-MOGRT compatibility, Premiere import, or visual verification."
});
`), bridgeOptions);
return result.success ? { ...result, data: { ...(result.data as object), operationId } } : { ...result, error: `${result.error ?? "After Effects inspection failed"} (operation ${operationId})` };
},
},
preview_mogrt_library_publish: {
description: "Preview a no-overwrite publish of a validated MOGRT into a workspace-contained, versioned local library. The library root must already exist; no file or directory is created during preview.",
parameters: { type: "object" as const, additionalProperties: false, properties: { approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." }, mogrt_path: { type: "string", description: "Existing workspace-contained MOGRT artifact." }, library_directory: { type: "string", description: "Existing workspace-contained local library root." }, template_name: { type: "string", description: "Safe immutable library template name." } }, required: ["approved_workspace_path", "mogrt_path", "library_directory", "template_name"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "mogrt_path", "library_directory", "template_name"], "arguments");
const artifact = mogrtArtifact(input.approved_workspace_path, input.mogrt_path);
const inspected = artifactStatus(artifact.artifact);
if (!inspected.exists || !inspected.zip_header_valid) throw new Error("mogrt_path must exist and have a ZIP header before publishing");
const library = containedPath(artifact.root, text(input.library_directory, "library_directory", MAX_PATH), "library_directory");
if (!directoryExists(library.candidate)) throw new Error("library_directory must already exist; this workflow will not create a new library root");
const templateName = safeTemplateName(input.template_name);
let version = "";
let destination = "";
for (let index = 1; index <= 999; index += 1) {
const candidateVersion = `v${String(index).padStart(3, "0")}`;
const candidate = library.api.join(library.candidate, templateName, candidateVersion, `${templateName}.mogrt`);
if (!fileExists(candidate)) { version = candidateVersion; destination = candidate; break; }
}
if (!destination) throw new Error("No unused version slot remains for this template");
const previewToken = issue(libraryPlans, { workspace: artifact.root, source: artifact.artifact, destination, templateName, version });
return { success: true, data: { operationId, previewToken, expiresInSeconds: PLAN_TTL_MS / 1000, sourceArtifact: artifact.artifact, destination, version, verificationScope: "This preview does not create a library entry. Publishing copies the exact ZIP-checked source file into a new version directory and never overwrites a prior version." } };
},
},
publish_mogrt_to_library: {
description: "Publish the exact previewed MOGRT as an immutable local-library version. Requires explicit confirmation and will fail instead of replacing an existing version.",
parameters: { type: "object" as const, additionalProperties: false, properties: { preview_token: { type: "string", description: "One-time token returned by preview_mogrt_library_publish." }, confirm_publish: { type: "boolean", description: "Must be true to copy the immutable library version." } }, required: ["preview_token", "confirm_publish"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["preview_token", "confirm_publish"], "arguments");
if (input.confirm_publish !== true) throw new Error("confirm_publish must be true to publish the MOGRT");
const plan = take(libraryPlans, input.preview_token, "preview_token");
if (!artifactStatus(plan.source).zip_header_valid) return { success: false, error: "The previewed source artifact is no longer a recognizable ZIP-based MOGRT" };
if (fileExists(plan.destination)) return { success: false, error: "The previewed library destination now exists; preview publishing again to select a new immutable version" };
makeDirectory(resolvePath(plan.destination).api.dirname(plan.destination));
copyFile(plan.source, plan.destination);
return { success: true, data: { operationId, templateName: plan.templateName, version: plan.version, libraryArtifactPath: plan.destination, artifact: artifactStatus(plan.destination), overwrite: false } };
},
},
inspect_mogrt_library: {
description: "List bounded top-level template names and version directories in an existing workspace-contained local MOGRT library. It never reads MOGRT contents or changes the library.",
parameters: { type: "object" as const, additionalProperties: false, properties: { approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." }, library_directory: { type: "string", description: "Existing workspace-contained local library root." } }, required: ["approved_workspace_path", "library_directory"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["approved_workspace_path", "library_directory"], "arguments");
const library = containedPath(text(input.approved_workspace_path, "approved_workspace_path", MAX_PATH), text(input.library_directory, "library_directory", MAX_PATH), "library_directory");
if (!directoryExists(library.candidate)) return { success: false, error: "library_directory does not exist" };
const templates = listDirectory(library.candidate).slice(0, 100).map((name) => ({ name, versions: directoryExists(library.api.join(library.candidate, name)) ? listDirectory(library.api.join(library.candidate, name)).filter((version) => /^v\d{3}$/.test(version)).slice(0, 100) : [] }));
return { success: true, data: { operationId, libraryDirectory: library.candidate, templates, truncated: listDirectory(library.candidate).length > 100 } };
},
},
inspect_after_effects_render_templates: {
description: "Read available render and output-module template names from the first existing After Effects render-queue item. It never queues or renders a composition.",
parameters: { type: "object" as const, additionalProperties: false, properties: {} },
handler: async () => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
const result = await sendAfterEffects(buildAfterEffectsScript(`
var project = app.project;
if (!project || project.renderQueue.numItems < 1) return __aeError("After Effects has no existing render-queue item to inspect; use known approved template names or add one manually before this read-only check");
var item = project.renderQueue.item(1);
var renderTemplates = item.templates || [];
var outputTemplates = item.outputModule(1).templates || [];
return __aeResult({ renderSettingsTemplates: renderTemplates, outputModuleTemplates: outputTemplates, readOnly: true });
`), bridgeOptions);
return result.success ? { ...result, data: { ...(result.data as object), operationId } } : { ...result, error: `${result.error ?? "After Effects render-template inspection failed"} (operation ${operationId})` };
},
},
preview_after_effects_render: {
description: "Preview a bounded queue-only After Effects render request for one named composition and existing workspace output directory. It does not contact Adobe, enqueue, or render.",
parameters: { type: "object" as const, additionalProperties: false, properties: { approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." }, composition_name: { type: "string", description: "Exact existing After Effects composition name." }, output_path: { type: "string", description: "New workspace-contained output file path whose parent already exists." }, render_settings_template: { type: "string", description: "Exact After Effects render-settings template name." }, output_module_template: { type: "string", description: "Exact After Effects output-module template name." } }, required: ["approved_workspace_path", "composition_name", "output_path", "render_settings_template", "output_module_template"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const plan = renderPlan(args, directoryExists, fileExists);
const previewToken = issue(renderPlans, plan);
return { success: true, data: { operationId, previewToken, expiresInSeconds: PLAN_TTL_MS / 1000, plan, verificationScope: "This does not verify that the named composition or installed render templates exist. Enqueue performs that host check but does not render or verify the output file." } };
},
},
enqueue_after_effects_render: {
description: "Queue exactly one previewed After Effects render with named host templates. Requires explicit confirmation; it saves the open project but never starts rendering or overwrites output.",
parameters: { type: "object" as const, additionalProperties: false, properties: { preview_token: { type: "string", description: "One-time token returned by preview_after_effects_render." }, confirm_enqueue: { type: "boolean", description: "Must be true to queue the planned render." } }, required: ["preview_token", "confirm_enqueue"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "edit", operationId);
requireCapability(capabilities, "export", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["preview_token", "confirm_enqueue"], "arguments");
if (input.confirm_enqueue !== true) throw new Error("confirm_enqueue must be true to queue the render");
const plan = take(renderPlans, input.preview_token, "preview_token");
const result = await sendAfterEffects(buildAfterEffectsScript(`
function normalizedPath(value) { return String(value).replace(/\\\\/g, "/").replace(/\\/+$/, "").toLowerCase(); }
function isInside(root, candidate) { var normalizedRoot = normalizedPath(root); var normalizedCandidate = normalizedPath(candidate); return normalizedCandidate === normalizedRoot || normalizedCandidate.indexOf(normalizedRoot + "/") === 0; }
var project = app.project;
if (!project || !project.file) return __aeError("Open a saved After Effects project inside approved_workspace_path before queuing a render");
if (!isInside("${escapeForAfterEffects(plan.workspace)}", project.file.fsName)) return __aeError("The open After Effects project is outside approved_workspace_path; no render was queued");
var outputFile = new File("${escapeForAfterEffects(plan.outputPath)}");
if (outputFile.exists) return __aeError("The planned output now exists; no render was queued");
var comp = null;
for (var i = 1; i <= project.numItems; i++) if (project.item(i) instanceof CompItem && String(project.item(i).name) === "${escapeForAfterEffects(plan.compositionName)}") { comp = project.item(i); break; }
if (!comp) return __aeError("The previewed composition does not exist in the open project");
var item = project.renderQueue.items.add(comp);
try { item.applyTemplate("${escapeForAfterEffects(plan.renderSettingsTemplate)}"); } catch (renderTemplateError) { item.remove(); return __aeError("After Effects could not apply the requested render-settings template: " + String(renderTemplateError)); }
var outputModule = item.outputModule(1);
try { outputModule.applyTemplate("${escapeForAfterEffects(plan.outputModuleTemplate)}"); } catch (outputTemplateError) { item.remove(); return __aeError("After Effects could not apply the requested output-module template: " + String(outputTemplateError)); }
outputModule.file = outputFile;
project.save(project.file);
return __aeResult({ queued: true, queueItemIndex: item.index, compositionName: String(comp.name), outputPath: outputFile.fsName, renderStarted: false, renderVerified: false, verificationScope: "The item was queued only. Start the queue in After Effects, then verify the resulting media file separately." });
`), bridgeOptions);
return result.success ? { ...result, data: { ...(result.data as object), operationId } } : { ...result, error: `${result.error ?? "After Effects render enqueue failed"} (operation ${operationId})` };
},
},
preview_mogrt_premiere_handoff: {
description: "Preview a contained MOGRT import into one explicitly named disposable Premiere verification sequence and empty video track. It does not contact Premiere or alter a sequence.",
parameters: { type: "object" as const, additionalProperties: false, properties: { approved_workspace_path: { type: "string", description: "Absolute operator-approved workspace root." }, mogrt_path: { type: "string", description: "Existing workspace-contained MOGRT artifact." }, sequence_id: { type: "string", description: "Exact Premiere sequence identifier to recheck before import." }, disposable_sequence_name: { type: "string", description: "Must begin with 'MOGRT Verify - '." }, video_track_index: { type: "integer", description: "Empty video track index for the imported MOGRT; defaults to 0." }, audio_track_index: { type: "integer", description: "Audio track index passed to Premiere; defaults to 0." }, start_seconds: { type: "number", description: "Insertion start time in seconds; defaults to 0." } }, required: ["approved_workspace_path", "mogrt_path", "sequence_id", "disposable_sequence_name"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "inspect", operationId);
requireCapability(capabilities, "filesystem", operationId);
const plan = premiereHandoffPlan(args, artifactStatus);
const previewToken = issue(handoffPlans, plan);
return { success: true, data: { operationId, previewToken, expiresInSeconds: PLAN_TTL_MS / 1000, plan, verificationScope: "This validates the artifact and exact intended Premiere target locally. Applying rechecks the sequence name and requires the requested video track to be empty." } };
},
},
apply_mogrt_premiere_handoff: {
description: "Import exactly one previewed MOGRT into the empty track of the explicit disposable Premiere verification sequence, then read back insertion and control descriptors. Requires explicit confirmation; no rendered-frame claim is made.",
parameters: { type: "object" as const, additionalProperties: false, properties: { preview_token: { type: "string", description: "One-time token returned by preview_mogrt_premiere_handoff." }, confirm_import: { type: "boolean", description: "Must be true to import into the explicit verification sequence." } }, required: ["preview_token", "confirm_import"] },
handler: async (args: unknown) => {
const operationId = nextOperationId();
requireCapability(capabilities, "edit", operationId);
requireCapability(capabilities, "filesystem", operationId);
const input = asObject(args, "arguments");
assertOnlyKeys(input, ["preview_token", "confirm_import"], "arguments");
if (input.confirm_import !== true) throw new Error("confirm_import must be true to import the MOGRT into Premiere");
const plan = take(handoffPlans, input.preview_token, "preview_token");
if (!artifactStatus(plan.mogrtPath).zip_header_valid) return { success: false, error: "The previewed MOGRT artifact is no longer a recognizable ZIP-based file" };
const result = await sendPremiere(buildPremiereHandoffScript(plan), bridgeOptions);
return result.success ? { ...result, data: { ...(result.data as object), operationId } } : { ...result, error: `${result.error ?? "Premiere MOGRT handoff failed"} (operation ${operationId})` };
},
},
};
}
+79
View File
@@ -0,0 +1,79 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getPlaybackTools(bridgeOptions: BridgeOptions) {
return {
play_timeline: {
description: "Request playback of the active sequence timeline through QE. The legacy API does not provide a same-call playhead readback, so movement is not reported as verified.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
qe.startPlayback();
return __result({
playbackRequested: true,
playbackVerified: false,
verificationScope: "QE accepted the playback request only; poll get_playhead_position before treating timeline movement as confirmed."
});
`);
return sendCommand(script, bridgeOptions);
},
},
stop_playback: {
description: "Request that active-sequence timeline playback stop through QE. The legacy API does not provide a same-call playhead readback, so stopped state is not reported as verified.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
qe.stopPlayback();
return __result({
stopRequested: true,
playbackVerified: false,
verificationScope: "QE accepted the stop request only; poll get_playhead_position across time before treating playback as stopped."
});
`);
return sendCommand(script, bridgeOptions);
},
},
play_source_monitor: {
description: "Request playback of the clip in the Source Monitor. The legacy API does not provide a same-call position readback, so movement is not reported as verified.",
parameters: {
type: "object" as const,
properties: {
speed: {
type: "number",
description: "Playback speed (1.0 = normal, 2.0 = 2x, -1.0 = reverse). Default: 1.0",
},
},
},
handler: async (args: { speed?: number }) => {
const speed = args.speed ?? 1.0;
const script = buildToolScript(`
app.sourceMonitor.play(${speed});
return __result({
playbackRequested: true,
playbackVerified: false,
speed: ${speed},
verificationScope: "Premiere accepted the source-monitor playback request only; poll get_source_monitor_position before treating movement as confirmed."
});
`);
return sendCommand(script, bridgeOptions);
},
},
get_source_monitor_position: {
description: "Get the current time indicator position in the Source Monitor",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var pos = app.sourceMonitor.getPosition();
if (!pos) return __error("No clip open in Source Monitor");
return __result({ seconds: __ticksToSeconds(pos.ticks), ticks: pos.ticks });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+152
View File
@@ -0,0 +1,152 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getPlayheadTools(bridgeOptions: BridgeOptions) {
return {
get_playhead_position: {
description: "Get the current playhead (CTI) position in the active sequence",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var ticks = seq.getPlayerPosition().ticks;
return __result({
seconds: __ticksToSeconds(ticks),
ticks: ticks
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_playhead_position: {
description: "Set the playhead (CTI) position in the active sequence",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
description: "Time position in seconds to move the playhead to",
},
},
required: ["time_seconds"],
},
handler: async (args: { time_seconds: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var ticks = __secondsToTicks(${args.time_seconds}).toString();
seq.setPlayerPosition(ticks);
return __result({ positionSeconds: ${args.time_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
set_work_area: {
description: "Set the work area (bar) in and out points",
parameters: {
type: "object" as const,
properties: {
in_seconds: {
type: "number",
description: "Work area in-point in seconds",
},
out_seconds: {
type: "number",
description: "Work area out-point in seconds",
},
},
required: ["in_seconds", "out_seconds"],
},
handler: async (args: { in_seconds: number; out_seconds: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
seq.setWorkAreaInPoint(__secondsToTicks(${args.in_seconds}).toString());
seq.setWorkAreaOutPoint(__secondsToTicks(${args.out_seconds}).toString());
return __result({ workAreaIn: ${args.in_seconds}, workAreaOut: ${args.out_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
get_work_area: {
description: "Get the current work area in and out points",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var inPoint = seq.getWorkAreaInPoint();
var outPoint = seq.getWorkAreaOutPoint();
return __result({
inSeconds: __ticksToSeconds(inPoint),
outSeconds: __ticksToSeconds(outPoint)
});
`);
return sendCommand(script, bridgeOptions);
},
},
set_sequence_in_out_points: {
description: "Set the sequence in and out points (for export range, etc.)",
parameters: {
type: "object" as const,
properties: {
in_seconds: {
type: "number",
description: "In-point in seconds",
},
out_seconds: {
type: "number",
description: "Out-point in seconds",
},
},
required: ["in_seconds", "out_seconds"],
},
handler: async (args: { in_seconds: number; out_seconds: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
seq.setInPoint(${args.in_seconds});
seq.setOutPoint(${args.out_seconds});
var observedIn = Number(seq.getInPoint());
var observedOut = Number(seq.getOutPoint());
var tolerance = 0.001;
if (!isFinite(observedIn) || !isFinite(observedOut) ||
Math.abs(observedIn - ${args.in_seconds}) > tolerance ||
Math.abs(observedOut - ${args.out_seconds}) > tolerance) {
return __error("Premiere did not apply the requested sequence in/out points; observed " + observedIn + " to " + observedOut + " seconds");
}
return __result({ inSeconds: observedIn, outSeconds: observedOut, verified: true });
`);
return sendCommand(script, bridgeOptions);
},
},
get_sequence_in_out_points: {
description: "Get the current sequence in and out points",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
return __result({
inSeconds: Number(seq.getInPoint()),
outSeconds: Number(seq.getOutPoint())
});
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+897
View File
@@ -0,0 +1,897 @@
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import path from "node:path";
import { buildToolScript } from "../bridge/script-builder.js";
import { type BridgeOptions, sendCommand } from "../bridge/file-bridge.js";
import {
contextRevision,
MAX_CONTEXT_RECORDS,
normalizeContextKeywords,
normalizeContextText,
PROJECT_CONTEXT_SCHEMA_VERSION,
type ProjectContextDocument,
type ProjectContextKind,
type ProjectContextRecord,
ProjectContextRepository,
type ProjectContextRepositoryOptions,
searchProjectContext,
stableContextId,
} from "../context/project-context-store.js";
const CORE_CONTEXT_KINDS = new Set<ProjectContextKind>(["project", "sequence", "source", "timeline"]);
const ENRICHMENT_KINDS = new Set<ProjectContextKind>(["transcript", "shot", "audio", "note"]);
const MAX_CAPTURED_TIMELINE_ITEMS = 2_000;
const MAX_ENRICHMENTS_PER_CALL = 512;
export const MAX_CONCURRENT_SOURCE_FINGERPRINTS = 16;
interface SnapshotClip {
nodeId: string;
name: string;
startSeconds: number;
endSeconds: number;
inPointSeconds: number;
outPointSeconds: number;
speed?: number;
trackType: "video" | "audio";
trackIndex: number;
sourceId?: string;
sourceName?: string;
mediaPath?: string;
offline?: boolean;
}
export interface PremiereContextSnapshot {
projectName: string;
projectPath?: string;
sequence: {
id: string;
name: string;
durationSeconds: number;
clips: SnapshotClip[];
truncated: boolean;
};
}
export interface ContextEnrichmentInput {
id?: string;
kind: ProjectContextKind;
name?: string;
text: string;
keywords?: string[];
sequence_id?: string;
source_id?: string;
timeline_item_id?: string;
start_seconds?: number;
end_seconds?: number;
track_type?: "video" | "audio";
track_index?: number;
source_revision?: string;
timeline_revision?: string;
metadata?: Record<string, unknown>;
}
export const EDITORIAL_EVIDENCE_TYPES = [
"transcript_passage",
"speaker_label",
"shot_log",
"audio_observation",
"operator_note",
"frame_reference",
] as const;
export type EditorialEvidenceType = (typeof EDITORIAL_EVIDENCE_TYPES)[number];
export interface EditorialEvidenceInput {
id?: string;
type: EditorialEvidenceType;
name?: string;
text?: string;
keywords?: string[];
speaker_label?: string;
frame_reference_id?: string;
sequence_id?: string;
source_id?: string;
timeline_item_id?: string;
start_seconds?: number;
end_seconds?: number;
track_type?: "video" | "audio";
track_index?: number;
source_revision?: string;
timeline_revision?: string;
metadata?: Record<string, unknown>;
}
interface MediaFingerprint {
mediaPathHash?: string;
size?: number;
modifiedMs?: number;
}
type FingerprintMedia = (mediaPath: string | undefined) => Promise<MediaFingerprint>;
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function normalizeMediaPath(value: string): string {
const normalized = path.normalize(value).replace(/\\/g, "/");
return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
}
async function fingerprintMedia(mediaPath: string | undefined): Promise<MediaFingerprint> {
if (!mediaPath) return {};
const normalized = normalizeMediaPath(mediaPath);
const mediaPathHash = hash(normalized);
try {
const info = await stat(mediaPath);
if (!info.isFile()) return { mediaPathHash };
return { mediaPathHash, size: info.size, modifiedMs: Math.trunc(info.mtimeMs) };
} catch {
return { mediaPathHash };
}
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function sanitizeMetadata(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>).slice(0, 32)) {
if (/(?:^|_)(?:path|token|secret|password|api.?key)(?:$|_)/i.test(key)) continue;
if (typeof entry === "string") result[key] = normalizeContextText(entry).slice(0, 2_000);
else if (typeof entry === "number" && Number.isFinite(entry)) result[key] = entry;
else if (typeof entry === "boolean" || entry === null) result[key] = entry;
}
return Object.keys(result).length ? result : undefined;
}
function safeName(value: unknown, fallback: string): string {
return normalizeContextText(value).slice(0, 512) || fallback;
}
function captureScript(): string {
return buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
var sequence = project.activeSequence;
if (!sequence) return __error("No active sequence");
var clips = [];
var truncated = false;
var maxItems = ${MAX_CAPTURED_TIMELINE_ITEMS};
function appendTracks(tracks, trackType) {
for (var trackIndex = 0; trackIndex < tracks.numTracks; trackIndex++) {
var track = tracks[trackIndex];
for (var clipIndex = 0; clipIndex < track.clips.numItems; clipIndex++) {
if (clips.length >= maxItems) { truncated = true; return; }
var clip = track.clips[clipIndex];
var entry = {
nodeId: String(clip.nodeId),
name: String(clip.name || "Untitled clip"),
startSeconds: __ticksToSeconds(clip.start.ticks),
endSeconds: __ticksToSeconds(clip.end.ticks),
inPointSeconds: __ticksToSeconds(clip.inPoint.ticks),
outPointSeconds: __ticksToSeconds(clip.outPoint.ticks),
trackType: trackType,
trackIndex: trackIndex
};
try { entry.speed = clip.getSpeed(); } catch (e) {}
try {
if (clip.projectItem) {
entry.sourceId = String(clip.projectItem.nodeId);
entry.sourceName = String(clip.projectItem.name || clip.name || "Untitled source");
try { entry.mediaPath = clip.projectItem.getMediaPath(); } catch (e) {}
try { entry.offline = clip.projectItem.isOffline(); } catch (e) {}
}
} catch (e) {}
clips.push(entry);
}
}
}
appendTracks(sequence.videoTracks, "video");
if (!truncated) appendTracks(sequence.audioTracks, "audio");
return __result({
projectName: String(project.name || "Untitled project"),
projectPath: String(project.path || ""),
sequence: {
id: String(sequence.sequenceID),
name: String(sequence.name || "Untitled sequence"),
durationSeconds: __ticksToSeconds(sequence.end),
clips: clips,
truncated: truncated
}
});
`);
}
export async function buildContextDocumentFromSnapshot(
snapshot: PremiereContextSnapshot,
existing?: ProjectContextDocument,
fingerprint: FingerprintMedia = fingerprintMedia,
): Promise<{ document: ProjectContextDocument; invalidatedRecords: number }> {
if (!snapshot.projectName || !snapshot.sequence?.id || !Array.isArray(snapshot.sequence.clips)) {
throw new Error("Premiere returned an incomplete context snapshot");
}
if (snapshot.sequence.clips.length > MAX_CAPTURED_TIMELINE_ITEMS) {
throw new Error(`Context capture is limited to ${MAX_CAPTURED_TIMELINE_ITEMS} timeline items`);
}
const projectIdentity = snapshot.projectPath?.trim() || snapshot.projectName;
const projectId = stableContextId("project", normalizeMediaPath(projectIdentity));
const projectPathHash = snapshot.projectPath?.trim() ? hash(normalizeMediaPath(snapshot.projectPath)) : undefined;
const indexedAt = new Date().toISOString();
const uniqueSources = new Map<string, SnapshotClip>();
for (const clip of snapshot.sequence.clips) {
if (clip.sourceId && !uniqueSources.has(clip.sourceId)) uniqueSources.set(clip.sourceId, clip);
}
const sourceEntries = [...uniqueSources.entries()];
const fingerprints = new Map<string, MediaFingerprint>();
let nextSourceIndex = 0;
await Promise.all(Array.from(
{ length: Math.min(MAX_CONCURRENT_SOURCE_FINGERPRINTS, sourceEntries.length) },
async () => {
while (nextSourceIndex < sourceEntries.length) {
const [sourceId, clip] = sourceEntries[nextSourceIndex++]!;
fingerprints.set(sourceId, await fingerprint(clip.mediaPath));
}
},
));
const sourceRecords: ProjectContextRecord[] = [...uniqueSources.entries()].map(([sourceId, clip]) => {
const media = fingerprints.get(sourceId) ?? {};
const extension = clip.mediaPath ? path.extname(clip.mediaPath).replace(/^\./, "").toLocaleLowerCase() : "";
const sourceRevision = stableContextId(
"source",
sourceId,
media.mediaPathHash,
media.size,
media.modifiedMs,
clip.offline === true,
);
return {
id: stableContextId("source-record", sourceId),
kind: "source",
name: safeName(clip.sourceName, safeName(clip.name, "Untitled source")),
text: [
`Source ${safeName(clip.sourceName, safeName(clip.name, "Untitled source"))}.`,
extension ? `Media type ${extension}.` : "",
clip.offline === true ? "Media is offline." : "Media is online or its state is unknown.",
].filter(Boolean).join(" "),
keywords: [extension, clip.trackType].filter(Boolean),
sourceId,
sourceRevision,
mediaPathHash: media.mediaPathHash,
metadata: {
...(media.size !== undefined ? { fileSize: media.size } : {}),
...(media.modifiedMs !== undefined ? { modifiedMs: media.modifiedMs } : {}),
offline: clip.offline === true,
},
indexedAt,
};
});
const sourceRevisionById = new Map(sourceRecords.map((record) => [record.sourceId!, record.sourceRevision!]));
const sourceRevision = stableContextId(
"sources",
sourceRecords.map((record) => [record.sourceId, record.sourceRevision]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
);
const timelineRecords: ProjectContextRecord[] = snapshot.sequence.clips.map((clip) => {
const timelineRevision = stableContextId(
"timeline-item",
snapshot.sequence.id,
clip.nodeId,
clip.startSeconds,
clip.endSeconds,
clip.inPointSeconds,
clip.outPointSeconds,
clip.speed,
clip.trackType,
clip.trackIndex,
);
return {
id: stableContextId("timeline-record", snapshot.sequence.id, clip.nodeId, clip.trackType, clip.trackIndex),
kind: "timeline",
name: safeName(clip.name, "Untitled timeline item"),
text: `${safeName(clip.name, "Untitled timeline item")} on ${clip.trackType} track ${clip.trackIndex} from ${clip.startSeconds.toFixed(3)}s to ${clip.endSeconds.toFixed(3)}s using source ${clip.inPointSeconds.toFixed(3)}s to ${clip.outPointSeconds.toFixed(3)}s.`,
keywords: [clip.trackType, `track-${clip.trackIndex}`],
sequenceId: snapshot.sequence.id,
sourceId: clip.sourceId,
timelineItemId: clip.nodeId,
startSeconds: clip.startSeconds,
endSeconds: clip.endSeconds,
trackType: clip.trackType,
trackIndex: clip.trackIndex,
sourceRevision: clip.sourceId ? sourceRevisionById.get(clip.sourceId) : undefined,
timelineRevision,
indexedAt,
};
});
const timelineRevision = stableContextId(
"timeline",
snapshot.sequence.id,
timelineRecords.map((record) => [record.id, record.timelineRevision]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
);
const retained: ProjectContextRecord[] = [];
let invalidatedRecords = 0;
for (const record of existing?.records ?? []) {
if (CORE_CONTEXT_KINDS.has(record.kind)) continue;
const currentSourceRevision = record.sourceId ? sourceRevisionById.get(record.sourceId) : undefined;
const sourceStillValid = !record.sourceId || (currentSourceRevision && record.sourceRevision === currentSourceRevision);
const timelineStillValid = !record.timelineRevision || record.timelineRevision === timelineRevision;
if (sourceStillValid && timelineStillValid) retained.push(record);
else invalidatedRecords++;
}
const records: ProjectContextRecord[] = [
{
id: stableContextId("project-record", projectId),
kind: "project",
name: safeName(snapshot.projectName, "Untitled project"),
text: `Project ${safeName(snapshot.projectName, "Untitled project")} with active sequence ${safeName(snapshot.sequence.name, "Untitled sequence")}.`,
keywords: ["project", "premiere"],
sequenceId: snapshot.sequence.id,
indexedAt,
},
{
id: stableContextId("sequence-record", snapshot.sequence.id),
kind: "sequence",
name: safeName(snapshot.sequence.name, "Untitled sequence"),
text: `Sequence ${safeName(snapshot.sequence.name, "Untitled sequence")} is ${snapshot.sequence.durationSeconds.toFixed(3)} seconds with ${snapshot.sequence.clips.length} indexed timeline items${snapshot.sequence.truncated ? "; capture was truncated" : ""}.`,
keywords: ["sequence", "timeline"],
sequenceId: snapshot.sequence.id,
startSeconds: 0,
endSeconds: snapshot.sequence.durationSeconds,
timelineRevision,
metadata: { captureTruncated: snapshot.sequence.truncated },
indexedAt,
},
...sourceRecords,
...timelineRecords,
...retained,
];
if (records.length > MAX_CONTEXT_RECORDS) {
throw new Error(`Captured context exceeds the ${MAX_CONTEXT_RECORDS}-record project limit`);
}
const revision = contextRevision(sourceRevision, timelineRevision, records);
return {
invalidatedRecords,
document: {
schemaVersion: PROJECT_CONTEXT_SCHEMA_VERSION,
projectId,
projectName: safeName(snapshot.projectName, "Untitled project"),
projectPathHash,
revision,
sourceRevision,
timelineRevision,
updatedAt: indexedAt,
records,
},
};
}
export function enrichContextDocument(
document: ProjectContextDocument,
inputs: ContextEnrichmentInput[],
replace = false,
): { document: ProjectContextDocument; upserted: number } {
if (!inputs.length) throw new Error("records must contain at least one enrichment");
if (inputs.length > MAX_ENRICHMENTS_PER_CALL) {
throw new Error(`One enrichment call is limited to ${MAX_ENRICHMENTS_PER_CALL} records`);
}
const sourceRevisionById = new Map(
document.records
.filter((record) => record.kind === "source" && record.sourceId && record.sourceRevision)
.map((record) => [record.sourceId!, record.sourceRevision!]),
);
const now = new Date().toISOString();
const normalized = inputs.map((input, index): ProjectContextRecord => {
if (!ENRICHMENT_KINDS.has(input.kind)) {
throw new Error(`records[${index}].kind must be transcript, shot, audio, or note`);
}
const text = normalizeContextText(input.text);
if (!text) throw new Error(`records[${index}].text must not be empty`);
const sourceRevision = input.source_id ? sourceRevisionById.get(input.source_id) : undefined;
if (input.source_id && !sourceRevision) {
throw new Error(`records[${index}] references an unknown source_id`);
}
if (input.source_revision && input.source_revision !== sourceRevision) {
throw new Error(`records[${index}] has a stale source_revision; capture context again before enrichment`);
}
if (input.timeline_revision && input.timeline_revision !== document.timelineRevision) {
throw new Error(`records[${index}] has a stale timeline_revision; capture context again before enrichment`);
}
const startSeconds = finiteNumber(input.start_seconds);
const endSeconds = finiteNumber(input.end_seconds);
if (startSeconds !== undefined && startSeconds < 0) throw new Error(`records[${index}].start_seconds must be non-negative`);
if (endSeconds !== undefined && (endSeconds < 0 || (startSeconds !== undefined && endSeconds < startSeconds))) {
throw new Error(`records[${index}].end_seconds must be at or after start_seconds`);
}
return {
id: input.id?.trim().slice(0, 256) || stableContextId(
"enrichment",
input.kind,
input.source_id,
input.timeline_item_id,
startSeconds,
endSeconds,
text,
),
kind: input.kind,
name: safeName(input.name, `${input.kind} context`),
text,
keywords: normalizeContextKeywords(input.keywords),
sequenceId: input.sequence_id,
sourceId: input.source_id,
timelineItemId: input.timeline_item_id,
startSeconds,
endSeconds,
trackType: input.track_type,
trackIndex: finiteNumber(input.track_index),
sourceRevision,
timelineRevision: input.timeline_item_id || input.sequence_id ? document.timelineRevision : undefined,
metadata: sanitizeMetadata(input.metadata),
indexedAt: now,
};
});
const base = replace
? document.records.filter((record) => CORE_CONTEXT_KINDS.has(record.kind))
: document.records;
const byId = new Map(base.map((record) => [record.id, record]));
for (const record of normalized) byId.set(record.id, record);
const records = [...byId.values()];
if (records.length > MAX_CONTEXT_RECORDS) {
throw new Error(`Project context is limited to ${MAX_CONTEXT_RECORDS} records`);
}
const updatedAt = now;
return {
upserted: normalized.length,
document: {
...document,
records,
updatedAt,
revision: contextRevision(document.sourceRevision, document.timelineRevision, records),
},
};
}
function requireProjectId(value: unknown): string {
const projectId = typeof value === "string" ? value.trim() : "";
if (!projectId || projectId.length > 256) throw new Error("project_id is required");
return projectId;
}
function enrichmentSchema() {
return {
type: "object",
properties: {
id: { type: "string", description: "Optional stable enrichment ID for deterministic replacement" },
kind: { type: "string", enum: ["transcript", "shot", "audio", "note"] },
name: { type: "string" },
text: { type: "string", description: "Transcript, visual description, audio observation, or editor note" },
keywords: { type: "array", items: { type: "string" } },
sequence_id: { type: "string" },
source_id: { type: "string" },
timeline_item_id: { type: "string" },
start_seconds: { type: "number" },
end_seconds: { type: "number" },
track_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "number" },
source_revision: { type: "string", description: "Optional stale-analysis guard from the source record" },
timeline_revision: { type: "string", description: "Optional stale-placement guard from context status" },
metadata: { type: "object", description: "Small scalar metadata object; path and credential-like keys are discarded" },
},
required: ["kind", "text"],
};
}
function editorialEvidenceSchema() {
return {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string", maxLength: 256, description: "Optional stable evidence ID for deterministic replacement." },
type: { type: "string", enum: EDITORIAL_EVIDENCE_TYPES, description: "Evidence shape: transcript passage, speaker label, shot log, audio observation, operator note, or an opaque frame reference." },
name: { type: "string", maxLength: 512, description: "Optional bounded display name for the local evidence record." },
text: { type: "string", maxLength: 20000, description: "Caller-approved local evidence text. Required except for speaker_label and frame_reference, which can derive a bounded local note." },
keywords: { type: "array", maxItems: 64, items: { type: "string", maxLength: 256, description: "One local search keyword." }, description: "Optional local search keywords." },
speaker_label: { type: "string", maxLength: 160, description: "For transcript_passage or speaker_label, an editor-supplied speaker label. It is not inferred by this server." },
frame_reference_id: { type: "string", maxLength: 256, description: "For frame_reference, an opaque fixture or review-frame identifier, never a native file path or URL." },
sequence_id: { type: "string", maxLength: 512, description: "Optional captured sequence ID. Requires the exact current timeline_revision." },
source_id: { type: "string", maxLength: 512, description: "Optional captured source ID. Requires the exact current source_revision." },
timeline_item_id: { type: "string", maxLength: 512, description: "Optional captured timeline item ID. Requires the exact current timeline_revision." },
start_seconds: { type: "number", minimum: 0, description: "Optional non-negative source or sequence start in seconds." },
end_seconds: { type: "number", minimum: 0, description: "Optional end in seconds at or after start_seconds." },
track_type: { type: "string", enum: ["video", "audio"], description: "Optional recorded track type when the evidence is tied to a timeline item." },
track_index: { type: "number", minimum: 0, description: "Optional recorded track index when the evidence is tied to a timeline item." },
source_revision: { type: "string", maxLength: 256, description: "Required exact source revision whenever source_id is supplied." },
timeline_revision: { type: "string", maxLength: 256, description: "Required exact timeline revision whenever sequence_id or timeline_item_id is supplied." },
metadata: { type: "object", description: "Optional small scalar metadata. Credential-like and path-like keys are discarded before local storage." },
},
required: ["type"],
};
}
function evidenceText(value: unknown, field: string, required: boolean): string | undefined {
if (value === undefined && !required) return undefined;
const normalized = normalizeContextText(value);
if (!normalized && required) throw new Error(`${field} is required`);
if (normalized.length > 20_000) throw new Error(`${field} must be at most 20000 characters`);
return normalized || undefined;
}
function evidenceLabel(value: unknown, field: string): string | undefined {
if (value === undefined) return undefined;
const normalized = normalizeContextText(value);
if (!normalized || normalized.length > 160) throw new Error(`${field} must be a non-empty string of at most 160 characters`);
return normalized;
}
function opaqueReference(value: unknown): string {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(value)) {
throw new Error("frame_reference_id must be an opaque identifier using letters, numbers, dots, underscores, or hyphens; paths and URLs are not accepted");
}
return value;
}
function requireExactRevision(value: unknown, expected: string, field: string): void {
if (typeof value !== "string" || value !== expected) {
throw new Error(`${field} must match the currently captured revision; capture context again before importing evidence`);
}
}
function editorialEvidenceToEnrichment(
document: ProjectContextDocument,
inputs: EditorialEvidenceInput[],
): ContextEnrichmentInput[] {
if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > MAX_ENRICHMENTS_PER_CALL) {
throw new Error(`evidence must contain between 1 and ${MAX_ENRICHMENTS_PER_CALL} entries`);
}
const sourceRevisionById = new Map(
document.records
.filter((record) => record.kind === "source" && record.sourceId && record.sourceRevision)
.map((record) => [record.sourceId!, record.sourceRevision!]),
);
const sequenceIds = new Set(document.records.filter((record) => record.kind === "sequence").map((record) => record.sequenceId));
const timelineItemIds = new Set(document.records.filter((record) => record.kind === "timeline").map((record) => record.timelineItemId));
return inputs.map((input, index): ContextEnrichmentInput => {
if (!input || typeof input !== "object" || Array.isArray(input) || !EDITORIAL_EVIDENCE_TYPES.includes(input.type)) {
throw new Error(`evidence[${index}].type must be a supported editorial evidence type`);
}
if (input.source_id) {
const expectedSourceRevision = sourceRevisionById.get(input.source_id);
if (!expectedSourceRevision) throw new Error(`evidence[${index}] references an unknown source_id`);
requireExactRevision(input.source_revision, expectedSourceRevision, `evidence[${index}].source_revision`);
} else if (input.source_revision !== undefined) {
throw new Error(`evidence[${index}].source_revision requires source_id`);
}
if (input.sequence_id || input.timeline_item_id) {
requireExactRevision(input.timeline_revision, document.timelineRevision, `evidence[${index}].timeline_revision`);
} else if (input.timeline_revision !== undefined) {
throw new Error(`evidence[${index}].timeline_revision requires sequence_id or timeline_item_id`);
}
if (input.sequence_id && !sequenceIds.has(input.sequence_id)) {
throw new Error(`evidence[${index}] references an unknown sequence_id`);
}
if (input.timeline_item_id && !timelineItemIds.has(input.timeline_item_id)) {
throw new Error(`evidence[${index}] references an unknown timeline_item_id`);
}
const speakerLabel = evidenceLabel(input.speaker_label, `evidence[${index}].speaker_label`);
const explicitText = evidenceText(input.text, `evidence[${index}].text`, input.type !== "speaker_label" && input.type !== "frame_reference");
const frameReferenceId = input.type === "frame_reference"
? opaqueReference(input.frame_reference_id)
: undefined;
const mapped = {
transcript_passage: "transcript",
speaker_label: "note",
shot_log: "shot",
audio_observation: "audio",
operator_note: "note",
frame_reference: "shot",
} as const;
const type = input.type as EditorialEvidenceType;
const text = explicitText
?? (type === "speaker_label"
? `Speaker label: ${speakerLabel ?? "unspecified"}.`
: `Frame reference: ${frameReferenceId}.`);
const metadata = input.metadata && typeof input.metadata === "object" && !Array.isArray(input.metadata)
? input.metadata
: {};
return {
id: input.id,
kind: mapped[type],
name: input.name,
text,
keywords: input.keywords,
sequence_id: input.sequence_id,
source_id: input.source_id,
timeline_item_id: input.timeline_item_id,
start_seconds: input.start_seconds,
end_seconds: input.end_seconds,
track_type: input.track_type,
track_index: input.track_index,
source_revision: input.source_revision,
timeline_revision: input.timeline_revision,
metadata: {
...metadata,
evidenceType: type,
...(speakerLabel ? { speakerLabel } : {}),
...(frameReferenceId ? { frameReferenceId } : {}),
},
};
});
}
export interface ProjectContextToolDependencies {
repository?: ProjectContextRepository;
repositoryOptions?: ProjectContextRepositoryOptions;
captureSnapshot?: () => Promise<{ success: boolean; data?: unknown; error?: string }>;
}
export function getProjectContextTools(
bridgeOptions: BridgeOptions,
dependencies: ProjectContextToolDependencies = {},
) {
const repository = dependencies.repository ?? new ProjectContextRepository(dependencies.repositoryOptions);
const loadSnapshot = dependencies.captureSnapshot ?? (() => sendCommand(captureScript(), bridgeOptions));
return {
manage_project_context: {
description: "Capture, enrich, import revision-bound editorial evidence, inspect, or clear a durable local Premiere project-context index. Capture stores bounded active-sequence/source metadata; local enrichment and evidence import add caller-approved transcripts, speaker labels, shots, audio observations, notes, or opaque frame references without re-analyzing media. Never include secrets or unrelated customer data.",
parameters: {
type: "object" as const,
properties: {
action: { type: "string", enum: ["capture", "enrich", "import_evidence", "status", "clear"], description: "Capture active context, enrich it, import revision-bound editorial evidence, inspect status, or clear one local project index." },
project_id: { type: "string", description: "Project context ID returned by capture; optional for status to list projects" },
replace: { type: "boolean", description: "For enrich, replace prior enrichments while retaining captured core records" },
records: { type: "array", items: enrichmentSchema(), description: "For enrich, up to 512 transcript, shot, audio, or note records" },
evidence: { type: "array", items: editorialEvidenceSchema(), description: "For import_evidence, up to 512 caller-approved records with strict source/timeline revision guards. The server does not read referenced frame files, invoke analysis, or contact a provider." },
},
required: ["action"],
},
handler: async (args: {
action: "capture" | "enrich" | "import_evidence" | "status" | "clear";
project_id?: string;
replace?: boolean;
records?: ContextEnrichmentInput[];
evidence?: EditorialEvidenceInput[];
}) => {
if (args.action === "capture") {
const result = await loadSnapshot();
if (!result.success) return result;
const snapshot = result.data as PremiereContextSnapshot;
const projectIdentity = snapshot.projectPath?.trim() || snapshot.projectName;
const projectId = stableContextId("project", normalizeMediaPath(projectIdentity));
const existing = await repository.get(projectId);
const built = await buildContextDocumentFromSnapshot(snapshot, existing);
await repository.put(built.document);
return {
success: true,
data: {
backend: await repository.backendName(),
projectId: built.document.projectId,
projectName: built.document.projectName,
revision: built.document.revision,
sourceRevision: built.document.sourceRevision,
timelineRevision: built.document.timelineRevision,
recordCount: built.document.records.length,
invalidatedRecords: built.invalidatedRecords,
captureTruncated: snapshot.sequence.truncated,
},
};
}
if (args.action === "status") {
if (!args.project_id) {
return { success: true, data: { backend: await repository.backendName(), projects: await repository.list() } };
}
const document = await repository.get(requireProjectId(args.project_id));
if (!document) return { success: false, error: "Project context not found" };
const counts = Object.fromEntries(
[...new Set(document.records.map((record) => record.kind))].map((kind) => [
kind,
document.records.filter((record) => record.kind === kind).length,
]),
);
return {
success: true,
data: {
backend: await repository.backendName(),
projectId: document.projectId,
projectName: document.projectName,
revision: document.revision,
sourceRevision: document.sourceRevision,
timelineRevision: document.timelineRevision,
recordCount: document.records.length,
counts,
updatedAt: document.updatedAt,
},
};
}
if (args.action === "clear") {
const projectId = requireProjectId(args.project_id);
return { success: true, data: { projectId, cleared: await repository.delete(projectId) } };
}
if (args.action === "enrich") {
const projectId = requireProjectId(args.project_id);
const current = await repository.get(projectId);
if (!current) return { success: false, error: "Project context not found; capture it before enrichment" };
const enriched = enrichContextDocument(current, args.records ?? [], args.replace === true);
await repository.put(enriched.document);
return {
success: true,
data: {
backend: await repository.backendName(),
projectId,
revision: enriched.document.revision,
sourceRevision: enriched.document.sourceRevision,
timelineRevision: enriched.document.timelineRevision,
recordCount: enriched.document.records.length,
upserted: enriched.upserted,
},
};
}
if (args.action === "import_evidence") {
const projectId = requireProjectId(args.project_id);
const current = await repository.get(projectId);
if (!current) return { success: false, error: "Project context not found; capture it before importing editorial evidence" };
try {
const inputs = editorialEvidenceToEnrichment(current, args.evidence ?? []);
const enriched = enrichContextDocument(current, inputs, args.replace === true);
await repository.put(enriched.document);
return {
success: true,
data: {
backend: await repository.backendName(),
projectId,
revision: enriched.document.revision,
sourceRevision: enriched.document.sourceRevision,
timelineRevision: enriched.document.timelineRevision,
recordCount: enriched.document.records.length,
upserted: enriched.upserted,
importedEvidenceTypes: [...new Set(args.evidence!.map((entry) => entry.type))],
applied: false,
verificationBoundary: "Evidence was stored only in the local project-context index. The server did not open a frame, invoke vision/ASR/LLM/Adobe services, or mutate Premiere.",
},
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
return { success: false, error: `Unsupported project context action: ${String(args.action)}` };
},
},
search_project_context: {
description: "Search a durable Premiere project-context index for relevant sources, timeline placements, transcript passages, shots, audio observations, and notes. Returns bounded evidence with stable IDs and revisions; it never changes Premiere.",
parameters: {
type: "object" as const,
properties: {
project_id: { type: "string", description: "Project context ID returned by manage_project_context capture" },
query: { type: "string", description: "Natural-language or keyword query matched against indexed evidence" },
sequence_id: { type: "string", description: "Optional exact sequence ID filter" },
kinds: {
type: "array",
items: { type: "string", enum: ["project", "sequence", "source", "timeline", "transcript", "shot", "audio", "note"] },
description: "Optional context-kind filter",
},
max_results: { type: "integer", minimum: 1, maximum: 50, description: "Maximum 50; default 12" },
},
required: ["project_id", "query"],
},
handler: async (args: {
project_id: string;
query: string;
sequence_id?: string;
kinds?: ProjectContextKind[];
max_results?: number;
}) => {
const projectId = requireProjectId(args.project_id);
if (args.max_results !== undefined &&
(!Number.isInteger(args.max_results) || args.max_results < 1 || args.max_results > 50)) {
return { success: false, error: "max_results must be an integer from 1 through 50" };
}
const document = await repository.get(projectId);
if (!document) return { success: false, error: "Project context not found" };
const results = searchProjectContext(document, {
query: args.query,
sequenceId: args.sequence_id,
kinds: args.kinds,
limit: args.max_results ?? 12,
});
return {
success: true,
data: {
projectId,
revision: document.revision,
sourceRevision: document.sourceRevision,
timelineRevision: document.timelineRevision,
results: results.map(({ score, matchedTerms, record }) => ({
score,
matchedTerms,
id: record.id,
kind: record.kind,
name: record.name,
evidence: record.text,
keywords: record.keywords,
sequenceId: record.sequenceId,
sourceId: record.sourceId,
timelineItemId: record.timelineItemId,
startSeconds: record.startSeconds,
endSeconds: record.endSeconds,
trackType: record.trackType,
trackIndex: record.trackIndex,
sourceRevision: record.sourceRevision,
timelineItemRevision: record.timelineRevision,
metadata: record.metadata,
})),
},
};
},
},
create_context_edit_plan: {
description: "Create a non-mutating, evidence-backed edit-plan scaffold from indexed Premiere context. It returns ranked source/time candidates and stale-state guards; the model must review them and use preview_edit_plan before any mutation.",
parameters: {
type: "object" as const,
properties: {
project_id: { type: "string", description: "Project context ID returned by manage_project_context capture" },
intent: { type: "string", description: "Editing goal, such as finding the strongest budget explanation for a rough cut" },
sequence_id: { type: "string", description: "Optional exact sequence ID filter" },
strategy: { type: "string", enum: ["rough_cut", "select_ranges", "review"], description: "Planning mode; default rough_cut" },
max_candidates: { type: "number", description: "Maximum 25; default 8" },
},
required: ["project_id", "intent"],
},
handler: async (args: {
project_id: string;
intent: string;
sequence_id?: string;
strategy?: "rough_cut" | "select_ranges" | "review";
max_candidates?: number;
}) => {
const projectId = requireProjectId(args.project_id);
const document = await repository.get(projectId);
if (!document) return { success: false, error: "Project context not found" };
const results = searchProjectContext(document, {
query: args.intent,
sequenceId: args.sequence_id,
kinds: ["transcript", "shot", "audio", "note", "timeline", "source"],
limit: Math.max(1, Math.min(25, Math.trunc(args.max_candidates ?? 8))),
});
return {
success: true,
data: {
applied: false,
projectId,
intent: normalizeContextText(args.intent).slice(0, 1_000),
strategy: args.strategy ?? "rough_cut",
expectedContextRevision: document.revision,
expectedTimelineRevision: document.timelineRevision,
candidates: results.map(({ score, record }) => ({
evidenceId: record.id,
score,
kind: record.kind,
name: record.name,
evidence: record.text,
sourceId: record.sourceId,
timelineItemId: record.timelineItemId,
startSeconds: record.startSeconds,
endSeconds: record.endSeconds,
sourceRevision: record.sourceRevision,
})),
nextSteps: [
"Review candidate evidence and reject unsupported or duplicate ranges.",
"Capture project context again if the active sequence changed; require the same expectedTimelineRevision.",
"Resolve exact project-item and timeline identities before constructing operations.",
"Call preview_edit_plan and present its confirmation token before apply_edit_plan.",
],
},
};
},
},
};
}
+130
View File
@@ -0,0 +1,130 @@
import { buildToolScript } from "../bridge/script-builder.js";
import { sendCommand, type BridgeOptions, type CommandResult } from "../bridge/file-bridge.js";
import {
buildProjectIntakeReport,
MAX_PROJECT_INTAKE_ITEMS,
} from "../intake/project-intake.js";
export interface ProjectIntakeToolDependencies {
captureSnapshot?: (maxItems: number) => Promise<CommandResult>;
}
function boundedMaxItems(value: unknown): number {
if (value === undefined) return MAX_PROJECT_INTAKE_ITEMS;
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_PROJECT_INTAKE_ITEMS) {
throw new Error(`max_items must be an integer from 1 through ${MAX_PROJECT_INTAKE_ITEMS}`);
}
return value;
}
/**
* Capture only the bounded fields required by the pure intake evaluator. The
* script does not require an active sequence and never changes Premiere.
*/
export function projectIntakeCaptureScript(maxItems: number): string {
return buildToolScript(`
var project = app.project;
if (!project || !project.rootItem) return __error("No project is open");
var maximumItems = ${maxItems};
var items = [];
var truncated = false;
function itemType(item) {
if (item.type === 2) return "bin";
if (item.type === 1 || item.type === 4) return "clip";
return "other";
}
function walk(parent, parentId, depth) {
if (!parent || !parent.children || depth > 32) {
if (depth > 32) truncated = true;
return;
}
for (var i = 0; i < parent.children.numItems; i++) {
if (items.length >= maximumItems) { truncated = true; return; }
var item = parent.children[i];
var entry = {
id: String(item.nodeId),
name: String(item.name),
type: itemType(item),
parentId: String(parentId || "root")
};
try { if (item.treePath) entry.treePath = String(item.treePath); } catch(e) {}
try {
var mediaPath = item.getMediaPath();
if (mediaPath) entry.mediaPath = String(mediaPath);
} catch(e) {}
try { entry.offline = Boolean(item.isOffline()); } catch(e) {}
try { entry.hasProxy = Boolean(item.hasProxy()); } catch(e) {}
try {
var interpretation = item.getFootageInterpretation();
if (interpretation && interpretation.frameRate) entry.frameRate = Number(interpretation.frameRate);
} catch(e) {}
items.push(entry);
if (item.type === 2) walk(item, item.nodeId, depth + 1);
if (truncated && items.length >= maximumItems) return;
}
}
walk(project.rootItem, "root", 0);
var projectId = "";
try { if (project.documentID) projectId = String(project.documentID); } catch(e) {}
if (!projectId) projectId = String(project.name);
return __result({
project: { id: projectId, name: String(project.name) },
items: items,
truncated: truncated,
unavailableEvidence: []
});
`);
}
export function getProjectIntakeTools(
bridgeOptions: BridgeOptions,
dependencies: ProjectIntakeToolDependencies = {},
) {
const capture = dependencies.captureSnapshot
?? ((maxItems: number) => sendCommand(projectIntakeCaptureScript(maxItems), bridgeOptions));
return {
preview_project_intake: {
description: "Inspect a bounded Premiere project against an explicit facility intake template and return a path-redacted report plus a non-mutating organization proposal. It never changes Premiere or persists the template.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
template: {
type: "object",
description: "Versioned facility policy. Unknown fields and unsafe matching expressions are rejected by the intake engine.",
},
include_paths: {
type: "boolean",
description: "Include observed media paths in findings. Defaults to false; hashes are returned instead.",
},
max_items: {
type: "integer",
minimum: 1,
maximum: MAX_PROJECT_INTAKE_ITEMS,
description: `Maximum project items to inspect; defaults to ${MAX_PROJECT_INTAKE_ITEMS}. Truncation returns an incomplete report.`,
},
},
required: ["template"],
},
handler: async (args: { template: unknown; include_paths?: boolean; max_items?: number }) => {
try {
const maxItems = boundedMaxItems(args.max_items);
const captured = await capture(maxItems);
if (!captured.success) return captured;
return {
success: true,
data: buildProjectIntakeReport(captured.data, args.template, { includePaths: args.include_paths === true }),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
};
}
+110
View File
@@ -0,0 +1,110 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getProjectManagerTools(bridgeOptions: BridgeOptions) {
return {
consolidate_and_transfer: {
description:
"Consolidate, copy, or transcode project media using the Project Manager. Reports success only after a new destination folder contains a copied Premiere project.",
parameters: {
type: "object" as const,
properties: {
destination_path: {
type: "string",
description: "Destination folder path for the consolidated project",
},
include_all_sequences: {
type: "boolean",
description: "Include all sequences (default: true). If false, only active sequence is used.",
},
copy_to_new_location: {
type: "boolean",
description: "Copy media to a new location (default: true)",
},
exclude_unused: {
type: "boolean",
description: "Exclude unused clips (default: true)",
},
transcode: {
type: "boolean",
description: "Transcode media during copy (default: false)",
},
include_preview_files: {
type: "boolean",
description: "Include preview/render files (default: false)",
},
rename_media: {
type: "boolean",
description: "Rename media to match clip names (default: false)",
},
convert_image_sequences: {
type: "boolean",
description: "Convert image sequences to clips (default: false)",
},
convert_ae_comps: {
type: "boolean",
description: "Convert After Effects compositions (default: false)",
},
convert_synthetic: {
type: "boolean",
description: "Convert synthetic importer items (default: false)",
},
},
required: ["destination_path"],
},
handler: async (args: {
destination_path: string;
include_all_sequences?: boolean;
copy_to_new_location?: boolean;
exclude_unused?: boolean;
transcode?: boolean;
include_preview_files?: boolean;
rename_media?: boolean;
convert_image_sequences?: boolean;
convert_ae_comps?: boolean;
convert_synthetic?: boolean;
}) => {
const destinationPath = args.destination_path.trim();
if (!destinationPath) {
return { success: false, error: "destination_path must not be empty" };
}
const script = buildToolScript(`
var pm = app.projectManager;
if (!pm) return __error("Project Manager not available");
var destination = new Folder("${escapeForExtendScript(destinationPath)}");
if (destination.exists) {
return __error("destination_path must be a new, empty folder so the Project Manager output can be verified: ${escapeForExtendScript(destinationPath)}");
}
pm.destinationPath = "${escapeForExtendScript(destinationPath)}";
pm.includeAllSequences = ${args.include_all_sequences !== false ? 1 : 0};
pm.copyToNewLocation = ${args.copy_to_new_location !== false ? 1 : 0};
pm.excludeUnused = ${args.exclude_unused !== false ? 1 : 0};
pm.transcodeMedia = ${args.transcode ? 1 : 0};
pm.includePreviewFiles = ${args.include_preview_files ? 1 : 0};
pm.renameMedia = ${args.rename_media ? 1 : 0};
pm.convertImageSequences = ${args.convert_image_sequences ? 1 : 0};
pm.convertAEComps = ${args.convert_ae_comps ? 1 : 0};
pm.convertSyntheticMedia = ${args.convert_synthetic ? 1 : 0};
var accepted = pm.process(app.project);
if (accepted === false) return __error("Premiere rejected the Project Manager request");
if (!destination.exists) {
return __error("Premiere accepted the Project Manager request but did not create the destination folder. No successful transfer is reported.");
}
var outputFiles = destination.getFiles("*.prproj");
if (!outputFiles || outputFiles.length < 1) {
return __error("Premiere created the destination folder but no copied .prproj file was found. No successful transfer is reported.");
}
return __result({
completed: true,
verified: true,
destination: "${escapeForExtendScript(destinationPath)}",
copiedProjectCount: outputFiles.length
});
`);
return sendCommand(script, { ...bridgeOptions, timeoutMs: 300000 }); // 5 min timeout
},
},
};
}
+805
View File
@@ -0,0 +1,805 @@
import {
buildToolScript,
escapeForExtendScript,
} from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getProjectTools(bridgeOptions: BridgeOptions) {
return {
save_project: {
description: "Save the current Premiere Pro project",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
project.save();
return __result({ saved: true, name: project.name, path: project.path });
`);
return sendCommand(script, bridgeOptions);
},
},
save_project_as: {
description: "Save the current project to a new location",
parameters: {
type: "object" as const,
properties: {
path: {
type: "string",
description:
"Full file path to save the project to (e.g., '/Users/me/projects/MyProject.prproj')",
},
},
required: ["path"],
},
handler: async (args: { path: string }) => {
const script = buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
project.saveAs("${escapeForExtendScript(args.path)}");
return __result({ saved: true, path: "${escapeForExtendScript(args.path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
open_project: {
description: "Open a Premiere Pro project file",
parameters: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "Full file path to the .prproj file",
},
},
required: ["path"],
},
handler: async (args: { path: string }) => {
const script = buildToolScript(`
app.openDocument("${escapeForExtendScript(args.path)}");
var project = app.project;
return __result({ opened: true, name: project.name, path: project.path });
`);
return sendCommand(script, bridgeOptions);
},
},
set_active_sequence: {
description: "Set the active sequence by name or ID",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID to make active",
},
},
required: ["sequence_id"],
},
handler: async (args: { sequence_id: string }) => {
const script = buildToolScript(`
var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}");
if (!seq) return __error("Sequence not found: ${escapeForExtendScript(args.sequence_id)}");
app.project.activeSequence = seq;
var active = __getCurrentActiveSequence();
if (!active || String(active.sequenceID) !== String(seq.sequenceID)) {
return __error("Premiere did not activate the requested sequence. Re-read the active sequence before making timeline edits.");
}
return __result({ active: true, verified: true, name: active.name, id: active.sequenceID });
`);
return sendCommand(script, bridgeOptions);
},
},
undo: {
description: "Undo the last action in Premiere Pro",
parameters: {
type: "object" as const,
properties: {
count: {
type: "number",
description: "Number of times to undo (default: 1)",
},
},
},
handler: async (args: { count?: number }) => {
const count = args.count || 1;
const script = buildToolScript(`
for (var i = 0; i < ${count}; i++) {
app.project.undo();
}
return __result({ undone: ${count} });
`);
return sendCommand(script, bridgeOptions);
},
},
consolidate_duplicates: {
description:
"Consolidate duplicate project items and report success only when duplicate media groups decrease.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
function __duplicateMediaStats() {
var pathMap = {};
function scan(bin) {
for (var i = 0; i < bin.children.numItems; i++) {
var item = bin.children[i];
try {
var mediaPath = item.getMediaPath();
var nodeId = String(item.nodeId || "");
if (mediaPath && nodeId) {
if (!pathMap[mediaPath]) pathMap[mediaPath] = { nodeIds: {}, count: 0 };
if (!pathMap[mediaPath].nodeIds[nodeId]) {
pathMap[mediaPath].nodeIds[nodeId] = true;
pathMap[mediaPath].count++;
}
}
} catch (e) {}
if (item.type === 2) scan(item);
}
}
scan(app.project.rootItem);
var duplicateGroupCount = 0;
var duplicateItemCount = 0;
for (var path in pathMap) {
if (pathMap.hasOwnProperty(path) && pathMap[path].count > 1) {
duplicateGroupCount++;
duplicateItemCount += pathMap[path].count - 1;
}
}
return {
duplicateGroupCount: duplicateGroupCount,
duplicateItemCount: duplicateItemCount
};
}
var before = __duplicateMediaStats();
if (before.duplicateGroupCount === 0) {
return __result({
consolidated: false,
changed: false,
reason: "No duplicate media groups were found before consolidation.",
before: before,
after: before
});
}
if (typeof app.project.consolidateDuplicates !== "function") {
return __error("This Premiere build does not expose project.consolidateDuplicates; no duplicate items were changed.");
}
try {
app.project.consolidateDuplicates();
} catch (e) {
return __error("Premiere could not consolidate duplicate project items: " + e.toString());
}
var after = __duplicateMediaStats();
if (after.duplicateGroupCount >= before.duplicateGroupCount && after.duplicateItemCount >= before.duplicateItemCount) {
return __error("Premiere completed consolidateDuplicates but duplicate media groups did not decrease. No consolidation success is reported; inspect the project and use get_duplicate_media to review the unchanged groups.");
}
return __result({ consolidated: true, changed: true, verified: true, before: before, after: after });
`);
return sendCommand(script, bridgeOptions);
},
},
create_project: {
description: "Create a new Premiere Pro project at the specified path",
parameters: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "Full file path for the new .prproj file",
},
},
required: ["path"],
},
handler: async (args: { path: string }) => {
const requestedPath = args.path.trim();
if (!/\.prproj$/i.test(requestedPath)) {
return {
success: false,
error:
"create_project path must be a full .prproj file path; Premiere cannot create a project from a directory path.",
};
}
const script = buildToolScript(`
var requestedPath = "${escapeForExtendScript(requestedPath)}";
function __normalizedProjectPath(path) {
return String(path || "").replace(/\\\\/g, "/").toLowerCase();
}
var beforePath = app.project ? String(app.project.path || "") : "";
if (__normalizedProjectPath(beforePath) === __normalizedProjectPath(requestedPath)) {
return __error("A project is already open at " + requestedPath + "; choose a new .prproj file path instead.");
}
app.newProject(requestedPath);
var project = app.project;
var actualPath = project ? String(project.path || "") : "";
if (!project || !actualPath || __normalizedProjectPath(actualPath) !== __normalizedProjectPath(requestedPath)) {
return __error(
"Premiere did not create a project at " + requestedPath +
"; the active project is still " + (actualPath || beforePath || "unavailable") +
". Check that the path is a new .prproj file and its parent directory exists."
);
}
var active = __getCurrentActiveSequence();
if (project.sequences.numSequences === 0 && active) {
return __error(
"Premiere retained an active sequence from the prior project after creating an empty project. " +
"No timeline operation is reported as ready; reopen the project or create and activate a sequence before editing."
);
}
return __result({ created: true, name: project.name, path: actualPath, activeSequence: active ? { name: active.name, id: active.sequenceID } : null });
`);
return sendCommand(script, bridgeOptions);
},
},
close_project: {
description: "Close the current Premiere Pro project",
parameters: {
type: "object" as const,
properties: {
save_first: {
type: "boolean",
description: "Whether to save before closing (default: true)",
},
},
},
handler: async (args: { save_first?: boolean }) => {
const save = args.save_first !== false;
const script = buildToolScript(`
var project = app.project;
if (!project) return __error("No project is open");
var name = project.name;
project.closeDocument(${save ? "1" : "0"}, ${save ? "0" : "0"});
return __result({ closed: true, name: name, saved: ${save} });
`);
return sendCommand(script, bridgeOptions);
},
},
import_ae_comps: {
description: "Import After Effects compositions from an .aep file",
parameters: {
type: "object" as const,
properties: {
ae_project_path: {
type: "string",
description: "Full path to the .aep file",
},
comp_names: {
type: "array",
items: { type: "string" },
description:
"Array of composition names to import. If omitted, imports all comps.",
},
target_bin: {
type: "string",
description: "Target bin name or node ID (optional)",
},
},
required: ["ae_project_path"],
},
handler: async (args: {
ae_project_path: string;
comp_names?: string[];
target_bin?: string;
}) => {
const binLookup = args.target_bin
? `var targetBin = __findProjectItem("${escapeForExtendScript(args.target_bin)}"); if (!targetBin) return __error("Bin not found");`
: `var targetBin = app.project.rootItem;`;
if (args.comp_names && args.comp_names.length > 0) {
const comps = args.comp_names
.map((c) => `"${escapeForExtendScript(c)}"`)
.join(", ");
const script = buildToolScript(`
${binLookup}
app.project.importAEComps("${escapeForExtendScript(args.ae_project_path)}", [${comps}], targetBin);
return __result({ imported: true, comps: [${comps}] });
`);
return sendCommand(script, bridgeOptions);
} else {
const script = buildToolScript(`
${binLookup}
app.project.importAllAEComps("${escapeForExtendScript(args.ae_project_path)}", targetBin);
return __result({ imported: true, allComps: true });
`);
return sendCommand(script, bridgeOptions);
}
},
},
delete_bin: {
description: "Delete a bin (folder) from the project panel",
parameters: {
type: "object" as const,
properties: {
bin_id: {
type: "string",
description: "Name or node ID of the bin to delete",
},
},
required: ["bin_id"],
},
handler: async (args: { bin_id: string }) => {
const script = buildToolScript(`
var bin = __findProjectItem("${escapeForExtendScript(args.bin_id)}");
if (!bin) return __error("Bin not found: ${escapeForExtendScript(args.bin_id)}");
if (bin.type !== 2) return __error("Item is not a bin");
var nodeId = String(bin.nodeId);
var name = bin.name;
bin.deleteBin();
if (__findProjectItem(nodeId)) {
return __error("Premiere did not remove bin: " + name + ". The deletion is not reported as successful.");
}
return __result({ deleted: true, verified: true, name: name, nodeId: nodeId });
`);
return sendCommand(script, bridgeOptions);
},
},
rename_bin: {
description: "Rename a bin (folder) in the project panel",
parameters: {
type: "object" as const,
properties: {
bin_id: {
type: "string",
description: "Name or node ID of the bin to rename",
},
new_name: {
type: "string",
description: "New name for the bin",
},
},
required: ["bin_id", "new_name"],
},
handler: async (args: { bin_id: string; new_name: string }) => {
const script = buildToolScript(`
var bin = __findProjectItem("${escapeForExtendScript(args.bin_id)}");
if (!bin) return __error("Bin not found: ${escapeForExtendScript(args.bin_id)}");
if (bin.type !== 2) return __error("Item is not a bin");
var oldName = bin.name;
bin.renameBin("${escapeForExtendScript(args.new_name)}");
return __result({ renamed: true, oldName: oldName, newName: "${escapeForExtendScript(args.new_name)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
create_smart_bin: {
description: "Create a smart bin (search bin) in the project panel",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name for the smart bin",
},
query: {
type: "string",
description: "Search query for the smart bin",
},
},
required: ["name", "query"],
},
handler: async (args: { name: string; query: string }) => {
const script = buildToolScript(`
app.project.rootItem.createSmartBin("${escapeForExtendScript(args.name)}", "${escapeForExtendScript(args.query)}");
return __result({ created: true, name: "${escapeForExtendScript(args.name)}", query: "${escapeForExtendScript(args.query)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
find_items_by_media_path: {
description:
"Find project items whose media path contains the given search string",
parameters: {
type: "object" as const,
properties: {
path_search: {
type: "string",
description: "Partial file path to search for",
},
},
required: ["path_search"],
},
handler: async (args: { path_search: string }) => {
const script = buildToolScript(`
var root = app.project.rootItem;
var matches = root.findItemsMatchingMediaPath("${escapeForExtendScript(args.path_search)}");
var items = [];
if (matches) {
for (var i = 0; i < matches.length; i++) {
items.push({
nodeId: matches[i].nodeId,
name: matches[i].name,
type: matches[i].type === 1 ? "clip" : matches[i].type === 2 ? "bin" : "sequence"
});
}
}
return __result({ count: items.length, items: items });
`);
return sendCommand(script, bridgeOptions);
},
},
start_batch_encode: {
description:
"Request Adobe Media Encoder to start the render queue; reports only accepted handoff, not queue progress or output-file creation.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
if (!app.encoder || typeof app.encoder.startBatch !== "function") return __error("Adobe Media Encoder is not available");
try {
var startResult = app.encoder.startBatch();
// Premiere reports an accepted batch-start request as 1/true.
if (startResult !== 1 && startResult !== true) return __error("Adobe Media Encoder did not accept the start request.");
} catch (e) {
return __error("Adobe Media Encoder rejected the start request: " + e.message);
}
return __result({
requested: true,
verified: false,
outcome: "committed_unverified",
verificationScope: "Premiere accepted a start request; queue presence, progress, and output-file creation are not verified by this tool."
});
`);
return sendCommand(script, bridgeOptions);
},
},
add_custom_metadata_field: {
description:
"Add a custom metadata field to the project's metadata schema. This creates a schema/column definition only; it does not set a per-item value. Use set_metadata with complete Project Metadata XML and readback to update a value.",
parameters: {
type: "object" as const,
properties: {
field_name: {
type: "string",
description: "Internal name for the metadata field",
},
field_label: {
type: "string",
description: "Display label for the field",
},
field_type: {
type: "number",
description:
"Type of the field: 0 = Integer, 1 = Real, 2 = String, 3 = Boolean",
},
},
required: ["field_name", "field_label", "field_type"],
},
handler: async (args: {
field_name: string;
field_label: string;
field_type: number;
}) => {
const script = buildToolScript(`
var accepted = app.project.addPropertyToProjectMetadataSchema("${escapeForExtendScript(args.field_name)}", "${escapeForExtendScript(args.field_label)}", ${args.field_type});
if (accepted === false) return __error("Premiere rejected the custom metadata schema field");
return __result({
added: true,
name: "${escapeForExtendScript(args.field_name)}",
label: "${escapeForExtendScript(args.field_label)}",
outcome: "committed_unverified",
verificationBoundary: "Premiere does not expose a schema-field enumeration readback through the legacy CEP API",
perItemValue: "Use set_metadata with complete Project Metadata XML and updated_fields."
});
`);
return sendCommand(script, bridgeOptions);
},
},
import_sequences: {
description: "Import sequences from another Premiere Pro project file",
parameters: {
type: "object" as const,
properties: {
project_path: {
type: "string",
description: "Full path to the source .prproj file",
},
sequence_ids: {
type: "array",
items: { type: "string" },
description:
"Non-empty array of sequence IDs to import from the source project.",
},
},
required: ["project_path", "sequence_ids"],
},
handler: async (args: {
project_path: string;
sequence_ids: string[];
}) => {
if (!Array.isArray(args.sequence_ids) || args.sequence_ids.length === 0
|| args.sequence_ids.some((id) => typeof id !== "string" || !id.trim())) {
return { success: false, error: "sequence_ids must be a non-empty array of source sequence IDs." };
}
const ids = args.sequence_ids
.map((id) => `"${escapeForExtendScript(id)}"`)
.join(", ");
const script = buildToolScript(`
var sourceProject = new File("${escapeForExtendScript(args.project_path)}");
if (!sourceProject.exists) return __error("Source Premiere project does not exist: " + sourceProject.fsName);
var beforeIds = {};
for (var beforeIndex = 0; beforeIndex < app.project.sequences.numSequences; beforeIndex++) {
beforeIds[String(app.project.sequences[beforeIndex].sequenceID)] = true;
}
app.project.importSequences(sourceProject.fsName, [${ids}]);
var imported = [];
for (var afterIndex = 0; afterIndex < app.project.sequences.numSequences; afterIndex++) {
var candidate = app.project.sequences[afterIndex];
if (!beforeIds[String(candidate.sequenceID)]) {
imported.push({ id: String(candidate.sequenceID), name: candidate.name });
}
}
if (!imported.length) {
return __error("Premiere did not add any of the requested sequences from the source project.");
}
return __result({ imported: true, requestedSequenceIds: [${ids}], sequences: imported, verified: true });
`);
return sendCommand(script, bridgeOptions);
},
},
create_bars_and_tone: {
description:
"Create a Bars and Tone synthetic media item in the project (useful for leader/calibration)",
parameters: {
type: "object" as const,
properties: {
width: {
type: "number",
description: "Frame width in pixels (default: 1920)",
},
height: {
type: "number",
description: "Frame height in pixels (default: 1080)",
},
timebase: {
type: "string",
description:
"Timebase as ticks-per-second string (default uses sequence timebase)",
},
pixel_aspect_numerator: {
type: "number",
description: "Pixel aspect ratio numerator (default: 1)",
},
pixel_aspect_denominator: {
type: "number",
description: "Pixel aspect ratio denominator (default: 1)",
},
audio_sample_rate: {
type: "number",
description: "Audio sample rate in Hz (default: 48000)",
},
name: {
type: "string",
description:
"Name for the bars and tone item (default: 'Bars and Tone')",
},
},
},
handler: async (args: {
width?: number;
height?: number;
timebase?: string;
pixel_aspect_numerator?: number;
pixel_aspect_denominator?: number;
audio_sample_rate?: number;
name?: string;
}) => {
const w = args.width ?? 1920;
const h = args.height ?? 1080;
const parNum = args.pixel_aspect_numerator ?? 1;
const parDen = args.pixel_aspect_denominator ?? 1;
const audioSampleRate = args.audio_sample_rate ?? 48000;
const name = args.name ?? "Bars and Tone";
if (![w, h, parNum, parDen, audioSampleRate].every(Number.isFinite)
|| ![w, h, parNum, parDen, audioSampleRate].every(Number.isInteger)
|| w < 1 || h < 1 || parNum < 1 || parDen < 1 || audioSampleRate < 1) {
return { success: false, error: "Bars and tone dimensions, pixel-aspect values, and audio_sample_rate must be positive integers." };
}
const script = buildToolScript(`
var seq = app.project.activeSequence;
var timebase = parseFloat(${args.timebase ? `"${escapeForExtendScript(args.timebase)}"` : `seq ? seq.timebase : "254016000000"`});
if (!isFinite(timebase) || timebase <= 0) return __error("A positive sequence timebase is required to create bars and tone.");
var item = app.project.newBarsAndTone(
${w},
${h},
timebase,
${parNum},
${parDen},
${audioSampleRate},
"${escapeForExtendScript(name)}"
);
if (!item) return __error("Premiere did not create the Bars and Tone project item.");
return __result({
created: true,
verified: true,
name: item.name,
nodeId: item.nodeId,
width: ${w},
height: ${h},
pixelAspectRatio: "${parNum}:${parDen}",
audioSampleRate: ${audioSampleRate}
});
`);
return sendCommand(script, bridgeOptions);
},
},
import_fcp_xml: {
description: "Import a Final Cut Pro XML file into the current project",
parameters: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "Full path to the FCP XML file",
},
},
required: ["path"],
},
handler: async (args: { path: string }) => {
const script = buildToolScript(`
app.openFCPXML("${escapeForExtendScript(args.path)}");
return __result({ imported: true, path: "${escapeForExtendScript(args.path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
set_transcode_on_ingest: {
description: "Enable or disable transcoding on ingest for the project",
parameters: {
type: "object" as const,
properties: {
enabled: {
type: "boolean",
description: "True to enable transcode on ingest, false to disable",
},
},
required: ["enabled"],
},
handler: async (args: { enabled: boolean }) => {
const script = buildToolScript(`
app.project.setEnableTranscodeOnIngest(${args.enabled ? 1 : 0});
return __result({ set: true, transcodeOnIngest: ${args.enabled} });
`);
return sendCommand(script, bridgeOptions);
},
},
get_insertion_bin: {
description:
"Get the current target bin for new imports (the bin that is currently focused in the Project panel)",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var bin = app.project.getInsertionBin();
if (!bin) return __error("No insertion bin found");
return __result({ name: bin.name, nodeId: bin.nodeId, treePath: bin.treePath });
`);
return sendCommand(script, bridgeOptions);
},
},
get_project_panel_metadata: {
description:
"Get the current project panel metadata/column configuration as XML",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var meta = app.project.getProjectPanelMetadata();
if (!meta) return __error("Could not retrieve project panel metadata");
return __result({ metadata: meta });
`);
return sendCommand(script, bridgeOptions);
},
},
set_project_panel_metadata: {
description:
"Set the project panel metadata/column configuration from XML and verify that Premiere reads back the exact XML",
parameters: {
type: "object" as const,
properties: {
metadata_xml: {
type: "string",
description:
"XML string containing the project panel metadata configuration",
},
},
required: ["metadata_xml"],
},
handler: async (args: { metadata_xml: string }) => {
const script = buildToolScript(`
var requestedMetadata = "${escapeForExtendScript(args.metadata_xml)}";
var accepted = app.project.setProjectPanelMetadata(requestedMetadata);
if (accepted === false) return __error("Premiere rejected the Project panel metadata update");
var readback = app.project.getProjectPanelMetadata();
if (String(readback) !== requestedMetadata) {
return __error(
"Premiere did not return the requested Project panel metadata XML after the write. " +
"The update is not reported as successful; inspect get_project_panel_metadata before retrying."
);
}
return __result({ set: true, verified: true });
`);
return sendCommand(script, bridgeOptions);
},
},
get_graphics_white_luminance: {
description:
"Get the graphics white luminance value (HDR setting) for the project",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var val = app.project.getGraphicsWhiteLuminance();
return __result({ graphicsWhiteLuminance: val });
`);
return sendCommand(script, bridgeOptions);
},
},
set_graphics_white_luminance: {
description:
"Set the graphics white luminance value (HDR setting) for the project",
parameters: {
type: "object" as const,
properties: {
luminance: {
type: "number",
description: "White luminance value in nits",
},
},
required: ["luminance"],
},
handler: async (args: { luminance: number }) => {
const script = buildToolScript(`
app.project.setGraphicsWhiteLuminance(${args.luminance});
return __result({ set: true, graphicsWhiteLuminance: ${args.luminance} });
`);
return sendCommand(script, bridgeOptions);
},
},
set_scratch_disk_path: {
description: "Set the scratch disk path for a specific media type",
parameters: {
type: "object" as const,
properties: {
scratch_disk_type: {
type: "string",
description:
"Type: 'capturedVideo', 'capturedAudio', 'videoPreview', 'audioPreview', 'autoSave', 'ccLibraries'",
},
path: {
type: "string",
description: "Full directory path for the scratch disk",
},
},
required: ["scratch_disk_type", "path"],
},
handler: async (args: { scratch_disk_type: string; path: string }) => {
const script = buildToolScript(`
app.setScratchDiskPath("${escapeForExtendScript(args.path)}", "${escapeForExtendScript(args.scratch_disk_type)}");
return __result({ set: true, type: "${escapeForExtendScript(args.scratch_disk_type)}", path: "${escapeForExtendScript(args.path)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+291
View File
@@ -0,0 +1,291 @@
import {
copyFileSync,
constants,
existsSync,
readFileSync,
readdirSync,
statSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { basename, dirname, extname, join, resolve } from "node:path";
import { buildToolScript } from "../bridge/script-builder.js";
import {
getTempDir,
getBridgeLiveness,
sendCommand,
type BridgeOptions,
type CommandResult,
} from "../bridge/file-bridge.js";
const MAX_CANDIDATES = 50;
const AUTOSAVE_DIR_NAMES = [
"Adobe Premiere Pro Auto-Save",
"Premiere Pro Auto-Save",
];
interface ProjectSnapshot {
name: string;
path: string;
}
export interface RecoveryCandidate {
path: string;
fileName: string;
modifiedAt: string;
modifiedMs: number;
sizeBytes: number;
newerThanProjectFile: boolean | null;
}
export interface ProjectBackupReceipt {
sourcePath: string;
backupPath: string;
sizeBytes: number;
checksumSha256: string;
sourceUnchanged: true;
byteIdentical: true;
}
function sha256File(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
export function createProjectBackup(projectPath: string, now = new Date()): ProjectBackupReceipt {
const sourcePath = resolve(projectPath);
if (extname(sourcePath).toLowerCase() !== ".prproj") {
throw new Error("project_path must point to an Adobe Premiere .prproj file");
}
if (!existsSync(sourcePath)) throw new Error(`Project file does not exist: ${sourcePath}`);
const before = statSync(sourcePath);
if (!before.isFile()) throw new Error(`Project path is not a regular file: ${sourcePath}`);
const stamp = now.toISOString().replace(/[:.]/g, "-");
const backupPath = `${sourcePath}.backup-${stamp}`;
copyFileSync(sourcePath, backupPath, constants.COPYFILE_EXCL);
const after = statSync(sourcePath);
const backup = statSync(backupPath);
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
throw new Error("Project file changed while its backup was being created; do not rely on this copy");
}
const sourceChecksum = sha256File(sourcePath);
const backupChecksum = sha256File(backupPath);
if (backup.size !== before.size || backupChecksum !== sourceChecksum) {
throw new Error("Project backup verification failed: copied bytes do not match the source");
}
return {
sourcePath,
backupPath,
sizeBytes: backup.size,
checksumSha256: backupChecksum,
sourceUnchanged: true,
byteIdentical: true,
};
}
export function discoverAdjacentRecoveryCandidates(
projectPath: string,
): RecoveryCandidate[] {
if (!projectPath || extname(projectPath).toLowerCase() !== ".prproj") return [];
const absoluteProjectPath = resolve(projectPath);
const projectDirectory = dirname(absoluteProjectPath);
const projectStem = basename(projectPath, extname(projectPath)).toLowerCase();
let projectModifiedMs: number | null = null;
try {
projectModifiedMs = statSync(absoluteProjectPath).mtimeMs;
} catch {
// An unsaved/new or inaccessible project may not exist on disk yet.
}
const roots = [
...AUTOSAVE_DIR_NAMES.map((name) => join(projectDirectory, name)),
projectDirectory,
];
const seen = new Set<string>();
const candidates: RecoveryCandidate[] = [];
for (const root of roots) {
if (!existsSync(root)) continue;
let names: string[] = [];
try {
names = readdirSync(root);
} catch {
continue;
}
for (const name of names) {
if (candidates.length >= MAX_CANDIDATES) break;
if (extname(name).toLowerCase() !== ".prproj") continue;
if (!name.toLowerCase().includes(projectStem)) continue;
const candidatePath = resolve(root, name);
if (candidatePath === absoluteProjectPath || seen.has(candidatePath)) continue;
seen.add(candidatePath);
try {
const stat = statSync(candidatePath);
if (!stat.isFile()) continue;
candidates.push({
path: candidatePath,
fileName: basename(candidatePath),
modifiedAt: stat.mtime.toISOString(),
modifiedMs: stat.mtimeMs,
sizeBytes: stat.size,
newerThanProjectFile:
projectModifiedMs === null ? null : stat.mtimeMs > projectModifiedMs,
});
} catch {
// A candidate can disappear during an autosave; omit unstable entries.
}
}
}
return candidates.sort((left, right) => right.modifiedMs - left.modifiedMs);
}
export function collectBridgeTelemetry(
bridgeOptions: BridgeOptions,
nowMs = Date.now(),
) {
const directory = getTempDir(bridgeOptions);
const heartbeat = getBridgeLiveness(bridgeOptions, nowMs);
const counts = { pendingCommands: 0, pendingResponses: 0, busyOperations: 0 };
let oldestPendingAgeMs: number | null = null;
let directoryAccessible = false;
try {
if (existsSync(directory)) {
directoryAccessible = true;
for (const name of readdirSync(directory)) {
let bucket: keyof typeof counts | null = null;
if (/^cmd_.+\.jsx$/.test(name)) bucket = "pendingCommands";
else if (/^res_.+\.json$/.test(name)) bucket = "pendingResponses";
else if (/^busy_.+\.json$/.test(name)) bucket = "busyOperations";
if (!bucket) continue;
counts[bucket]++;
try {
const age = Math.max(0, nowMs - statSync(join(directory, name)).mtimeMs);
oldestPendingAgeMs =
oldestPendingAgeMs === null ? age : Math.max(oldestPendingAgeMs, age);
} catch {
// Aggregate telemetry remains useful if one transient file disappears.
}
}
}
} catch {
directoryAccessible = false;
}
return {
schemaVersion: 1,
directoryAccessible,
...counts,
oldestPendingAgeMs,
heartbeat,
healthy:
directoryAccessible &&
heartbeat.state === "running" &&
counts.busyOperations === 0 &&
(oldestPendingAgeMs === null || oldestPendingAgeMs < 30_000),
privacy:
"Aggregate local bridge state only; no paths, filenames, script contents, project names, or media names are returned.",
};
}
function projectSnapshot(result: CommandResult): ProjectSnapshot | null {
if (!result.success || !result.data || typeof result.data !== "object") return null;
const value = result.data as Record<string, unknown>;
if (typeof value.name !== "string" || typeof value.path !== "string") return null;
return { name: value.name, path: value.path };
}
export function getRecoveryTools(bridgeOptions: BridgeOptions) {
return {
create_project_backup: {
description:
"Create a collision-safe, byte-verified backup beside an existing .prproj file without opening or modifying the source project.",
parameters: {
type: "object" as const,
properties: {
project_path: {
type: "string",
description: "Absolute or working-directory-relative path to an existing .prproj file",
},
},
required: ["project_path"],
},
handler: async (args: { project_path: string }) => {
try {
return { success: true, data: createProjectBackup(args.project_path) };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
inspect_project_recovery: {
description:
"Read-only recovery inspection: diagnose the active project path and list adjacent Premiere Auto-Save project candidates without opening, copying, or restoring anything.",
parameters: {},
handler: async () => {
const result = await sendCommand(
buildToolScript(`
if (!app.project) return __error("No project is open");
return __result({
name: app.project.name || "",
path: app.project.path || ""
});
`),
bridgeOptions,
);
if (!result.success) return result;
const project = projectSnapshot(result);
if (!project) {
return {
success: false,
error: "Premiere returned an invalid project snapshot",
};
}
const hasSavedPath =
project.path.length > 0 &&
extname(project.path).toLowerCase() === ".prproj";
const candidates = hasSavedPath
? discoverAdjacentRecoveryCandidates(project.path)
: [];
return {
success: true,
data: {
project: {
name: project.name,
path: project.path || null,
hasSavedPath,
fileExists: hasSavedPath ? existsSync(project.path) : false,
},
unsavedChanges: {
status: "not_exposed",
dirty: null,
reason:
"Premiere's documented scripting APIs do not expose the current dirty/unsaved flag.",
},
recovery: {
mode: "read_only_discovery",
candidateCount: candidates.length,
truncated: candidates.length >= MAX_CANDIDATES,
candidates,
automaticRestoreSupported: false,
guidance:
candidates.length > 0
? "Review modification times and file sizes, then use Premiere Pro's File > Open to inspect a chosen copy. Keep the current project open until you have confirmed the recovery."
: hasSavedPath
? "No adjacent .prproj recovery candidates were found. Check Premiere Pro's configured Auto Save location and Creative Cloud recovery UI manually."
: "Save the project to establish a project directory; then inspect Premiere's Auto Save location manually.",
},
},
};
},
},
get_bridge_telemetry: {
description:
"Inspect privacy-preserving aggregate bridge health: pending command/response counts, busy operations, queue age, and CEP heartbeat state without returning project or personal data.",
parameters: {},
handler: async () => ({
success: true,
data: collectBridgeTelemetry(bridgeOptions),
}),
},
};
}
+428
View File
@@ -0,0 +1,428 @@
import { buildToolScript, buildScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, sendRawCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getScriptingTools(bridgeOptions: BridgeOptions) {
return {
execute_extendscript: {
description: `Execute custom ExtendScript code in Premiere Pro. The code runs inside an IIFE with helper functions available.
IMPORTANT: You MUST write ES3 syntax (var instead of let/const, no arrow functions, no template literals, no destructuring).
Available helpers (auto-prepended):
- __ticksToSeconds(ticks) / __secondsToTicks(seconds) — time conversion
- __ticksToTimecode(ticks, fps) — timecode string
- __findSequence(idOrName) — find sequence by name or ID
- __findProjectItem(nodeIdOrName) — find project item recursively
- __findClip(nodeId) — find clip in active sequence, returns {clip, trackIndex, clipIndex, trackType}
- __getAllClips(seq) — get all clips in a sequence
- __result(data) — return success with data (MUST call this or __error)
- __error(msg) — return error message
- TICKS_PER_SECOND — constant 254016000000
- app.enableQE() — enable QE DOM access
Your code MUST end with: return __result({...}) or return __error("message")
Example: Set opacity to 50% on all video clips
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var count = 0;
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.displayName === "Opacity") {
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "Opacity") {
comp.properties[p].setValue(50, true);
count++;
}
}
}
}
}
}
return __result({updated: count});`,
parameters: {
type: "object" as const,
properties: {
code: {
type: "string",
description: "ExtendScript code to execute (ES3 syntax). Must use return __result({...}) or return __error('...'). Helpers are auto-prepended.",
},
timeout_ms: {
type: "number",
description: "Custom timeout in milliseconds (default: 30000). Increase for long operations.",
},
},
required: ["code"],
},
handler: async (args: { code: string; timeout_ms?: number }) => {
const script = buildToolScript(args.code);
const opts = { ...bridgeOptions };
if (args.timeout_ms) {
opts.timeoutMs = args.timeout_ms;
}
return sendRawCommand(script, opts);
},
},
evaluate_expression: {
description: `Evaluate a simple ExtendScript expression and return its value. Use for quick queries like checking a property, getting a count, or reading state. The expression should be a single value/call — NOT a full script.
Examples:
- "app.project.name" → project name
- "app.project.activeSequence.name" → active sequence name
- "app.project.rootItem.children.numItems" → number of root items
- "app.project.activeSequence.videoTracks.numTracks" → number of video tracks
- "app.version" → Premiere Pro version`,
parameters: {
type: "object" as const,
properties: {
expression: {
type: "string",
description: "ExtendScript expression to evaluate (e.g., 'app.project.name')",
},
},
required: ["expression"],
},
handler: async (args: { expression: string }) => {
const script = buildToolScript(`
try {
var val = ${args.expression};
return __result({ value: val, type: typeof val });
} catch(e) {
return __error("Expression error: " + e.toString());
}
`);
return sendRawCommand(script, bridgeOptions);
},
},
inspect_dom_object: {
description: `Inspect a Premiere Pro DOM object and list its properties, methods, and values. Useful for exploring the API and debugging.
Examples:
- "app.project" → project properties
- "app.project.activeSequence" → sequence properties
- "app.project.activeSequence.videoTracks[0].clips[0]" → first clip on V1
- "app.project.activeSequence.videoTracks[0].clips[0].components[0]" → first component of a clip`,
parameters: {
type: "object" as const,
properties: {
object_path: {
type: "string",
description: "Dot-path to the DOM object to inspect (e.g., 'app.project.activeSequence')",
},
max_depth: {
type: "number",
description: "Max depth for nested inspection (default: 1, max: 3)",
},
},
required: ["object_path"],
},
handler: async (args: { object_path: string; max_depth?: number }) => {
const maxDepth = Math.min(args.max_depth ?? 1, 3);
const script = buildToolScript(`
function inspectObj(obj, depth, maxD) {
if (depth > maxD) return "<max depth>";
if (obj === null) return null;
if (obj === undefined) return undefined;
var t = typeof obj;
if (t === "string" || t === "number" || t === "boolean") return obj;
var result = {};
var propCount = 0;
try {
for (var key in obj) {
if (propCount > 50) {
result["__truncated"] = true;
break;
}
try {
var val = obj[key];
var vt = typeof val;
if (vt === "function") {
result[key] = "[function]";
} else if (vt === "object" && val !== null) {
if (depth < maxD) {
result[key] = inspectObj(val, depth + 1, maxD);
} else {
result[key] = "[object]";
}
} else {
result[key] = val;
}
} catch(e) {
result[key] = "[error: " + e.toString() + "]";
}
propCount++;
}
} catch(e) {
return { __error: e.toString() };
}
// Also try common collection properties
try { if (obj.numItems !== undefined) result["numItems"] = obj.numItems; } catch(e) {}
try { if (obj.numTracks !== undefined) result["numTracks"] = obj.numTracks; } catch(e) {}
try { if (obj.numSequences !== undefined) result["numSequences"] = obj.numSequences; } catch(e) {}
try { if (obj.length !== undefined) result["length"] = obj.length; } catch(e) {}
try { if (obj.name !== undefined) result["name"] = obj.name; } catch(e) {}
try { if (obj.displayName !== undefined) result["displayName"] = obj.displayName; } catch(e) {}
try { if (obj.matchName !== undefined) result["matchName"] = obj.matchName; } catch(e) {}
try { if (obj.nodeId !== undefined) result["nodeId"] = obj.nodeId; } catch(e) {}
return result;
}
try {
var obj = ${args.object_path};
if (obj === null || obj === undefined) return __error("Object is null or undefined: ${args.object_path}");
var inspection = inspectObj(obj, 0, ${maxDepth});
return __result({
path: "${args.object_path}",
type: typeof obj,
inspection: inspection
});
} catch(e) {
return __error("Cannot access: ${args.object_path} — " + e.toString());
}
`);
return sendRawCommand(script, bridgeOptions);
},
},
list_clip_effects: {
description: "List all effects/components on a clip with their properties and current values. Essential for debugging effect issues.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to inspect",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
var components = [];
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
var props = [];
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
var info = {
index: p,
displayName: prop.displayName,
matchName: ""
};
try { info.matchName = prop.matchName; } catch(e) {}
try { info.value = prop.getValue(0, 0); } catch(e) {}
try { info.isTimeVarying = prop.isTimeVarying(); } catch(e) {}
try { info.keyframesSupported = prop.areKeyframesSupported(); } catch(e) {}
props.push(info);
}
components.push({
index: i,
displayName: comp.displayName,
matchName: comp.matchName,
properties: props
});
}
return __result({
clipName: clip.name,
nodeId: clip.nodeId,
trackType: result.trackType,
trackIndex: result.trackIndex,
componentCount: components.length,
components: components
});
`);
return sendCommand(script, bridgeOptions);
},
},
get_sequence_structure: {
description: "Get a complete structural overview of the active sequence: all tracks, all clips with positions, gaps, and clip metadata. Essential for understanding timeline state before making edits.",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID. Uses active sequence if omitted.",
},
},
},
handler: async (args: { sequence_id?: string }) => {
const seqLookup = args.sequence_id
? `var seq = __findSequence("${args.sequence_id}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const script = buildToolScript(`
${seqLookup}
var videoTracks = [];
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
var clips = [];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
var clipInfo = {
index: c,
nodeId: clip.nodeId,
name: clip.name,
startSeconds: __ticksToSeconds(clip.start.ticks),
endSeconds: __ticksToSeconds(clip.end.ticks),
durationSeconds: __ticksToSeconds(clip.duration.ticks),
inPointSeconds: __ticksToSeconds(clip.inPoint.ticks),
outPointSeconds: __ticksToSeconds(clip.outPoint.ticks),
mediaType: clip.mediaType,
enabled: true
};
try { clipInfo.enabled = !clip.isDisabled(); } catch(e) {}
try { clipInfo.speed = clip.getSpeed(); } catch(e) {}
clips.push(clipInfo);
}
videoTracks.push({
index: t,
name: track.name,
clipCount: clips.length,
clips: clips,
isMuted: track.isMuted(),
isLocked: track.isLocked()
});
}
var audioTracks = [];
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
var clips = [];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
clips.push({
index: c,
nodeId: clip.nodeId,
name: clip.name,
startSeconds: __ticksToSeconds(clip.start.ticks),
endSeconds: __ticksToSeconds(clip.end.ticks),
durationSeconds: __ticksToSeconds(clip.duration.ticks),
inPointSeconds: __ticksToSeconds(clip.inPoint.ticks),
outPointSeconds: __ticksToSeconds(clip.outPoint.ticks),
mediaType: clip.mediaType
});
}
audioTracks.push({
index: t,
name: track.name,
clipCount: clips.length,
clips: clips,
isMuted: track.isMuted(),
isLocked: track.isLocked()
});
}
return __result({
name: seq.name,
id: seq.sequenceID,
durationSeconds: __ticksToSeconds(seq.end),
videoTrackCount: videoTracks.length,
audioTrackCount: audioTracks.length,
videoTracks: videoTracks,
audioTracks: audioTracks
});
`);
return sendCommand(script, bridgeOptions);
},
},
get_premiere_state: {
description: "Get a comprehensive snapshot of the current Premiere Pro state: project info, active sequence, playhead position, selected clips, and available sequences. The best first call to understand the current context.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var state = {};
// Project info
var project = app.project;
if (!project) return __error("No project is open");
state.project = {
name: project.name,
path: project.path,
rootItemCount: project.rootItem.children.numItems,
sequenceCount: project.sequences.numSequences
};
// List all sequences
state.sequences = [];
for (var i = 0; i < project.sequences.numSequences; i++) {
var seq = project.sequences[i];
state.sequences.push({
name: seq.name,
id: seq.sequenceID,
duration: __ticksToSeconds(seq.end)
});
}
// Active sequence
var active = project.activeSequence;
if (active) {
state.activeSequence = {
name: active.name,
id: active.sequenceID,
durationSeconds: __ticksToSeconds(active.end),
videoTrackCount: active.videoTracks.numTracks,
audioTrackCount: active.audioTracks.numTracks
};
// Playhead
try {
var playerPos = active.getPlayerPosition();
state.playheadSeconds = __ticksToSeconds(playerPos.ticks);
} catch(e) {}
// Selected clips
try {
var sel = active.getSelection();
state.selectedClips = [];
for (var i = 0; i < sel.length; i++) {
state.selectedClips.push({
nodeId: sel[i].nodeId,
name: sel[i].name
});
}
} catch(e) {
state.selectedClips = [];
}
// Quick clip count
var totalClips = 0;
for (var t = 0; t < active.videoTracks.numTracks; t++) {
totalClips += active.videoTracks[t].clips.numItems;
}
for (var t = 0; t < active.audioTracks.numTracks; t++) {
totalClips += active.audioTracks[t].clips.numItems;
}
state.activeSequence.totalClipCount = totalClips;
} else {
state.activeSequence = null;
}
// Premiere version
try { state.version = app.version; } catch(e) {}
try { state.buildNumber = app.build; } catch(e) {}
return __result(state);
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+340
View File
@@ -0,0 +1,340 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getSelectionTools(bridgeOptions: BridgeOptions) {
return {
select_clips_by_name: {
description: "Select all clips in the active sequence that match a name (substring match). Optionally filter by track type and index.",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Clip name to search for (case-insensitive substring match)",
},
track_type: {
type: "string",
enum: ["video", "audio", "both"],
description: "Track type to search (default: both)",
},
track_index: {
type: "number",
description: "Specific track index to search (optional, searches all if omitted)",
},
add_to_selection: {
type: "boolean",
description: "If true, add to existing selection instead of replacing it (default: false)",
},
},
required: ["name"],
},
handler: async (args: { name: string; track_type?: string; track_index?: number; add_to_selection?: boolean }) => {
const trackType = args.track_type || "both";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var query = "${escapeForExtendScript(args.name)}".toLowerCase();
var addToSel = ${args.add_to_selection ? "true" : "false"};
var count = 0;
function selectInTracks(tracks, type) {
for (var t = 0; t < tracks.numTracks; t++) {
${args.track_index !== undefined ? `if (t !== ${args.track_index}) continue;` : ""}
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
if (clip.name.toLowerCase().indexOf(query) !== -1) {
clip.setSelected(true, true);
count++;
} else if (!addToSel) {
clip.setSelected(false, true);
}
}
}
}
if (!addToSel) {
// Deselect all first
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
for (var c = 0; c < track.clips.numItems; c++) track.clips[c].setSelected(false, true);
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
for (var c = 0; c < track.clips.numItems; c++) track.clips[c].setSelected(false, true);
}
}
if ("${trackType}" !== "audio") selectInTracks(seq.videoTracks, "video");
if ("${trackType}" !== "video") selectInTracks(seq.audioTracks, "audio");
return __result({ selected: count, query: "${escapeForExtendScript(args.name)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
select_all_clips: {
description: "Select all clips in the active sequence, or all clips on a specific track.",
parameters: {
type: "object" as const,
properties: {
track_type: {
type: "string",
enum: ["video", "audio", "both"],
description: "Track type (default: both)",
},
track_index: {
type: "number",
description: "Specific track index (optional, selects all tracks if omitted)",
},
},
},
handler: async (args: { track_type?: string; track_index?: number }) => {
const trackType = args.track_type || "both";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var count = 0;
function selectAll(tracks) {
for (var t = 0; t < tracks.numTracks; t++) {
${args.track_index !== undefined ? `if (t !== ${args.track_index}) continue;` : ""}
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
track.clips[c].setSelected(true, true);
count++;
}
}
}
if ("${trackType}" !== "audio") selectAll(seq.videoTracks);
if ("${trackType}" !== "video") selectAll(seq.audioTracks);
return __result({ selected: count });
`);
return sendCommand(script, bridgeOptions);
},
},
deselect_all_clips: {
description: "Deselect all clips in the active sequence.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var count = 0;
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
var track = seq.videoTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
track.clips[c].setSelected(false, true);
count++;
}
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
var track = seq.audioTracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
track.clips[c].setSelected(false, true);
count++;
}
}
return __result({ deselected: count });
`);
return sendCommand(script, bridgeOptions);
},
},
select_clips_in_range: {
description: "Select all clips that overlap a time range in the active sequence.",
parameters: {
type: "object" as const,
properties: {
start_seconds: {
type: "number",
description: "Start of selection range in seconds",
},
end_seconds: {
type: "number",
description: "End of selection range in seconds",
},
track_type: {
type: "string",
enum: ["video", "audio", "both"],
description: "Track type (default: both)",
},
track_index: {
type: "number",
description: "Specific track index (optional)",
},
},
required: ["start_seconds", "end_seconds"],
},
handler: async (args: { start_seconds: number; end_seconds: number; track_type?: string; track_index?: number }) => {
const trackType = args.track_type || "both";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var startTicks = __secondsToTicks(${args.start_seconds});
var endTicks = __secondsToTicks(${args.end_seconds});
var count = 0;
// Deselect all first
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
for (var c = 0; c < seq.videoTracks[t].clips.numItems; c++) seq.videoTracks[t].clips[c].setSelected(false, true);
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
for (var c = 0; c < seq.audioTracks[t].clips.numItems; c++) seq.audioTracks[t].clips[c].setSelected(false, true);
}
function selectInRange(tracks) {
for (var t = 0; t < tracks.numTracks; t++) {
${args.track_index !== undefined ? `if (t !== ${args.track_index}) continue;` : ""}
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
var cs = parseFloat(clip.start.ticks);
var ce = parseFloat(clip.end.ticks);
if (cs < endTicks && ce > startTicks) {
clip.setSelected(true, true);
count++;
}
}
}
}
if ("${trackType}" !== "audio") selectInRange(seq.videoTracks);
if ("${trackType}" !== "video") selectInRange(seq.audioTracks);
return __result({ selected: count, rangeStart: ${args.start_seconds}, rangeEnd: ${args.end_seconds} });
`);
return sendCommand(script, bridgeOptions);
},
},
select_clips_by_color: {
description: "Select all clips whose source project item has a specific color label.",
parameters: {
type: "object" as const,
properties: {
color_index: {
type: "number",
description: "Label color index (0=Violet, 1=Iris, 2=Caribbean, 3=Lavender, 4=Cerulean, 5=Forest, 6=Rose, 7=Mango, 8=Purple, 9=Blue, 10=Teal, 11=Magenta, 12=Tan, 13=Green, 14=Brown, 15=Yellow)",
},
},
required: ["color_index"],
},
handler: async (args: { color_index: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
// Deselect all
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
for (var c = 0; c < seq.videoTracks[t].clips.numItems; c++) seq.videoTracks[t].clips[c].setSelected(false, true);
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
for (var c = 0; c < seq.audioTracks[t].clips.numItems; c++) seq.audioTracks[t].clips[c].setSelected(false, true);
}
var count = 0;
function scan(tracks) {
for (var t = 0; t < tracks.numTracks; t++) {
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
try {
if (clip.projectItem && clip.projectItem.getColorLabel() === ${args.color_index}) {
clip.setSelected(true, true);
count++;
}
} catch(e) {}
}
}
}
scan(seq.videoTracks);
scan(seq.audioTracks);
return __result({ selected: count, colorIndex: ${args.color_index} });
`);
return sendCommand(script, bridgeOptions);
},
},
invert_selection: {
description: "Invert the current clip selection in the active sequence (selected become deselected and vice versa).",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var selected = 0;
var deselected = 0;
function invert(tracks) {
for (var t = 0; t < tracks.numTracks; t++) {
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
var clip = track.clips[c];
if (clip.isSelected()) {
clip.setSelected(false, true);
deselected++;
} else {
clip.setSelected(true, true);
selected++;
}
}
}
}
invert(seq.videoTracks);
invert(seq.audioTracks);
return __result({ nowSelected: selected, nowDeselected: deselected });
`);
return sendCommand(script, bridgeOptions);
},
},
select_disabled_clips: {
description: "Select all disabled clips in the active sequence.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
// Deselect all
for (var t = 0; t < seq.videoTracks.numTracks; t++) {
for (var c = 0; c < seq.videoTracks[t].clips.numItems; c++) seq.videoTracks[t].clips[c].setSelected(false, true);
}
for (var t = 0; t < seq.audioTracks.numTracks; t++) {
for (var c = 0; c < seq.audioTracks[t].clips.numItems; c++) seq.audioTracks[t].clips[c].setSelected(false, true);
}
var count = 0;
function scan(tracks) {
for (var t = 0; t < tracks.numTracks; t++) {
var track = tracks[t];
for (var c = 0; c < track.clips.numItems; c++) {
try {
if (track.clips[c].isDisabled()) {
track.clips[c].setSelected(true, true);
count++;
}
} catch(e) {}
}
}
}
scan(seq.videoTracks);
scan(seq.audioTracks);
return __result({ selected: count });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+585
View File
@@ -0,0 +1,585 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* Find a default .sqpreset on this machine for create_sequence without preset_path.
*
* Every sequence-creation route that lets Premiere pick settings interactively is
* unusable from scripting: app.project.createNewSequence(name, id) opens the modal
* New Sequence dialog in Premiere 26 (verified on 26.2.2 — even with a UUID id),
* which freezes the shared ExtendScript engine until a human dismisses it. So the
* no-preset path must still resolve to a concrete preset file.
*/
let cachedDefaultPreset: string | null | undefined;
export function findDefaultSequencePreset(): string | null {
if (cachedDefaultPreset !== undefined) return cachedDefaultPreset;
if (process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET && existsSync(process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET)) {
return (cachedDefaultPreset = process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET);
}
const roots: string[] = [];
const appDirs =
process.platform === "darwin"
? { base: "/Applications", match: /^Adobe Premiere Pro/ }
: { base: "C:\\Program Files\\Adobe", match: /^Adobe Premiere Pro/ };
try {
for (const dir of readdirSync(appDirs.base)) {
if (!appDirs.match.test(dir)) continue;
const appRoot = join(appDirs.base, dir);
if (process.platform === "darwin") {
for (const inner of readdirSync(appRoot)) {
if (inner.endsWith(".app")) roots.push(join(appRoot, inner, "Contents", "Settings", "SequencePresets"));
}
} else {
roots.push(join(appRoot, "Settings", "SequencePresets"));
}
}
} catch {
/* fall through */
}
const found: string[] = [];
const walk = (dir: string, depth: number) => {
if (depth > 3 || !existsSync(dir)) return;
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
try {
if (statSync(p).isDirectory()) walk(p, depth + 1);
else if (entry.endsWith(".sqpreset")) found.push(p);
} catch {
/* skip unreadable */
}
}
};
for (const root of roots.sort().reverse()) walk(root, 0); // newest app version first
// Prefer a plain HD/UHD progressive preset; otherwise take anything.
const preferred =
found.find((p) => /UHD \(4K\) 2160p 25 fps\.sqpreset$/.test(p)) ||
found.find((p) => /2160p 25|1080p 25/.test(p)) ||
found.find((p) => /2160p|1080p/.test(p)) ||
found[0] ||
null;
return (cachedDefaultPreset = preferred);
}
export function getSequenceTools(bridgeOptions: BridgeOptions) {
return {
create_sequence: {
description: "Create a new sequence in the project",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name for the new sequence",
},
preset_path: {
type: "string",
description:
"Optional path to a sequence preset file (.sqpreset). If omitted, a default preset is discovered from the Premiere installation (override with PREMIERE_DEFAULT_SEQUENCE_PRESET).",
},
},
required: ["name"],
},
handler: async (args: { name: string; preset_path?: string }) => {
// app.project.createNewSequenceFromPreset does not exist in Premiere Pro
// (verified missing in 26.x), and createNewSequence(name, id) opens the
// modal New Sequence dialog there — every creation goes through the QE DOM
// with an explicit preset. See findDefaultSequencePreset().
const presetPath = args.preset_path || findDefaultSequencePreset();
if (!presetPath) {
return {
success: false,
error:
"No sequence preset found. Pass preset_path (an .sqpreset file) or set PREMIERE_DEFAULT_SEQUENCE_PRESET — " +
"creating a sequence without a preset opens a modal dialog in Premiere 26+, which would freeze scripting.",
};
}
const script = buildToolScript(`
var beforeSequenceIds = {};
for (var i = 0; i < app.project.sequences.numSequences; i++) {
beforeSequenceIds[String(app.project.sequences[i].sequenceID)] = true;
}
app.enableQE();
qe.project.newSequence("${escapeForExtendScript(args.name)}", "${escapeForExtendScript(presetPath)}");
var seq = app.project.activeSequence;
if (!seq || seq.name !== "${escapeForExtendScript(args.name)}") {
return __error("Failed to create sequence from preset: ${escapeForExtendScript(presetPath)}");
}
var sequenceId = String(seq.sequenceID);
if (beforeSequenceIds[sequenceId]) {
return __error("Premiere did not create a new sequence; the active sequence already existed before the preset request.");
}
var created = __findSequence(sequenceId);
if (!created || String(created.sequenceID) !== sequenceId) {
return __error("Premiere did not add the new sequence to the project collection; no creation success is reported.");
}
return __result({ created: true, verified: true, name: created.name, id: sequenceId, presetUsed: "${escapeForExtendScript(presetPath)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
duplicate_sequence: {
description: "Duplicate an existing sequence",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID to duplicate",
},
},
required: ["sequence_id"],
},
handler: async (args: { sequence_id: string }) => {
const script = buildToolScript(`
var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}");
if (!seq) return __error("Sequence not found: ${escapeForExtendScript(args.sequence_id)}");
seq.clone();
return __result({ duplicated: true, originalName: seq.name });
`);
return sendCommand(script, bridgeOptions);
},
},
delete_sequence: {
description: "Delete a sequence from the project",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID to delete",
},
},
required: ["sequence_id"],
},
handler: async (args: { sequence_id: string }) => {
const script = buildToolScript(`
var project = app.project;
var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}");
if (!seq) return __error("Sequence not found: ${escapeForExtendScript(args.sequence_id)}");
var sequenceId = String(seq.sequenceID);
var name = seq.name;
var accepted = project.deleteSequence(seq);
if (accepted === false) return __error("Premiere rejected deletion of sequence: " + name);
if (__findSequence(sequenceId)) {
return __error("Premiere did not remove sequence: " + name + ". The deletion is not reported as successful.");
}
return __result({ deleted: true, verified: true, name: name, id: sequenceId });
`);
return sendCommand(script, bridgeOptions);
},
},
set_sequence_settings: {
description: "Modify and read back sequence frame-size settings.",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID. Uses active sequence if omitted.",
},
width: {
type: "number",
description: "Frame width in pixels",
},
height: {
type: "number",
description: "Frame height in pixels",
},
},
},
handler: async (args: { sequence_id?: string; width?: number; height?: number }) => {
if (args.width === undefined && args.height === undefined) {
return { success: false, error: "Pass width, height, or both" };
}
for (const [name, value] of Object.entries({ width: args.width, height: args.height })) {
if (value !== undefined && (!Number.isInteger(value) || value < 1 || value > 16384)) {
return { success: false, error: `${name} must be an integer from 1 through 16384` };
}
}
const seqLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const script = buildToolScript(`
${seqLookup}
var settings = seq.getSettings();
if (!settings) return __error("Could not get sequence settings");
${args.width === undefined ? "" : `settings.videoFrameWidth = ${args.width};`}
${args.height === undefined ? "" : `settings.videoFrameHeight = ${args.height};`}
var accepted = seq.setSettings(settings);
if (accepted === false) return __error("Premiere rejected the requested sequence settings");
var applied = seq.getSettings();
if (!applied) return __error("Premiere did not return sequence settings after the update");
var appliedWidth = Number(applied.videoFrameWidth);
var appliedHeight = Number(applied.videoFrameHeight);
${args.width === undefined ? "" : `if (appliedWidth !== ${args.width}) return __error("Premiere did not apply the requested frame width: expected ${args.width}, got " + appliedWidth);`}
${args.height === undefined ? "" : `if (appliedHeight !== ${args.height}) return __error("Premiere did not apply the requested frame height: expected ${args.height}, got " + appliedHeight);`}
return __result({ updated: true, verified: true, name: seq.name, width: appliedWidth, height: appliedHeight });
`);
return sendCommand(script, bridgeOptions);
},
},
create_subsequence: {
description:
"Create a separate subsequence from selected clips or a time range. This Premiere API does not replace the original timeline clips with a nested-sequence reference.",
parameters: {
type: "object" as const,
properties: {
ignore_track_targeting: {
type: "boolean",
description: "Whether to ignore track targeting (default: false)",
},
},
},
handler: async (args: { ignore_track_targeting?: boolean }) => {
const script = buildToolScript(`
var seq = __getCurrentActiveSequence();
if (!seq) return __error("No active sequence");
var before = [];
for (var i = 0; i < app.project.sequences.numSequences; i++) {
before.push(String(app.project.sequences[i].sequenceID));
}
var newSeq = seq.createSubsequence(${args.ignore_track_targeting ? "true" : "false"});
if (!newSeq) return __error("Failed to create subsequence");
var newId = String(newSeq.sequenceID);
var exists = false;
for (var j = 0; j < app.project.sequences.numSequences; j++) {
if (String(app.project.sequences[j].sequenceID) === newId) {
exists = true;
break;
}
}
if (!exists || before.indexOf(newId) !== -1) {
return __error("Premiere did not expose a newly created subsequence in the current project");
}
return __result({
created: true,
verified: true,
nested: false,
name: newSeq.name,
id: newSeq.sequenceID,
note: "The source timeline selection is intentionally unchanged; use Premiere's Nest command for replacement behavior."
});
`);
return sendCommand(script, bridgeOptions);
},
},
auto_reframe_sequence: {
description: "Auto-reframe a sequence for a different aspect ratio",
parameters: {
type: "object" as const,
properties: {
sequence_id: {
type: "string",
description: "Sequence name or ID to reframe. Uses active sequence if omitted.",
},
target_width: {
type: "number",
description: "Target frame width in pixels",
},
target_height: {
type: "number",
description: "Target frame height in pixels",
},
motion_preset: {
type: "string",
enum: ["slower", "default", "faster"],
description: "Premiere Auto Reframe motion preset (default: default)",
},
new_name: {
type: "string",
description: "Name for the newly created auto-reframed sequence",
},
use_nested_sequences: {
type: "boolean",
description: "Whether Auto Reframe should honor nested sequences (default: false)",
},
},
required: ["target_width", "target_height"],
},
handler: async (args: {
sequence_id?: string;
target_width: number;
target_height: number;
motion_preset?: "slower" | "default" | "faster";
new_name?: string;
use_nested_sequences?: boolean;
}) => {
if (!Number.isInteger(args.target_width) || !Number.isInteger(args.target_height)
|| args.target_width < 1 || args.target_height < 1) {
return { success: false, error: "target_width and target_height must be positive integers" };
}
let a = args.target_width;
let b = args.target_height;
while (b !== 0) [a, b] = [b, a % b];
const numerator = args.target_width / a;
const denominator = args.target_height / a;
const motionPreset = args.motion_preset ?? "default";
const requestedName = args.new_name?.trim();
const seqLookup = args.sequence_id
? `var seq = __findSequence("${escapeForExtendScript(args.sequence_id)}"); if (!seq) return __error("Sequence not found");`
: `var seq = app.project.activeSequence; if (!seq) return __error("No active sequence");`;
const script = buildToolScript(`
${seqLookup}
var newName = "${escapeForExtendScript(requestedName || "")}" || (seq.name + " - Auto Reframe ${numerator}x${denominator}");
var reframed = seq.autoReframeSequence(${numerator}, ${denominator}, "${motionPreset}", newName, ${args.use_nested_sequences === true});
if (!reframed) return __error("Premiere did not create an auto-reframed sequence");
return __result({
reframed: true,
sourceName: seq.name,
name: reframed.name,
id: reframed.sequenceID,
requestedAspectRatio: "${numerator}:${denominator}",
observedWidth: reframed.frameSizeHorizontal,
observedHeight: reframed.frameSizeVertical
});
`);
return sendCommand(script, bridgeOptions);
},
},
unnest_sequence: {
description: "Unnest a nested sequence on the timeline, replacing it with the contents of the nested sequence",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the nested sequence clip on the timeline to unnest",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string }) => {
const script = buildToolScript(`
var seq = __getCurrentActiveSequence();
if (!seq) return __error("No active sequence");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
var clipName = clip.name;
var projectItem = clip.projectItem;
if (!projectItem) return __error("Cannot find project item for this clip");
// Check if the project item is a sequence (type 3 = sequence)
// For nested sequences, the projectItem should reference another sequence
var nestedSeq = null;
for (var i = 0; i < app.project.sequences.numSequences; i++) {
var s = app.project.sequences[i];
if (s.name === projectItem.name || s.sequenceID === projectItem.nodeId) {
nestedSeq = s;
break;
}
}
if (!nestedSeq) return __error("Clip is not a nested sequence: " + clipName);
var startTicks = String(clip.start.ticks);
var endTicks = String(clip.end.ticks);
var nestedProjectItemId = String(projectItem.nodeId);
var linkedReferences = [];
function collectLinkedReferences(tracks, mediaType) {
for (var trackIndex = 0; trackIndex < tracks.numTracks; trackIndex++) {
var track = tracks[trackIndex];
for (var clipIndex = 0; clipIndex < track.clips.numItems; clipIndex++) {
var candidate = track.clips[clipIndex];
try {
if (candidate.projectItem &&
String(candidate.projectItem.nodeId) === nestedProjectItemId &&
String(candidate.start.ticks) === startTicks &&
String(candidate.end.ticks) === endTicks) {
linkedReferences.push({ clip: candidate, trackIndex: trackIndex, mediaType: mediaType });
}
} catch (e) {}
}
}
}
collectLinkedReferences(seq.videoTracks, "video");
collectLinkedReferences(seq.audioTracks, "audio");
if (linkedReferences.length !== 1) {
return __error(
"Legacy CEP cannot atomically unnest linked video and audio references. " +
"No clips were changed; use Premiere's Unnest command to preserve linked tracks."
);
}
var reference = linkedReferences[0];
var tracks = reference.mediaType === "video" ? nestedSeq.videoTracks : nestedSeq.audioTracks;
var targetTracks = reference.mediaType === "video" ? seq.videoTracks : seq.audioTracks;
var planned = [];
var expectedByTrack = [];
for (var t = 0; t < tracks.numTracks; t++) {
var targetTrackIndex = reference.trackIndex + t;
if (targetTrackIndex >= targetTracks.numTracks) {
return __error("Cannot unnest safely because the destination " + reference.mediaType + " track " + targetTrackIndex + " does not exist. No clips were changed.");
}
expectedByTrack[t] = 0;
for (var c = 0; c < tracks[t].clips.numItems; c++) {
var nestedClip = tracks[t].clips[c];
if (!nestedClip.projectItem) {
return __error("Cannot unnest safely because a nested " + reference.mediaType + " clip has no project item. No clips were changed.");
}
planned.push({
projectItem: nestedClip.projectItem,
insertTime: (parseFloat(startTicks) + parseFloat(nestedClip.start.ticks)).toString(),
sourceTrackIndex: t,
name: nestedClip.name
});
expectedByTrack[t]++;
}
}
if (planned.length === 0) return __error("Nested sequence has no " + reference.mediaType + " clips to unnest. No clips were changed.");
var beforeCounts = [];
for (var b = 0; b < tracks.numTracks; b++) {
beforeCounts[b] = targetTracks[reference.trackIndex + b].clips.numItems;
}
// The existing implementation only changed one lane of a linked clip.
// This route runs only when preflight has proven there is exactly one
// unlinked reference, so it cannot report a partial A/V unnest as success.
reference.clip.remove(false, false);
var addedClips = [];
for (var p = 0; p < planned.length; p++) {
var placement = planned[p];
var targetTrack = targetTracks[reference.trackIndex + placement.sourceTrackIndex];
targetTrack.insertClip(placement.projectItem, placement.insertTime);
addedClips.push(placement.name);
}
for (var a = 0; a < tracks.numTracks; a++) {
var actualCount = targetTracks[reference.trackIndex + a].clips.numItems;
if (actualCount !== beforeCounts[a] - (a === 0 ? 1 : 0) + expectedByTrack[a]) {
return __error("Premiere did not place every nested " + reference.mediaType + " clip. Inspect the timeline before retrying.");
}
}
return __result({
unnested: true,
verified: true,
nestedSequence: clipName,
mediaType: reference.mediaType,
clipsAdded: addedClips.length,
clips: addedClips
});
`);
return sendCommand(script, bridgeOptions);
},
},
create_sequence_from_preset: {
description: "Create a new sequence from a specific preset file (.sqpreset)",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name for the new sequence",
},
preset_path: {
type: "string",
description: "Full path to the .sqpreset file",
},
},
required: ["name", "preset_path"],
},
handler: async (args: { name: string; preset_path: string }) => {
// createNewSequenceFromPreset is not a real API (missing in 26.x) — use QE.
const script = buildToolScript(`
app.enableQE();
qe.project.newSequence("${escapeForExtendScript(args.name)}", "${escapeForExtendScript(args.preset_path)}");
var seq = app.project.activeSequence;
if (!seq || seq.name !== "${escapeForExtendScript(args.name)}") {
return __error("Failed to create sequence from preset: ${escapeForExtendScript(args.preset_path)}");
}
return __result({ created: true, name: seq.name, id: seq.sequenceID });
`);
return sendCommand(script, bridgeOptions);
},
},
attach_custom_property: {
description: "Attach a custom property (key/value pair) to the active sequence",
parameters: {
type: "object" as const,
properties: {
property_id: {
type: "string",
description: "Unique identifier for the custom property",
},
property_value: {
type: "string",
description: "Value for the custom property",
},
},
required: ["property_id", "property_value"],
},
handler: async (args: { property_id: string; property_value: string }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
seq.attachCustomProperty("${escapeForExtendScript(args.property_id)}", "${escapeForExtendScript(args.property_value)}");
return __result({ attached: true, propertyId: "${escapeForExtendScript(args.property_id)}", value: "${escapeForExtendScript(args.property_value)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
is_work_area_enabled: {
description: "Check whether the work area bar is enabled on the active sequence",
parameters: {
type: "object" as const,
properties: {},
},
handler: async () => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var enabled = seq.isWorkAreaEnabled();
return __result({ sequenceName: seq.name, workAreaEnabled: enabled });
`);
return sendCommand(script, bridgeOptions);
},
},
get_export_file_extension: {
description: "Get the file extension that would be used when exporting the active sequence with a given preset",
parameters: {
type: "object" as const,
properties: {
preset_path: {
type: "string",
description: "Full path to the export preset file (.epr)",
},
},
required: ["preset_path"],
},
handler: async (args: { preset_path: string }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var ext = seq.getExportFileExtension("${escapeForExtendScript(args.preset_path)}");
return __result({ sequenceName: seq.name, presetPath: "${escapeForExtendScript(args.preset_path)}", extension: ext });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+71
View File
@@ -0,0 +1,71 @@
export type SilenceRange = { startSeconds: number; endSeconds: number };
export type FrameRange = { startFrame: number; endFrame: number };
export type SilenceRemovalPlan = {
frameRate: number;
totalFrames: number;
removedFrames: number;
keptFrames: number;
removalRanges: FrameRange[];
keepRanges: FrameRange[];
};
function finite(value: number, label: string): number {
if (!Number.isFinite(value)) throw new Error(`${label} must be finite`);
return value;
}
export function planDerivedSilenceRemoval(input: {
durationSeconds: number;
frameRate: number;
silenceRanges: SilenceRange[];
keepHandleFrames?: number;
maximumRemovals?: number;
}): SilenceRemovalPlan {
const duration = finite(input.durationSeconds, "durationSeconds");
const frameRate = finite(input.frameRate, "frameRate");
if (duration <= 0 || frameRate <= 0) throw new Error("durationSeconds and frameRate must be positive");
if (!Array.isArray(input.silenceRanges)) throw new Error("silenceRanges must be an array");
const keepHandleFrames = input.keepHandleFrames ?? 0;
const maximumRemovals = input.maximumRemovals ?? 256;
if (!Number.isInteger(keepHandleFrames) || keepHandleFrames < 0 || keepHandleFrames > 2400) throw new Error("keepHandleFrames must be an integer between 0 and 2400");
if (!Number.isInteger(maximumRemovals) || maximumRemovals < 1 || maximumRemovals > 512) throw new Error("maximumRemovals must be an integer between 1 and 512");
const totalFrames = Math.floor(duration * frameRate + 1e-7);
if (totalFrames < 1) throw new Error("media duration is shorter than one frame");
const snappedSilenceRanges: FrameRange[] = [];
for (let index = 0; index < input.silenceRanges.length; index += 1) {
const range = input.silenceRanges[index];
const start = finite(range?.startSeconds, `silenceRanges[${index}].startSeconds`);
const end = finite(range?.endSeconds, `silenceRanges[${index}].endSeconds`);
if (start < 0 || end <= start || end > duration + 1e-7) throw new Error(`silenceRanges[${index}] is outside the media duration`);
const startFrame = Math.max(0, Math.ceil(start * frameRate - 1e-7));
const endFrame = Math.min(totalFrames, Math.floor(end * frameRate + 1e-7));
if (endFrame > startFrame) snappedSilenceRanges.push({ startFrame, endFrame });
}
snappedSilenceRanges.sort((left, right) => left.startFrame - right.startFrame || left.endFrame - right.endFrame);
const mergedSilenceRanges: FrameRange[] = [];
for (const candidate of snappedSilenceRanges) {
const previous = mergedSilenceRanges.at(-1);
if (previous && candidate.startFrame <= previous.endFrame) previous.endFrame = Math.max(previous.endFrame, candidate.endFrame);
else mergedSilenceRanges.push({ ...candidate });
}
const removalRanges: FrameRange[] = [];
for (const silence of mergedSilenceRanges) {
const startFrame = silence.startFrame + keepHandleFrames;
const endFrame = silence.endFrame - keepHandleFrames;
if (endFrame > startFrame) removalRanges.push({ startFrame, endFrame });
}
if (removalRanges.length > maximumRemovals) throw new Error(`silence plan exceeds maximumRemovals (${maximumRemovals})`);
const keepRanges: FrameRange[] = [];
let cursor = 0;
for (const removal of removalRanges) {
if (removal.startFrame > cursor) keepRanges.push({ startFrame: cursor, endFrame: removal.startFrame });
cursor = removal.endFrame;
}
if (cursor < totalFrames) keepRanges.push({ startFrame: cursor, endFrame: totalFrames });
if (!keepRanges.length) throw new Error("silence plan would remove the entire source");
const removedFrames = removalRanges.reduce((sum, range) => sum + range.endFrame - range.startFrame, 0);
return { frameRate, totalFrames, removedFrames, keptFrames: totalFrames - removedFrames, removalRanges, keepRanges };
}
+186
View File
@@ -0,0 +1,186 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getSourceMonitorTools(bridgeOptions: BridgeOptions) {
return {
open_in_source: {
description: "Open a project item in the Source Monitor for preview and trimming.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item to open",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string }) => {
const script = buildToolScript(`
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Project item not found");
app.sourceMonitor.openProjectItem(item);
return __result({ opened: true, item: item.name });
`);
return sendCommand(script, bridgeOptions);
},
},
close_source_monitor: {
description: "Close the clip currently open in the Source Monitor.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.sourceMonitor.closeClip();
return __result({ closed: true });
`);
return sendCommand(script, bridgeOptions);
},
},
close_all_source_clips: {
description: "Close all clips in the Source Monitor.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.sourceMonitor.closeAllClips();
return __result({ closed: true });
`);
return sendCommand(script, bridgeOptions);
},
},
set_source_in_out: {
description: "Set in and/or out points on the clip currently open in the Source Monitor.",
parameters: {
type: "object" as const,
properties: {
in_seconds: {
type: "number",
description: "In point in seconds (optional)",
},
out_seconds: {
type: "number",
description: "Out point in seconds (optional)",
},
},
},
handler: async (args: { in_seconds?: number; out_seconds?: number }) => {
const script = buildToolScript(`
var item = app.sourceMonitor.getProjectItem();
if (!item) return __error("No clip open in Source Monitor");
${args.in_seconds !== undefined ? `
var inTime = new Time();
inTime.seconds = ${args.in_seconds};
item.setInPoint(inTime.ticks, 4);
` : ""}
${args.out_seconds !== undefined ? `
var outTime = new Time();
outTime.seconds = ${args.out_seconds};
item.setOutPoint(outTime.ticks, 4);
` : ""}
return __result({
item: item.name,
inSet: ${args.in_seconds !== undefined},
outSet: ${args.out_seconds !== undefined}
});
`);
return sendCommand(script, bridgeOptions);
},
},
insert_from_source: {
description: "Insert the clip from the Source Monitor at the playhead position (insert edit — shifts existing clips).",
parameters: {
type: "object" as const,
properties: {
video_track_index: {
type: "number",
description: "Target video track index (default: 0)",
},
audio_track_index: {
type: "number",
description: "Target audio track index (default: 0)",
},
},
},
handler: async (args: { video_track_index?: number; audio_track_index?: number }) => {
const vTrack = args.video_track_index ?? 0;
const aTrack = args.audio_track_index ?? 0;
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var item = app.sourceMonitor.getProjectItem();
if (!item) return __error("No clip open in Source Monitor");
var pos = seq.getPlayerPosition().ticks;
seq.insertClip(item, pos, ${vTrack}, ${aTrack});
return __result({ inserted: true, item: item.name, atSeconds: __ticksToSeconds(pos) });
`);
return sendCommand(script, bridgeOptions);
},
},
overwrite_from_source: {
description: "Overwrite the clip from the Source Monitor at the playhead position (overwrite edit — replaces existing clips).",
parameters: {
type: "object" as const,
properties: {
video_track_index: {
type: "number",
description: "Target video track index (default: 0)",
},
audio_track_index: {
type: "number",
description: "Target audio track index (default: 0)",
},
},
},
handler: async (args: { video_track_index?: number; audio_track_index?: number }) => {
const vTrack = args.video_track_index ?? 0;
const aTrack = args.audio_track_index ?? 0;
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var item = app.sourceMonitor.getProjectItem();
if (!item) return __error("No clip open in Source Monitor");
var pos = seq.getPlayerPosition().ticks;
seq.overwriteClip(item, pos, ${vTrack}, ${aTrack});
return __result({ overwritten: true, item: item.name, atSeconds: __ticksToSeconds(pos) });
`);
return sendCommand(script, bridgeOptions);
},
},
get_source_monitor_info: {
description: "Get information about the clip currently loaded in the Source Monitor.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var item = app.sourceMonitor.getProjectItem();
if (!item) return __result({ loaded: false });
var info = {
loaded: true,
nodeId: item.nodeId,
name: item.name
};
try { info.mediaPath = item.getMediaPath(); } catch(e) {}
try { info.inPoint = __ticksToSeconds(item.getInPoint().ticks); } catch(e) {}
try { info.outPoint = __ticksToSeconds(item.getOutPoint().ticks); } catch(e) {}
return __result(info);
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+601
View File
@@ -0,0 +1,601 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import path from "node:path";
import { BridgeOptions, sendCommand } from "../bridge/file-bridge.js";
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import {
AuditSink,
CapabilityConfig,
createOperationId,
emitAudit,
requireCapability,
resolveCapabilities,
stderrAuditSink,
} from "../security/index.js";
type WorkflowKind = "motion_graphics_demo" | "product_spot" | "brand_spot";
type MotionStyle = "none" | "push_in" | "pull_out" | "alternate" | "demo";
interface TransitionSpec {
name: string;
duration_seconds: number;
}
interface MogrtSpec {
path: string;
approved_workspace_path: string;
title_track_index: number;
title_start_seconds: number;
}
export interface SpotWorkflowPlan {
schema_version: 1;
workflow: WorkflowKind;
sequence_id: string;
asset_item_ids: string[];
clip_duration_seconds: number;
video_track_index: number;
audio_track_index: number;
motion_style: MotionStyle;
transition?: TransitionSpec;
mogrt?: MogrtSpec;
}
export interface SpotWorkflowDependencies {
capabilities?: CapabilityConfig;
auditSink?: AuditSink;
operationIdFactory?: () => string;
fileExists?: (value: string) => boolean;
}
const MAX_ASSETS = 12;
const MAX_TEXT = 512;
const MAX_PATH = 4096;
function text(value: unknown, field: string, maxLength = MAX_TEXT): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
const normalized = value.trim();
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function optionalText(value: unknown, field: string, maxLength = MAX_TEXT): string | undefined {
return value === undefined ? undefined : text(value, field, maxLength);
}
function positiveNumber(value: unknown, field: string, fallback: number, maximum: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > maximum) {
throw new Error(`${field} must be a finite number greater than 0 and at most ${maximum}`);
}
return value;
}
function nonNegativeNumber(value: unknown, field: string, fallback: number, maximum: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > maximum) {
throw new Error(`${field} must be a finite number between 0 and ${maximum}`);
}
return value;
}
function trackIndex(value: unknown, field: string, fallback: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 32) {
throw new Error(`${field} must be an integer between 0 and 32`);
}
return value;
}
function assetItemIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ASSETS) {
throw new Error(`asset_item_ids must contain between 1 and ${MAX_ASSETS} project item IDs`);
}
return value.map((item, index) => text(item, `asset_item_ids[${index}]`, 256));
}
function motionStyle(value: unknown, fallback: MotionStyle): MotionStyle {
if (value === undefined) return fallback;
if (value === "none" || value === "push_in" || value === "pull_out" || value === "alternate") return value;
throw new Error("motion_style must be one of none, push_in, pull_out, or alternate");
}
function transition(value: unknown, duration: unknown, fallbackName: string, fallbackDuration: number): TransitionSpec | undefined {
const name = optionalText(value, "transition_name", 128) ?? fallbackName;
if (name.toLowerCase() === "none") return undefined;
return {
name,
duration_seconds: positiveNumber(duration, "transition_duration_seconds", fallbackDuration, 10),
};
}
function isAbsolutePortable(value: string): boolean {
return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
}
function isWithinApprovedWorkspace(approvedWorkspacePath: string, candidatePath: string): boolean {
// Plans can be created on Windows and later inspected on macOS (or vice versa),
// so do not let the runner's host OS reinterpret a Windows absolute path as a
// relative POSIX path. Mixed path families are not contained by definition.
const windowsPaths = path.win32.isAbsolute(approvedWorkspacePath) && path.win32.isAbsolute(candidatePath);
const posixPaths = path.posix.isAbsolute(approvedWorkspacePath) && path.posix.isAbsolute(candidatePath);
if (!windowsPaths && !posixPaths) return false;
const pathApi = windowsPaths ? path.win32 : path.posix;
const root = pathApi.resolve(approvedWorkspacePath);
const candidate = pathApi.resolve(candidatePath);
const relative = pathApi.relative(root, candidate);
return relative !== "" && relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative);
}
function mogrt(value: Record<string, unknown>, videoTrackIndex: number): MogrtSpec | undefined {
const mogrtPath = optionalText(value.mogrt_path, "mogrt_path", MAX_PATH);
if (!mogrtPath) return undefined;
if (!/\.mogrt$/i.test(mogrtPath)) throw new Error("mogrt_path must point to a .mogrt file");
const approvedWorkspacePath = text(value.approved_workspace_path, "approved_workspace_path", MAX_PATH);
if (!isAbsolutePortable(mogrtPath) || !isAbsolutePortable(approvedWorkspacePath)) {
throw new Error("mogrt_path and approved_workspace_path must be absolute paths");
}
if (!isWithinApprovedWorkspace(approvedWorkspacePath, mogrtPath)) {
throw new Error("mogrt_path must be within approved_workspace_path");
}
const titleTrackIndex = trackIndex(value.title_track_index, "title_track_index", 1);
if (titleTrackIndex === videoTrackIndex) {
throw new Error("title_track_index must differ from video_track_index so the branding overlay remains separate from the edit");
}
return {
path: mogrtPath,
approved_workspace_path: approvedWorkspacePath,
title_track_index: titleTrackIndex,
title_start_seconds: nonNegativeNumber(value.title_start_seconds, "title_start_seconds", 0.4, 36000),
};
}
function rawObject(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object`);
return value as Record<string, unknown>;
}
function assertOnlyKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
for (const key of Object.keys(value)) {
if (!keys.includes(key)) throw new Error(`${field} contains unsupported field: ${key}`);
}
}
function buildPlan(workflow: WorkflowKind, value: unknown): SpotWorkflowPlan {
const input = rawObject(value, "arguments");
const baseKeys = [
"sequence_id", "asset_item_ids", "clip_duration_seconds", "video_track_index", "audio_track_index",
"motion_style", "transition_name", "transition_duration_seconds",
];
const brandKeys = [...baseKeys, "mogrt_path", "approved_workspace_path", "title_track_index", "title_start_seconds"];
assertOnlyKeys(input, workflow === "brand_spot" ? brandKeys : baseKeys, "arguments");
const videoTrackIndex = trackIndex(input.video_track_index, "video_track_index", 0);
const plan: SpotWorkflowPlan = {
schema_version: 1,
workflow,
sequence_id: text(input.sequence_id, "sequence_id", 256),
asset_item_ids: assetItemIds(input.asset_item_ids),
clip_duration_seconds: positiveNumber(input.clip_duration_seconds, "clip_duration_seconds", workflow === "motion_graphics_demo" ? 5 : 4, 300),
video_track_index: videoTrackIndex,
audio_track_index: trackIndex(input.audio_track_index, "audio_track_index", 0),
motion_style: workflow === "motion_graphics_demo" ? "demo" : motionStyle(input.motion_style, "alternate"),
transition: transition(input.transition_name, input.transition_duration_seconds, "Cross Dissolve", workflow === "motion_graphics_demo" ? 0.75 : 0.5),
};
if (workflow === "brand_spot") {
const overlay = mogrt(input, videoTrackIndex);
if (overlay) plan.mogrt = overlay;
}
return plan;
}
/** Reconstruct a bounded, canonical plan before deriving its confirmation token. */
export function validateSpotWorkflowPlan(value: unknown): SpotWorkflowPlan {
const raw = rawObject(value, "plan");
assertOnlyKeys(raw, [
"schema_version", "workflow", "sequence_id", "asset_item_ids", "clip_duration_seconds", "video_track_index",
"audio_track_index", "motion_style", "transition", "mogrt",
], "plan");
if (raw.schema_version !== 1) throw new Error("plan.schema_version must be 1");
if (raw.workflow !== "motion_graphics_demo" && raw.workflow !== "product_spot" && raw.workflow !== "brand_spot") {
throw new Error("plan.workflow is unsupported");
}
const workflow = raw.workflow;
const input: Record<string, unknown> = {
sequence_id: raw.sequence_id,
asset_item_ids: raw.asset_item_ids,
clip_duration_seconds: raw.clip_duration_seconds,
video_track_index: raw.video_track_index,
audio_track_index: raw.audio_track_index,
};
if (workflow !== "motion_graphics_demo") input.motion_style = raw.motion_style;
else if (raw.motion_style !== "demo") throw new Error("motion_graphics_demo plans must use demo motion_style");
if (raw.transition !== undefined) {
const rawTransition = rawObject(raw.transition, "plan.transition");
assertOnlyKeys(rawTransition, ["name", "duration_seconds"], "plan.transition");
input.transition_name = rawTransition.name;
input.transition_duration_seconds = rawTransition.duration_seconds;
} else {
input.transition_name = "none";
}
if (raw.mogrt !== undefined) {
if (workflow !== "brand_spot") throw new Error("only brand_spot plans may include mogrt");
const rawMogrt = rawObject(raw.mogrt, "plan.mogrt");
assertOnlyKeys(rawMogrt, ["path", "approved_workspace_path", "title_track_index", "title_start_seconds"], "plan.mogrt");
input.mogrt_path = rawMogrt.path;
input.approved_workspace_path = rawMogrt.approved_workspace_path;
input.title_track_index = rawMogrt.title_track_index;
input.title_start_seconds = rawMogrt.title_start_seconds;
}
return buildPlan(workflow, input);
}
export function spotWorkflowConfirmationToken(plan: SpotWorkflowPlan): string {
return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
}
function changes(plan: SpotWorkflowPlan) {
const placementChanges = plan.asset_item_ids.map((itemId, index) => ({
type: "insert_clip" as const,
project_item_id: itemId,
start_seconds: index * plan.clip_duration_seconds,
video_track_index: plan.video_track_index,
audio_track_index: plan.audio_track_index,
}));
return {
target: { sequence_id: plan.sequence_id, requires_empty_target_tracks: true },
placements: placementChanges,
transition: plan.transition
? { ...plan.transition, applies_only_to_observed_adjacent_cuts: true }
: { skipped: true, reason: "No transition was requested" },
motion: plan.motion_style === "none"
? { skipped: true }
: { style: plan.motion_style, parameter_readback_only: true },
mogrt: plan.mogrt
? { title_track_index: plan.mogrt.title_track_index, start_seconds: plan.mogrt.title_start_seconds, workspace_contained: true }
: { skipped: true },
};
}
function quotedArray(values: readonly string[]): string {
return `[${values.map((value) => `"${escapeForExtendScript(value)}"`).join(",")}]`;
}
function buildApplyScript(plan: SpotWorkflowPlan): string {
const sequenceId = escapeForExtendScript(plan.sequence_id);
const itemIds = quotedArray(plan.asset_item_ids);
const transitionName = plan.transition ? `"${escapeForExtendScript(plan.transition.name)}"` : "null";
const transitionDuration = plan.transition?.duration_seconds ?? 0;
const mogrtScript = plan.mogrt
? `
var titleTrack = seq.videoTracks[${plan.mogrt.title_track_index}];
if (!titleTrack) return __error("Title video track index ${plan.mogrt.title_track_index} is out of range");
if (titleTrack.clips.numItems !== 0) return __error("Title video track must be empty before applying this brand-spot plan");
`
: "";
const mogrtApplyScript = plan.mogrt
? `
var mogrtCountBefore = titleTrack.clips.numItems;
var mogrtImportResult = null;
try {
mogrtImportResult = seq.importMGT("${escapeForExtendScript(plan.mogrt.path)}", __secondsToTicks(${plan.mogrt.title_start_seconds}).toString(), ${plan.mogrt.title_track_index}, ${plan.audio_track_index});
} catch (mogrtError) {
mogrtVerification = { applied: false, verified: false, error: String(mogrtError) };
}
if (!mogrtVerification) {
var mogrtCountAfter = titleTrack.clips.numItems;
mogrtVerification = mogrtCountAfter > mogrtCountBefore
? { applied: true, verified: true, insertedTrackItems: mogrtCountAfter - mogrtCountBefore }
: { applied: false, verified: false, error: "Premiere returned from importMGT without adding a title-track item" };
}
`
: 'var mogrtVerification = { skipped: true, reason: "No MOGRT was requested" };';
return buildToolScript(`
var seq = __findSequence("${sequenceId}");
if (!seq) return __error("Target sequence was not found");
if (!app.project.activeSequence || String(app.project.activeSequence.sequenceID) !== String(seq.sequenceID)) {
return __error("Target sequence must be active before applying a spot workflow plan; activate it and preview again");
}
var videoTrack = seq.videoTracks[${plan.video_track_index}];
if (!videoTrack) return __error("Video track index ${plan.video_track_index} is out of range");
var audioTrack = seq.audioTracks[${plan.audio_track_index}];
if (!audioTrack) return __error("Audio track index ${plan.audio_track_index} is out of range");
if (videoTrack.clips.numItems !== 0 || audioTrack.clips.numItems !== 0) {
return __error("Target video and audio tracks must be empty before applying a spot workflow plan");
}
${mogrtScript}
var requestedItemIds = ${itemIds};
var requestedItems = [];
for (var requestIndex = 0; requestIndex < requestedItemIds.length; requestIndex++) {
var requestedItem = __findProjectItem(requestedItemIds[requestIndex]);
if (!requestedItem || String(requestedItem.nodeId) !== String(requestedItemIds[requestIndex])) {
return __error("Project item ID was not found exactly: " + requestedItemIds[requestIndex]);
}
requestedItems.push(requestedItem);
}
var transitionName = ${transitionName};
var transitionDuration = ${transitionDuration};
var transitionReady = false;
var qeTrack = null;
var transitionQE = null;
var transitionPreflight = { requested: !!transitionName, ready: false };
if (transitionName) {
try {
app.enableQE();
var qeSequence = qe.project.getActiveSequence();
qeTrack = qeSequence ? qeSequence.getVideoTrackAt(${plan.video_track_index}) : null;
if (qeTrack && qe.project.getVideoTransitionByName) {
transitionQE = qe.project.getVideoTransitionByName(transitionName);
}
transitionReady = !!qeTrack && !!transitionQE;
transitionPreflight = transitionReady
? { requested: true, ready: true }
: { requested: true, ready: false, reason: "The active Premiere build did not expose the requested transition or QE video track" };
} catch (transitionProbeError) {
transitionPreflight = { requested: true, ready: false, reason: String(transitionProbeError) };
}
}
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var frameTolerance = __ticksToSeconds(frameTicks) + 0.000001;
var placed = [];
var usedNodeIds = {};
function findPlacedClip(track, itemId, expectedStart) {
for (var clipIndex = 0; clipIndex < track.clips.numItems; clipIndex++) {
var candidate = track.clips[clipIndex];
if (!candidate.projectItem || String(candidate.projectItem.nodeId) !== String(itemId) || usedNodeIds[String(candidate.nodeId)]) continue;
if (Math.abs(__ticksToSeconds(candidate.start.ticks) - expectedStart) <= frameTolerance) return candidate;
}
return null;
}
function trimPlacedClip(clip, targetEnd) {
var requestedEnd = new Time(); requestedEnd.ticks = __secondsToTicks(targetEnd).toString();
clip.end = requestedEnd;
return Math.abs(__ticksToSeconds(clip.end.ticks) - targetEnd) <= frameTolerance;
}
for (var placementIndex = 0; placementIndex < requestedItems.length; placementIndex++) {
var targetStart = placementIndex * ${plan.clip_duration_seconds};
var targetEnd = targetStart + ${plan.clip_duration_seconds};
var audioCountBefore = audioTrack.clips.numItems;
seq.insertClip(requestedItems[placementIndex], __secondsToTicks(targetStart).toString(), ${plan.video_track_index}, ${plan.audio_track_index});
var placedClip = findPlacedClip(videoTrack, requestedItemIds[placementIndex], targetStart);
if (!placedClip) return __error("Premiere did not add the requested video item at the planned frame; the assembly is not reported as verified");
if (!trimPlacedClip(placedClip, targetEnd)) return __error("Premiere did not trim the placed video item to the previewed duration; the assembly is not reported as verified");
var audioCountAfter = audioTrack.clips.numItems;
if (audioCountAfter > audioCountBefore) {
if (audioCountAfter !== audioCountBefore + 1) return __error("Premiere added an unexpected number of audio items; the assembly is not reported as verified");
var placedAudio = findPlacedClip(audioTrack, requestedItemIds[placementIndex], targetStart);
if (!placedAudio || !trimPlacedClip(placedAudio, targetEnd)) return __error("Premiere did not identify and trim the placed audio item to the previewed duration; the assembly is not reported as verified");
}
usedNodeIds[String(placedClip.nodeId)] = true;
placed.push({
nodeId: String(placedClip.nodeId),
projectItemId: requestedItemIds[placementIndex],
startSeconds: __ticksToSeconds(placedClip.start.ticks),
endSeconds: __ticksToSeconds(placedClip.end.ticks),
requestedDurationSeconds: ${plan.clip_duration_seconds},
verified: true
});
}
function scaleRange(style, index) {
if (style === "push_in") return { from: 100, to: 108 };
if (style === "pull_out") return { from: 108, to: 100 };
if (style === "demo") {
if (index === 1) return { from: 112, to: 100 };
return { from: 100, to: index === 2 ? 106 : 108 };
}
return index % 2 === 1 ? { from: 110, to: 100 } : { from: 100, to: 108 };
}
function addScaleMotion(clip, index) {
if ("${plan.motion_style}" === "none") return { skipped: true };
var motionComponent = null;
for (var componentIndex = 0; componentIndex < clip.components.numItems; componentIndex++) {
var component = clip.components[componentIndex];
if (component.displayName === "Motion" || component.matchName === "AE.ADBE Motion") { motionComponent = component; break; }
}
if (!motionComponent) return { applied: false, verified: false, reason: "Motion component was not available on the placed clip" };
var scaleProperty = null;
for (var propertyIndex = 0; propertyIndex < motionComponent.properties.numItems; propertyIndex++) {
var property = motionComponent.properties[propertyIndex];
if (property.displayName === "Scale") { scaleProperty = property; break; }
}
if (!scaleProperty) return { applied: false, verified: false, reason: "Motion Scale property was not available on the placed clip" };
var start = __ticksToSeconds(clip.start.ticks);
var end = __ticksToSeconds(clip.end.ticks) - 0.1;
if (end <= start) return { applied: false, verified: false, reason: "Placed clip is too short for the requested scale motion" };
var range = scaleRange("${plan.motion_style}", index);
try {
if (!scaleProperty.isTimeVarying()) scaleProperty.setTimeVarying(true);
var startTime = new Time(); startTime.ticks = __secondsToTicks(start).toString();
var endTime = new Time(); endTime.ticks = __secondsToTicks(end).toString();
scaleProperty.addKey(startTime); scaleProperty.setValueAtKey(startTime, range.from, true);
scaleProperty.addKey(endTime); scaleProperty.setValueAtKey(endTime, range.to, true);
var readStart = scaleProperty.getValueAtKey(startTime);
var readEnd = scaleProperty.getValueAtKey(endTime);
if (typeof readStart === "number" && Math.abs(readStart - range.from) > 0.0001) throw new Error("start keyframe readback differed");
if (typeof readEnd === "number" && Math.abs(readEnd - range.to) > 0.0001) throw new Error("end keyframe readback differed");
return { applied: true, verified: true, startSeconds: start, endSeconds: end, from: range.from, to: range.to };
} catch (motionError) {
return { applied: false, verified: false, reason: String(motionError) };
}
}
var motionResults = [];
for (var motionIndex = 0; motionIndex < placed.length; motionIndex++) {
var verifiedClip = __findClip(placed[motionIndex].nodeId);
motionResults.push(verifiedClip ? addScaleMotion(verifiedClip.clip, motionIndex) : { applied: false, verified: false, reason: "Placed clip could not be re-resolved for motion" });
}
var transitionResults = [];
for (var cutIndex = 0; cutIndex < placed.length - 1; cutIndex++) {
if (!transitionName) { transitionResults.push({ skipped: true, reason: "No transition was requested" }); continue; }
if (!transitionReady) { transitionResults.push({ skipped: true, reason: transitionPreflight.reason || "Transition API unavailable" }); continue; }
if (Math.abs(placed[cutIndex].endSeconds - placed[cutIndex + 1].startSeconds) > frameTolerance) {
transitionResults.push({ skipped: true, reason: "Placed clips were not adjacent, so no transition was added" }); continue;
}
var transitionCountBefore = videoTrack.transitions.numItems;
try {
var placedInfo = __findClip(placed[cutIndex + 1].nodeId);
var qeClip = placedInfo ? __findQeClipByDomClip(qeTrack, placedInfo.clip) : null;
if (!qeClip || typeof qeClip.addTransition !== "function") {
transitionResults.push({ applied: false, verified: false, reason: "The target QE clip did not expose addTransition" });
continue;
}
var durationFrames = Math.max(1, Math.round(__secondsToTicks(transitionDuration) / frameTicks));
qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true);
var transitionCountAfter = videoTrack.transitions.numItems;
var expectedCutTicks = __secondsToTicks(placed[cutIndex + 1].startSeconds);
var foundAtExpectedCut = false;
for (var transitionIndex = 0; transitionIndex < videoTrack.transitions.numItems; transitionIndex++) {
var transitionReadback = videoTrack.transitions[transitionIndex];
var transitionStartTicks = parseFloat(transitionReadback.start.ticks);
var transitionEndTicks = parseFloat(transitionReadback.end.ticks);
if (!isNaN(transitionStartTicks) && !isNaN(transitionEndTicks) && Math.abs(((transitionStartTicks + transitionEndTicks) / 2) - expectedCutTicks) <= frameTicks / 2) { foundAtExpectedCut = true; break; }
}
transitionResults.push(transitionCountAfter > transitionCountBefore && foundAtExpectedCut
? { applied: true, verified: true, atSeconds: placed[cutIndex].endSeconds }
: { applied: false, verified: false, reason: transitionCountAfter > transitionCountBefore ? "Premiere added a transition, but not at the requested cut" : "Premiere did not add a transition to the track" });
} catch (transitionError) {
transitionResults.push({ applied: false, verified: false, reason: String(transitionError) });
}
}
${mogrtApplyScript}
return __result({
applied: true,
workflow: "${plan.workflow}",
targetSequenceId: String(seq.sequenceID),
placements: placed,
transitions: transitionResults,
transitionPreflight: transitionPreflight,
motion: motionResults,
mogrt: mogrtVerification,
renderVerified: false,
verificationScope: "Placement, transition-count, MOGRT track-count, and Motion keyframe readback only. Inspect the resulting sequence and verify playback or an exported frame before delivery."
});
`);
}
function previewResult(plan: SpotWorkflowPlan, operationId: string) {
return {
success: true,
data: {
operationId,
applied: false,
plan,
changes: changes(plan),
confirmationToken: spotWorkflowConfirmationToken(plan),
verificationScope: "This is a local plan preview. It does not inspect Premiere, prove assets exist, or make project changes.",
},
};
}
const sharedProperties = {
sequence_id: { type: "string", description: "Exact ID of an existing empty destination sequence. It must be active at apply time." },
asset_item_ids: { type: "array", description: "Exact existing project-item node IDs in playback order; external media paths are deliberately not imported by this workflow.", items: { type: "string" } },
clip_duration_seconds: { type: "number", description: "Planned spacing between asset starts; defaults to 5 seconds for a motion demo and 4 seconds for spots." },
video_track_index: { type: "number", description: "Empty destination video track index, default 0." },
audio_track_index: { type: "number", description: "Empty destination audio track index, default 0." },
motion_style: { type: "string", enum: ["none", "push_in", "pull_out", "alternate"], description: "Scale-motion style for product and brand spots; defaults to alternate." },
transition_name: { type: "string", description: "Transition name; defaults to Cross Dissolve. Use none to disable transitions." },
transition_duration_seconds: { type: "number", description: "Transition duration, default 0.75 seconds for demos and 0.5 seconds for spots." },
};
const planParameter = { type: "object", description: "Exact plan returned by a preview_*_spot tool" };
export function getSpotWorkflowTools(bridgeOptions: BridgeOptions, dependencies: SpotWorkflowDependencies = {}) {
const capabilities = dependencies.capabilities ?? resolveCapabilities();
const auditSink = dependencies.auditSink ?? stderrAuditSink;
const nextId = dependencies.operationIdFactory ?? createOperationId;
const fileExists = dependencies.fileExists ?? existsSync;
const preview = (workflow: WorkflowKind) => async (args: Record<string, unknown>) => {
const operationId = nextId();
requireCapability(capabilities, "inspect", operationId);
return previewResult(buildPlan(workflow, args), operationId);
};
return {
preview_motion_graphics_demo: {
description: "Preview a contained motion-graphics demo assembly from existing project items. It never creates demo assets, imports files, creates a sequence, or changes Premiere; apply requires an exact confirmation token.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
sequence_id: sharedProperties.sequence_id,
asset_item_ids: sharedProperties.asset_item_ids,
clip_duration_seconds: sharedProperties.clip_duration_seconds,
video_track_index: sharedProperties.video_track_index,
audio_track_index: sharedProperties.audio_track_index,
transition_name: sharedProperties.transition_name,
transition_duration_seconds: sharedProperties.transition_duration_seconds,
},
required: ["sequence_id", "asset_item_ids"],
},
handler: preview("motion_graphics_demo"),
},
preview_product_spot: {
description: "Preview a product-spot assembly from existing project items. The eventual apply is limited to explicit empty tracks, revalidates item IDs, and reports host readback without claiming visual delivery verification.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: sharedProperties,
required: ["sequence_id", "asset_item_ids"],
},
handler: preview("product_spot"),
},
preview_brand_spot: {
description: "Preview a brand-spot assembly from existing project items with an optional workspace-contained MOGRT overlay. Preview is local-only; it does not read or import the MOGRT file.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
...sharedProperties,
mogrt_path: { type: "string", description: "Optional absolute .mogrt path, which must be inside approved_workspace_path." },
approved_workspace_path: { type: "string", description: "Operator-approved root containing mogrt_path; required whenever mogrt_path is supplied." },
title_track_index: { type: "number", description: "Empty title-overlay video track index, default 1 and distinct from video_track_index." },
title_start_seconds: { type: "number", description: "MOGRT start time in seconds, default 0.4." },
},
required: ["sequence_id", "asset_item_ids"],
},
handler: preview("brand_spot"),
},
apply_spot_workflow_plan: {
description: "Apply one exact previewed motion-demo, product-spot, or brand-spot plan. Requires edit authority, requires filesystem authority for a MOGRT, and only targets empty explicitly named tracks. Host readback is not playback or render verification.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
plan: planParameter,
confirmation_token: { type: "string", description: "Exact token returned by the corresponding preview tool." },
},
required: ["plan", "confirmation_token"],
},
handler: async (args: { plan: unknown; confirmation_token: string }) => {
const operationId = nextId();
try {
requireCapability(capabilities, "edit", operationId);
const plan = validateSpotWorkflowPlan(args.plan);
if (args.confirmation_token !== spotWorkflowConfirmationToken(plan)) {
throw new Error("Confirmation token does not match this spot workflow plan; preview it again");
}
if (plan.mogrt) {
requireCapability(capabilities, "filesystem", operationId);
if (!fileExists(plan.mogrt.path)) {
return { success: false, error: "The previewed MOGRT file no longer exists; preview the plan again after correcting it" };
}
}
emitAudit(auditSink, { operationId, action: "apply_spot_workflow_plan", outcome: "started", details: { workflow: plan.workflow, placements: plan.asset_item_ids.length, hasMogrt: !!plan.mogrt } });
const result = await sendCommand(buildApplyScript(plan), bridgeOptions);
emitAudit(auditSink, { operationId, action: "apply_spot_workflow_plan", outcome: result.success ? "succeeded" : "failed" });
return result.success
? { ...result, data: { ...(result.data as object), operationId } }
: { ...result, error: `${result.error ?? "Spot workflow plan failed"} (operation ${operationId})` };
} catch (error) {
emitAudit(auditSink, { operationId, action: "apply_spot_workflow_plan", outcome: error instanceof Error && error.name === "CapabilityDeniedError" ? "denied" : "failed" });
throw error;
}
},
},
};
}
+173
View File
@@ -0,0 +1,173 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getTextTools(bridgeOptions: BridgeOptions) {
return {
add_text_overlay: {
description:
"Unavailable: Premiere does not expose a supported scripting API to create caption clips directly from raw text. " +
"Import an .srt/.vtt and use create_caption_track, or use a MOGRT/PNG overlay for title graphics.",
parameters: {
type: "object" as const,
properties: {
text: {
type: "string",
description: "Text content to display",
},
start_seconds: {
type: "number",
description: "Start time in seconds (default: 0)",
},
duration_seconds: {
type: "number",
description: "Duration in seconds (default: 5)",
},
caption_format: {
type: "string",
enum: ["subtitle", "608", "708", "teletext"],
description: "Caption format (default: subtitle)",
},
},
required: ["text"],
},
handler: async (args: {
text: string;
start_seconds?: number;
duration_seconds?: number;
caption_format?: string;
}) => {
void args;
return {
success: false,
error:
"Premiere does not expose a supported scripting API to create a caption clip from raw text. No mutation was attempted. Import an .srt or .vtt first, then use create_caption_track; use a MOGRT or pre-rendered PNG overlay for title graphics.",
};
},
},
import_mogrt: {
description: "Import a Motion Graphics Template (.mogrt) file and add it to the timeline",
parameters: {
type: "object" as const,
properties: {
mogrt_path: {
type: "string",
description: "Full path to the .mogrt file",
},
track_index: {
type: "number",
description: "Video track index (default: 0)",
},
start_seconds: {
type: "number",
description: "Start time in seconds (default: 0)",
},
duration_seconds: {
type: "number",
description: "Duration in seconds (default: 5)",
},
},
required: ["mogrt_path"],
},
handler: async (args: {
mogrt_path: string;
track_index?: number;
start_seconds?: number;
duration_seconds?: number;
}) => {
const trackIndex = args.track_index ?? 0;
const startSeconds = args.start_seconds ?? 0;
const durationSeconds = args.duration_seconds ?? 5;
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var mogrtPath = "${escapeForExtendScript(args.mogrt_path)}";
var startTicks = __secondsToTicks(${startSeconds}).toString();
var durationTicks = __secondsToTicks(${durationSeconds}).toString();
var success = seq.importMGT(
mogrtPath,
startTicks,
${trackIndex},
${trackIndex} // audio track index
);
if (!success) return __error("Failed to import MOGRT");
return __result({
imported: true,
mogrtPath: mogrtPath,
trackIndex: ${trackIndex},
startSeconds: ${startSeconds},
durationSeconds: ${durationSeconds}
});
`);
return sendCommand(script, bridgeOptions);
},
},
import_mogrt_from_library: {
description: "Import a MOGRT from a named Adobe Creative Cloud Library.",
parameters: {
type: "object" as const,
properties: {
library_name: {
type: "string",
description: "Name of the Adobe Creative Cloud Library that contains the MOGRT",
},
mogrt_name: {
type: "string",
description: "Name of the MOGRT in the library",
},
track_index: {
type: "number",
description: "Video track index (default: 0)",
},
start_seconds: {
type: "number",
description: "Start time in seconds (default: 0)",
},
},
required: ["library_name", "mogrt_name"],
},
handler: async (args: {
library_name: string;
mogrt_name: string;
track_index?: number;
start_seconds?: number;
}) => {
const trackIndex = args.track_index ?? 0;
const startSeconds = args.start_seconds ?? 0;
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var libraryName = "${escapeForExtendScript(args.library_name)}";
var mogrtName = "${escapeForExtendScript(args.mogrt_name)}";
var startTicks = __secondsToTicks(${startSeconds}).toString();
var success = seq.importMGTFromLibrary(
libraryName,
mogrtName,
startTicks,
${trackIndex},
${trackIndex}
);
if (!success) return __error("Failed to import MOGRT from library: " + mogrtName);
return __result({
imported: true,
libraryName: libraryName,
mogrtName: mogrtName,
trackIndex: ${trackIndex},
startSeconds: ${startSeconds}
});
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+894
View File
@@ -0,0 +1,894 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getTimelineTools(bridgeOptions: BridgeOptions) {
return {
add_to_timeline: {
description:
"Insert a project item at a timeline position and verify Premiere added no unexpected same-track fragments.",
parameters: {
type: "object" as const,
properties: {
item_id: {
type: "string",
description: "Node ID or name of the project item to add",
},
track_index: {
type: "number",
description: "Video track index (0-based, default: 0)",
},
start_seconds: {
type: "number",
description: "Start time in seconds on the timeline (default: 0)",
},
audio_track_index: {
type: "number",
description: "Audio track index for the audio portion (default: 0)",
},
},
required: ["item_id"],
},
handler: async (args: { item_id: string; track_index?: number; start_seconds?: number; audio_track_index?: number }) => {
const trackIndex = args.track_index ?? 0;
const startSeconds = args.start_seconds ?? 0;
const audioTrackIndex = args.audio_track_index ?? 0;
if (!Number.isInteger(trackIndex) || trackIndex < 0 ||
!Number.isInteger(audioTrackIndex) || audioTrackIndex < 0 ||
!Number.isFinite(startSeconds) || startSeconds < 0) {
return {
success: false,
error: "track_index and audio_track_index must be non-negative integers, and start_seconds must be a finite non-negative number.",
};
}
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var videoTrack = seq.videoTracks[${trackIndex}];
if (!videoTrack) return __error("Video track index ${trackIndex} is out of range");
var audioTrack = seq.audioTracks[${audioTrackIndex}];
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
if (!item) return __error("Project item not found: ${escapeForExtendScript(args.item_id)}");
var beforeVideoCount = videoTrack.clips.numItems;
var beforeAudioCount = audioTrack ? audioTrack.clips.numItems : 0;
var beforeVideoIds = {};
var beforeAudioIds = {};
var i;
for (i = 0; i < beforeVideoCount; i++) beforeVideoIds[videoTrack.clips[i].nodeId] = true;
if (audioTrack) {
for (i = 0; i < beforeAudioCount; i++) beforeAudioIds[audioTrack.clips[i].nodeId] = true;
}
var startTicks = __secondsToTicks(${startSeconds}).toString();
seq.insertClip(item, startTicks, ${trackIndex}, ${audioTrackIndex});
var afterVideoCount = videoTrack.clips.numItems;
var afterAudioCount = audioTrack ? audioTrack.clips.numItems : 0;
if (afterVideoCount > beforeVideoCount + 1 || (audioTrack && afterAudioCount > beforeAudioCount + 1)) {
return __error("Premiere inserted more than one clip on a targeted track. This can leave a residual frame fragment at an exact boundary; the insertion may be partial, but is not reported as verified.");
}
var inserted = [];
for (i = 0; i < afterVideoCount; i++) {
var videoClip = videoTrack.clips[i];
if (!beforeVideoIds[videoClip.nodeId]) inserted.push({ clip: videoClip, trackType: "video" });
}
if (audioTrack) {
for (i = 0; i < afterAudioCount; i++) {
var audioClip = audioTrack.clips[i];
if (!beforeAudioIds[audioClip.nodeId]) inserted.push({ clip: audioClip, trackType: "audio" });
}
}
if (!inserted.length) {
return __error("Premiere did not add a new track item at the requested insertion point.");
}
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var tolerance = __ticksToSeconds(frameTicks);
var matchedItem = false;
for (i = 0; i < inserted.length; i++) {
var insertedClip = inserted[i].clip;
if (insertedClip.projectItem && insertedClip.projectItem.nodeId === item.nodeId) {
matchedItem = true;
if (Math.abs(__ticksToSeconds(insertedClip.start.ticks) - ${startSeconds}) > tolerance) {
return __error("Premiere added the requested item but not at the requested timeline frame; the insertion is not reported as verified.");
}
}
}
if (!matchedItem) {
return __error("Premiere changed the target track but the requested project item was not found after insertion.");
}
return __result({
added: true,
verified: true,
item: item.name,
trackIndex: ${trackIndex},
startSeconds: ${startSeconds},
insertedTrackItems: inserted.length
});
`);
return sendCommand(script, bridgeOptions);
},
},
remove_from_timeline: {
description: "Remove a clip from the timeline",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to remove",
},
ripple: {
type: "boolean",
description: "Whether to ripple delete (close the gap). Default: false",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string; ripple?: boolean }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
clip.remove(${args.ripple ? "true" : "false"}, ${args.ripple ? "true" : "false"});
return __result({ removed: true, clipName: clip.name });
`);
return sendCommand(script, bridgeOptions);
},
},
move_clip: {
description: "Move a clip to a new position on the timeline",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to move",
},
new_start_seconds: {
type: "number",
description: "New start time in seconds",
},
new_track_index: {
type: "number",
description: "Optional new track index to move the clip to",
},
},
required: ["node_id", "new_start_seconds"],
},
handler: async (args: { node_id: string; new_start_seconds: number; new_track_index?: number }) => {
const nodeId = escapeForExtendScript(args.node_id);
const script = buildToolScript(`
var result = __findClip("${nodeId}");
if (!result) return __error("Clip not found: ${nodeId}");
var clip = result.clip;
var clipName = clip.name;
// Premiere snaps clip positions to frame boundaries, so verification
// allows one frame of drift. seq.timebase is ticks-per-frame; fall
// back to 24fps if it cannot be read so we never compare against NaN.
var seq = app.project.activeSequence;
var frameTicks = seq && seq.timebase ? parseFloat(seq.timebase) : NaN;
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var tolerance = __ticksToSeconds(frameTicks);
${args.new_track_index !== undefined ? `
// The track change is attempted before the start time is written, so
// a failure here leaves the clip completely untouched rather than
// repositioned-but-not-moved.
var targetTracks = result.trackType === "video" ? seq.videoTracks : seq.audioTracks;
if (${args.new_track_index} >= targetTracks.numTracks) {
return __error("Track index ${args.new_track_index} is out of range: the sequence has " + targetTracks.numTracks + " " + result.trackType + " track(s).");
}
// TrackItem has no DOM moveToTrack; only the QE clip exposes one.
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE); cannot change track.");
var qeTrack = result.trackType === "video"
? qeSeq.getVideoTrackAt(result.trackIndex)
: qeSeq.getAudioTrackAt(result.trackIndex);
if (!qeTrack) return __error("QE track not found; cannot change track.");
// QE item indices count gaps ("Empty" items) alongside clips, so the
// DOM clip index does not map onto getItemAt. Match on start time.
var qeClip = null;
var wantStart = parseFloat(clip.start.ticks);
for (var qi = 0; qi < qeTrack.numItems; qi++) {
var cand = qeTrack.getItemAt(qi);
if (!cand || String(cand.type) !== "Clip") continue;
if (Math.abs(parseFloat(cand.start.ticks) - wantStart) < 1) { qeClip = cand; break; }
}
if (!qeClip) return __error("Could not locate the clip among the QE track's items; cannot change track.");
try {
qeClip.moveToTrack(${args.new_track_index});
} catch (moveErr) {
return __error("Could not move the clip to track ${args.new_track_index}: the QE moveToTrack API rejected the call (" + moveErr.toString() + "). This is a known QE limitation on Premiere Pro 26.x (confirmed on 26.2.2). The clip was left untouched — call move_clip without new_track_index to reposition it in time.");
}
` : ""}
clip.start = __secondsToTicks(${args.new_start_seconds}).toString();
// Re-find the clip rather than trusting the original reference, which
// can go stale once the clip changes track.
var after = __findClip("${nodeId}");
if (!after) return __error("Clip ${nodeId} could not be found after the move; the timeline may be in an unexpected state.");
var actualStart = __ticksToSeconds(after.clip.start.ticks);
if (Math.abs(actualStart - ${args.new_start_seconds}) > tolerance) {
return __error("Premiere did not move the clip: requested start ${args.new_start_seconds}s, read back " + actualStart + "s. Structural clip edits are known to no-op on some Premiere Pro 26.x installations (confirmed on 26.2.2).");
}
${args.new_track_index !== undefined ? `
if (after.trackIndex !== ${args.new_track_index}) {
return __error("Premiere did not move the clip to track ${args.new_track_index}; it is still on track " + after.trackIndex + ". Structural clip edits are known to no-op on some Premiere Pro 26.x installations (confirmed on 26.2.2).");
}
` : ""}
return __result({
moved: true,
verified: true,
clipName: clipName,
newStart: actualStart,
trackIndex: after.trackIndex
});
`);
return sendCommand(script, bridgeOptions);
},
},
trim_clip: {
description:
"Trim exactly one source in/out point and verify the corresponding visible timeline edge. Refuses retimed clips and, by default, trims that would leave effect keyframes outside the visible clip.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to trim",
},
new_in_seconds: {
type: "number",
minimum: 0,
description:
"New source in-point in seconds (relative to the clip's source media). Specify exactly one edit point.",
},
new_out_seconds: {
type: "number",
minimum: 0,
description:
"New source out-point in seconds (relative to the clip's source media). Specify exactly one edit point.",
},
keyframe_policy: {
type: "string",
enum: ["reject", "preserve"],
description:
"How to handle effect keyframes beyond the trimmed visible range: reject (default) leaves the timeline unchanged; preserve explicitly keeps them and reports their count.",
},
},
required: ["node_id"],
},
handler: async (args: {
node_id: string;
new_in_seconds?: number;
new_out_seconds?: number;
keyframe_policy?: "reject" | "preserve";
}) => {
// The schema permits both optional edit points. Applying both requires
// two CEP writes and can leave a partially altered timeline when the
// second write silently fails, so this tool intentionally supports one
// verified edge per request.
if ((args.new_in_seconds === undefined) === (args.new_out_seconds === undefined)) {
return {
success: false,
error:
"trim_clip requires exactly one of new_in_seconds or new_out_seconds so its visible timeline edge can be verified without a partial multi-write.",
};
}
const requestedSeconds = args.new_in_seconds ?? args.new_out_seconds;
if (requestedSeconds === undefined || !Number.isFinite(requestedSeconds) || requestedSeconds < 0) {
return {
success: false,
error: "trim_clip edit points must be finite, non-negative seconds.",
};
}
const keyframePolicy = args.keyframe_policy ?? "reject";
if (keyframePolicy !== "reject" && keyframePolicy !== "preserve") {
return {
success: false,
error: "keyframe_policy must be reject or preserve.",
};
}
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
// Premiere snaps in/out points to frame boundaries, so verification
// allows one frame of drift from the requested value. seq.timebase is
// ticks-per-frame; fall back to 24fps if it cannot be read so we never
// compare against NaN.
var seq = app.project.activeSequence;
var frameTicks = seq && seq.timebase ? parseFloat(seq.timebase) : NaN;
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var tolerance = __ticksToSeconds(frameTicks);
function __trimSeconds(timeValue) {
try { return __ticksToSeconds(timeValue.ticks); } catch(e) { return NaN; }
}
function __snapshotTrimGeometry(item) {
return {
inPoint: __trimSeconds(item.inPoint),
outPoint: __trimSeconds(item.outPoint),
start: __trimSeconds(item.start),
end: __trimSeconds(item.end),
duration: __trimSeconds(item.duration)
};
}
// ComponentParam keyframe times are relative to the clip start in
// this CEP interface. A bounded scan lets the default reject a trim
// before it creates keyframes that remain beyond the visible range.
function __findOutOfRangeKeyframes(item, visibleDuration) {
var scan = { outside: [], errors: [], inspected: 0 };
var limit = 10000;
try {
for (var ci = 0; ci < item.components.numItems; ci++) {
var component = item.components[ci];
for (var pi = 0; pi < component.properties.numItems; pi++) {
var property = component.properties[pi];
var isTimeVarying = false;
try { isTimeVarying = property.isTimeVarying(); } catch(varyingError) {
scan.errors.push("component " + ci + " property " + pi + " time-varying state: " + varyingError.toString());
continue;
}
if (!isTimeVarying) continue;
var keys;
try { keys = property.getKeys(); } catch(keyError) {
scan.errors.push("component " + ci + " property " + pi + " keys: " + keyError.toString());
continue;
}
if (!keys) continue;
for (var ki = 0; ki < keys.length; ki++) {
scan.inspected++;
if (scan.inspected > limit) {
scan.errors.push("more than " + limit + " keyframes; refusing an unbounded inspection");
return scan;
}
var keySeconds = __trimSeconds(keys[ki]);
if (!isFinite(keySeconds)) {
scan.errors.push("component " + ci + " property " + pi + " key " + ki + " has no readable time");
continue;
}
if (keySeconds > visibleDuration + tolerance) {
scan.outside.push({ component: ci, property: pi, seconds: keySeconds });
}
}
}
}
} catch(scanError) {
scan.errors.push(scanError.toString());
}
return scan;
}
var before = __snapshotTrimGeometry(clip);
if (!isFinite(before.inPoint) || !isFinite(before.outPoint) || !isFinite(before.start) || !isFinite(before.end) || !isFinite(before.duration)) {
return __error("Premiere did not provide readable source and timeline times for this clip; trim was not attempted.");
}
if (before.outPoint - before.inPoint < tolerance || before.end - before.start < tolerance) {
return __error("Clip has an empty or unreadable duration; trim was not attempted.");
}
// A source-point trim can only have an exact CEP postcondition when
// the source and visible durations agree. Retimed/reversed clips need
// host-specific semantics, so refusing them is safer than guessing.
if (Math.abs((before.end - before.start) - (before.outPoint - before.inPoint)) > tolerance) {
return __error("trim_clip does not support retimed or otherwise non-1x clips because CEP cannot prove the requested source trim maps to the correct timeline edge. Use a host-verified workflow instead.");
}
var requestedIn = ${args.new_in_seconds !== undefined ? args.new_in_seconds : "null"};
var requestedOut = ${args.new_out_seconds !== undefined ? args.new_out_seconds : "null"};
var targetIn = requestedIn === null ? before.inPoint : requestedIn;
var targetOut = requestedOut === null ? before.outPoint : requestedOut;
if (!isFinite(targetIn) || !isFinite(targetOut) || targetIn < 0 || targetOut - targetIn < tolerance) {
return __error("The requested source trim must leave at least one frame between in and out; trim was not attempted.");
}
var prospectiveDuration = targetOut - targetIn;
var beforeKeyframes = __findOutOfRangeKeyframes(clip, prospectiveDuration);
if (beforeKeyframes.errors.length && "${keyframePolicy}" === "reject") {
return __error("Could not inspect every time-varying effect property before trim (" + beforeKeyframes.errors.join("; ") + "); trim was not attempted so keyframe behavior is not guessed.");
}
if (beforeKeyframes.outside.length && "${keyframePolicy}" === "reject") {
return __error("Refusing trim before mutation: " + beforeKeyframes.outside.length + " effect keyframe(s) would remain outside the visible clip. Use keyframe_policy: preserve only if retaining those keyframes is intentional, or adjust them explicitly with the keyframe tools.");
}
${args.new_in_seconds !== undefined ? `clip.inPoint = __secondsToTicks(${args.new_in_seconds}).toString();` : "clip.outPoint = __secondsToTicks(" + args.new_out_seconds + ").toString();"}
// Re-find the TrackItem after the write. Premiere can replace stale
// DOM references during an edit, especially for audio clips.
var afterResult = __findClip("${escapeForExtendScript(args.node_id)}");
if (!afterResult) return __error("Clip could not be found after the trim attempt; the timeline may have changed and the result is not verified.");
if (afterResult.trackType !== result.trackType || afterResult.trackIndex !== result.trackIndex) {
return __error("Clip moved tracks during the trim attempt; the result is not verified.");
}
var after = __snapshotTrimGeometry(afterResult.clip);
if (!isFinite(after.inPoint) || !isFinite(after.outPoint) || !isFinite(after.start) || !isFinite(after.end) || !isFinite(after.duration)) {
return __error("Premiere did not provide readable source and timeline times after trim; the result is not verified.");
}
var actualIn = after.inPoint;
var actualOut = after.outPoint;
var drift = [];
${args.new_in_seconds !== undefined ? `
if (Math.abs(actualIn - ${args.new_in_seconds}) > tolerance) {
drift.push("inPoint requested ${args.new_in_seconds}s, read back " + actualIn + "s");
}` : ""}
${args.new_out_seconds !== undefined ? `
if (Math.abs(actualOut - ${args.new_out_seconds}) > tolerance) {
drift.push("outPoint requested ${args.new_out_seconds}s, read back " + actualOut + "s");
}` : ""}
var expectedStart = ${args.new_in_seconds !== undefined
? "before.start + (actualIn - before.inPoint)"
: "before.start"};
var expectedEnd = ${args.new_in_seconds !== undefined
? "before.end"
: "before.end + (actualOut - before.outPoint)"};
if (Math.abs(after.start - expectedStart) > tolerance) {
drift.push("timeline start expected " + expectedStart + "s, read back " + after.start + "s");
}
if (Math.abs(after.end - expectedEnd) > tolerance) {
drift.push("timeline end expected " + expectedEnd + "s, read back " + after.end + "s");
}
if (Math.abs(after.duration - (after.end - after.start)) > tolerance) {
drift.push("timeline duration " + after.duration + "s does not match visible span " + (after.end - after.start) + "s");
}
if (Math.abs((after.end - after.start) - (actualOut - actualIn)) > tolerance) {
drift.push("visible timeline duration does not match the applied source range");
}
if (drift.length) {
return __error("Premiere did not apply a verified timeline trim: " + drift.join("; ") + ". The source metadata may have changed, but this is not reported as success. Structural clip edits are known to no-op on some Premiere Pro 26.x installations.");
}
var afterKeyframes = __findOutOfRangeKeyframes(afterResult.clip, after.end - after.start);
if (afterKeyframes.errors.length && "${keyframePolicy}" === "reject") {
return __error("The timeline trim may have applied, but keyframes could not be fully read back (" + afterKeyframes.errors.join("; ") + "). It is not reported as verified; inspect the clip or use Undo.");
}
if (afterKeyframes.outside.length && "${keyframePolicy}" === "reject") {
return __error("The timeline trim may have applied, but " + afterKeyframes.outside.length + " effect keyframe(s) remain outside its visible range. It is not reported as verified; inspect the clip or use Undo.");
}
return __result({
trimmed: true,
verified: true,
clipName: afterResult.clip.name,
inPoint: actualIn,
outPoint: actualOut,
timelineStart: after.start,
timelineEnd: after.end,
timelineDuration: after.duration,
keyframePolicy: "${keyframePolicy}",
keyframesOutsideVisibleRange: afterKeyframes.outside.length,
keyframesVerified: afterKeyframes.errors.length === 0 && afterKeyframes.outside.length === 0
});
`);
return sendCommand(script, bridgeOptions);
},
},
split_clip: {
description:
"Split every clip on one track that spans a timeline time, then verify both resulting boundaries. Requires QE DOM; effect-keyframe redistribution remains unverified.",
parameters: {
type: "object" as const,
properties: {
time_seconds: {
type: "number",
minimum: 0,
description: "Timeline time in seconds where clips on the selected track will split",
},
track_index: {
type: "number",
minimum: 0,
description: "Track index (0-based, default: 0)",
},
track_type: {
type: "string",
enum: ["video", "audio"],
description: "Track type (default: video)",
},
},
required: ["time_seconds"],
},
handler: async (args: { time_seconds: number; track_index?: number; track_type?: string }) => {
if (!Number.isFinite(args.time_seconds) || args.time_seconds < 0) {
return { success: false, error: "time_seconds must be finite, non-negative seconds." };
}
if (args.track_index !== undefined && (!Number.isInteger(args.track_index) || args.track_index < 0)) {
return { success: false, error: "track_index must be a non-negative integer." };
}
const trackType = args.track_type ?? "video";
if (trackType !== "video" && trackType !== "audio") {
return { success: false, error: "track_type must be video or audio." };
}
const trackIndex = args.track_index ?? 0;
const script = buildToolScript(`
app.enableQE();
var domSequence = app.project.activeSequence;
if (!domSequence) return __error("No active sequence");
var seq = qe.project.getActiveSequence();
if (!seq) return __error("No active sequence (QE)");
var track = ${trackType === "video" ? `seq.getVideoTrackAt(${trackIndex})` : `seq.getAudioTrackAt(${trackIndex})`};
if (!track) return __error("QE track not found");
var domTrack = ${trackType === "video" ? `domSequence.videoTracks[${trackIndex}]` : `domSequence.audioTracks[${trackIndex}]`};
if (!domTrack) return __error("DOM track not found");
var frameTicks = domSequence.timebase ? parseFloat(domSequence.timebase) : NaN;
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
var boundaryTolerance = frameTicks;
var clipCountBefore = domTrack.clips.numItems;
var cutTicks = __secondsToTicks(${args.time_seconds});
function __eligibleClips(track, cut) {
var clips = [];
for (var i = 0; i < track.clips.numItems; i++) {
var item = track.clips[i];
var start = parseFloat(item.start.ticks);
var end = parseFloat(item.end.ticks);
if (!isFinite(start) || !isFinite(end)) continue;
if (cut > start && cut < end) {
clips.push({ start: start, end: end, nodeId: item.nodeId, name: item.name });
}
}
return clips;
}
function __hasSegment(track, wantedStart, wantedEnd) {
for (var i = 0; i < track.clips.numItems; i++) {
var item = track.clips[i];
var actualStart = parseFloat(item.start.ticks);
var actualEnd = parseFloat(item.end.ticks);
if (Math.abs(actualStart - wantedStart) <= boundaryTolerance && Math.abs(actualEnd - wantedEnd) <= boundaryTolerance) return true;
}
return false;
}
var eligibleBefore = __eligibleClips(domTrack, cutTicks);
if (!eligibleBefore.length) {
return __error("No clip on the requested ${trackType} track strictly spans ${args.time_seconds}s; no razor was attempted.");
}
// QE razor() parses its argument as a timecode string. Handing it a
// tick count makes the call succeed and do nothing, which is the
// silent no-op reported in #21, #127, #263 and #264. frameTicks is
// ticks-per-frame for this sequence, so frames = ticks / frameTicks.
var __razorFps = Math.round(TICKS_PER_SECOND / frameTicks);
if (!__razorFps || !isFinite(__razorFps) || __razorFps < 1) __razorFps = 30;
var __razorFrames = Math.round(cutTicks / frameTicks);
function __pad2(n) { return n < 10 ? "0" + n : "" + n; }
var __razorTc = __pad2(Math.floor(__razorFrames / (__razorFps * 3600))) + ":" +
__pad2(Math.floor((__razorFrames % (__razorFps * 3600)) / (__razorFps * 60))) + ":" +
__pad2(Math.floor((__razorFrames % (__razorFps * 60)) / __razorFps)) + ":" +
__pad2(__razorFrames % __razorFps);
try {
track.razor(__razorTc);
} catch(razorError) {
return __error("QE razor rejected the request: " + razorError.toString() + ". No verified split was produced.");
}
var clipCountAfter = domTrack.clips.numItems;
var expectedClipCount = clipCountBefore + eligibleBefore.length;
if (clipCountAfter !== expectedClipCount) {
return __error("Premiere razor changed the track clip count from " + clipCountBefore + " to " + clipCountAfter + ", expected " + expectedClipCount + " for " + eligibleBefore.length + " spanning clip(s). The timeline may be partially changed, but the split is not reported as verified. Structural QE edits are known to no-op on some Premiere Pro 26.x installations.");
}
var missingSegments = [];
for (var ei = 0; ei < eligibleBefore.length; ei++) {
var before = eligibleBefore[ei];
if (!__hasSegment(domTrack, before.start, cutTicks)) missingSegments.push(before.name + " left segment");
if (!__hasSegment(domTrack, cutTicks, before.end)) missingSegments.push(before.name + " right segment");
}
if (missingSegments.length) {
return __error("Premiere razor changed the clip count but did not create the requested cut boundary for " + missingSegments.join(", ") + ". The timeline may be partially changed, but the split is not reported as verified.");
}
return __result({
split: true,
verified: true,
timelineVerified: true,
atSeconds: __ticksToSeconds(cutTicks),
requestedSeconds: ${args.time_seconds},
trackIndex: ${trackIndex},
trackType: "${trackType}",
splitClipCount: eligibleBefore.length,
keyframeSemantics: "unverified"
});
`);
return sendCommand(script, bridgeOptions);
},
},
duplicate_clip: {
description: "Duplicate a clip on the timeline (copy to same position on next available track)",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to duplicate",
},
},
required: ["node_id"],
},
handler: async (args: { node_id: string }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
var seq = app.project.activeSequence;
var projectItem = clip.projectItem;
if (!projectItem) return __error("Cannot find source project item for clip");
var newTrackIndex = result.trackIndex + 1;
var startTicks = clip.start.ticks;
seq.insertClip(projectItem, startTicks, newTrackIndex, newTrackIndex);
return __result({ duplicated: true, clipName: clip.name, newTrackIndex: newTrackIndex });
`);
return sendCommand(script, bridgeOptions);
},
},
enable_disable_clip: {
description: "Enable or disable a clip on the timeline",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
enabled: {
type: "boolean",
description: "Set to true to enable, false to disable",
},
},
required: ["node_id", "enabled"],
},
handler: async (args: { node_id: string; enabled: boolean }) => {
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var wantDisabled = ${args.enabled ? "false" : "true"};
result.clip.disabled = wantDisabled;
var verified = __findClip("${escapeForExtendScript(args.node_id)}");
if (!verified) return __error("Clip state changed, but the clip could not be re-resolved for verification.");
if (!!verified.clip.disabled !== wantDisabled) return __error("Premiere did not persist the requested clip enabled state.");
return __result({ clipName: verified.clip.name, enabled: !verified.clip.disabled, verified: true });
`);
return sendCommand(script, bridgeOptions);
},
},
set_clip_properties: {
description:
"Set supported clip properties (opacity, scale, position, rotation). Clip speed is unsupported and fails before mutation.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
opacity: {
type: "number",
description: "Opacity value (0-100)",
},
speed: {
type: "number",
description: "Unsupported by Premiere's public scripting APIs. Supplying this returns an actionable error without mutating the clip.",
},
scale: {
type: "number",
description: "Scale percentage (100 = original size)",
},
position_x: {
type: "number",
description: "Horizontal position",
},
position_y: {
type: "number",
description: "Vertical position",
},
rotation: {
type: "number",
description: "Rotation in degrees",
},
},
required: ["node_id"],
},
handler: async (args: {
node_id: string;
opacity?: number;
speed?: number;
scale?: number;
position_x?: number;
position_y?: number;
rotation?: number;
}) => {
if (args.speed !== undefined) {
return {
success: false,
error:
"Changing a timeline clip's speed is not exposed by Premiere's supported ExtendScript or UXP APIs. No mutation was attempted. Use Premiere's Speed/Duration UI or pre-render retimed media before import.",
};
}
const script = buildToolScript(`
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var clip = result.clip;
var changes = {};
${args.opacity !== undefined ? `
// Set opacity via Motion component
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.matchName === "AE.ADBE Opacity" || comp.displayName === "Opacity") {
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "Opacity") {
comp.properties[p].setValue(${args.opacity}, true);
changes.opacity = ${args.opacity};
}
}
}
}
` : ""}
${args.scale !== undefined || args.position_x !== undefined || args.position_y !== undefined || args.rotation !== undefined ? `
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.matchName === "AE.ADBE Motion" || comp.displayName === "Motion") {
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
${args.scale !== undefined ? `
if (prop.displayName === "Scale") {
prop.setValue(${args.scale}, true);
changes.scale = ${args.scale};
}` : ""}
${args.position_x !== undefined || args.position_y !== undefined ? `
if (prop.displayName === "Position") {
var posVal = prop.getValue();
var px = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[0] : 0;
var py = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[1] : 0;
${args.position_x !== undefined ? `px = ${args.position_x}; changes.position_x = ${args.position_x};` : ""}
${args.position_y !== undefined ? `py = ${args.position_y}; changes.position_y = ${args.position_y};` : ""}
prop.setValue([px, py], true);
}` : ""}
${args.rotation !== undefined ? `
if (prop.displayName === "Rotation") {
prop.setValue(${args.rotation}, true);
changes.rotation = ${args.rotation};
}` : ""}
}
}
}
` : ""}
return __result({ updated: true, clipName: clip.name, changes: changes });
`);
return sendCommand(script, bridgeOptions);
},
},
replace_clip: {
description: "Replace a clip on the timeline with a different project item, preserving position and duration",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip to replace",
},
new_item_id: {
type: "string",
description: "Node ID or name of the new project item to replace with",
},
},
required: ["node_id", "new_item_id"],
},
handler: async (args: { node_id: string; new_item_id: string }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
var newItem = __findProjectItem("${escapeForExtendScript(args.new_item_id)}");
if (!newItem) return __error("Replacement project item not found: ${escapeForExtendScript(args.new_item_id)}");
var clip = result.clip;
var oldName = clip.name;
var startTicks = clip.start.ticks;
var trackIndex = result.trackIndex;
var trackType = result.trackType;
// Remove old clip
clip.remove(false, false);
// Insert new clip at same position
if (trackType === "video") {
seq.insertClip(newItem, startTicks, trackIndex, trackIndex);
} else {
seq.insertClip(newItem, startTicks, 0, trackIndex);
}
return __result({
replaced: true,
oldClip: oldName,
newClip: newItem.name,
trackIndex: trackIndex,
trackType: trackType
});
`);
return sendCommand(script, bridgeOptions);
},
},
speed_change: {
description:
"Unavailable: Premiere does not expose a supported scripting API for changing a timeline clip's speed.",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
speed_percent: {
type: "number",
description: "Speed as percentage (100 = normal, 200 = double, 50 = half)",
},
reverse: {
type: "boolean",
description: "Reverse playback direction (default: false)",
},
},
required: ["node_id", "speed_percent"],
},
handler: async (args: { node_id: string; speed_percent: number; reverse?: boolean }) => {
void args;
return {
success: false,
error:
"Changing a timeline clip's speed is not exposed by Premiere's supported ExtendScript or UXP APIs. No mutation was attempted. Use Premiere's Speed/Duration UI or pre-render retimed media before import.",
};
},
},
};
}
+1688
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
function isPositiveInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}
export function getTrackTools(bridgeOptions: BridgeOptions) {
return {
add_track: {
description:
"Add verified video or audio tracks to the active sequence. Returns an error if Premiere cannot add the exact requested count.",
parameters: {
type: "object" as const,
properties: {
track_type: {
type: "string",
enum: ["video", "audio"],
description: "Type of track to add",
},
count: {
type: "number",
description: "Number of tracks to add (default: 1)",
},
},
required: ["track_type"],
},
handler: async (args: { track_type: string; count?: number }) => {
const count = args.count ?? 1;
if (args.track_type !== "video" && args.track_type !== "audio") {
return { success: false, error: "track_type must be either video or audio" };
}
if (!isPositiveInteger(count)) {
return { success: false, error: "count must be a positive integer" };
}
const isVideo = args.track_type === "video";
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var before = ${isVideo ? "seq.videoTracks.numTracks" : "seq.audioTracks.numTracks"};
var expected = before + ${count};
var method = "public DOM";
var publicFailure = "";
// Premiere 26.x can expose this public Sequence API but reject the
// call. Do not report the request as successful until the DOM count
// proves it happened. If the public call made no change, QE is a
// bounded fallback; a partially applied public call is never retried.
try {
if (typeof seq.${isVideo ? "insertVideoTrackAt" : "insertAudioTrackAt"} !== "function") {
publicFailure = "Sequence.${isVideo ? "insertVideoTrackAt" : "insertAudioTrackAt"} is unavailable";
} else {
${isVideo
? `seq.insertVideoTrackAt(before, ${count});`
: `seq.insertAudioTrackAt(before, ${count});`}
}
} catch (publicError) {
publicFailure = publicError.toString();
}
var afterPublic = ${isVideo ? "seq.videoTracks.numTracks" : "seq.audioTracks.numTracks"};
if (afterPublic !== expected && afterPublic !== before) {
return __error("Track add partially applied through the public DOM: requested ${count} ${args.track_type} track(s), had " + before + ", now has " + afterPublic + ". It was not retried.");
}
if (afterPublic !== expected) {
method = "QE fallback";
if (typeof app.enableQE !== "function") {
return __error("Could not add ${args.track_type} track(s): " + publicFailure + ". QE fallback is unavailable on this Premiere build.");
}
app.enableQE();
if (typeof qe === "undefined" || !qe.project || typeof qe.project.getActiveSequence !== "function") {
return __error("Could not add ${args.track_type} track(s): " + publicFailure + ". QE active-sequence access is unavailable on this Premiere build.");
}
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq || typeof qeSeq.addTracks !== "function") {
return __error("Could not add ${args.track_type} track(s): " + publicFailure + ". QE addTracks is unavailable on this Premiere build.");
}
try {
qeSeq.addTracks(${isVideo ? count : 0}, ${isVideo ? 0 : count}, 0, 0);
} catch (qeError) {
return __error("Could not add ${args.track_type} track(s): public DOM failed (" + publicFailure + ") and QE addTracks failed (" + qeError.toString() + ").");
}
}
var after = ${isVideo ? "seq.videoTracks.numTracks" : "seq.audioTracks.numTracks"};
if (after !== expected) {
return __error("Premiere did not add the requested ${args.track_type} tracks: requested ${count}, had " + before + ", now has " + after + " (" + method + ").");
}
return __result({
added: ${count},
trackType: "${args.track_type}",
totalTracks: after,
method: method,
verified: true
});
`);
return sendCommand(script, bridgeOptions);
},
},
delete_track: {
description: "Delete a video or audio track from the active sequence",
parameters: {
type: "object" as const,
properties: {
track_type: {
type: "string",
enum: ["video", "audio"],
description: "Type of track to delete",
},
track_index: {
type: "number",
description: "Index of the track to delete (0-based)",
},
},
required: ["track_type", "track_index"],
},
handler: async (args: { track_type: string; track_index: number }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
${args.track_type === "video"
? `if (${args.track_index} >= seq.videoTracks.numTracks) return __error("Track index out of range");
seq.deleteVideoTrackAt(${args.track_index});`
: `if (${args.track_index} >= seq.audioTracks.numTracks) return __error("Track index out of range");
seq.deleteAudioTrackAt(${args.track_index});`
}
return __result({ deleted: true, trackType: "${args.track_type}", trackIndex: ${args.track_index} });
`);
return sendCommand(script, bridgeOptions);
},
},
lock_track: {
description: "Lock or unlock a video track",
parameters: {
type: "object" as const,
properties: {
track_index: {
type: "number",
description: "Video track index (0-based)",
},
locked: {
type: "boolean",
description: "True to lock, false to unlock",
},
},
required: ["track_index", "locked"],
},
handler: async (args: { track_index: number; locked: boolean }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
if (${args.track_index} >= seq.videoTracks.numTracks) return __error("Track index out of range");
var track = seq.videoTracks[${args.track_index}];
track.setLocked(${args.locked ? 1 : 0});
return __result({ trackIndex: ${args.track_index}, locked: ${args.locked}, trackName: track.name });
`);
return sendCommand(script, bridgeOptions);
},
},
toggle_track_visibility: {
description: "Toggle a video track's visibility (eye icon)",
parameters: {
type: "object" as const,
properties: {
track_index: {
type: "number",
description: "Video track index (0-based)",
},
visible: {
type: "boolean",
description: "True to show, false to hide",
},
},
required: ["track_index", "visible"],
},
handler: async (args: { track_index: number; visible: boolean }) => {
const script = buildToolScript(`
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
if (${args.track_index} >= seq.videoTracks.numTracks) return __error("Track index out of range");
var track = seq.videoTracks[${args.track_index}];
track.setMute(${args.visible ? 0 : 1});
return __result({ trackIndex: ${args.track_index}, visible: ${args.visible}, trackName: track.name });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+204
View File
@@ -0,0 +1,204 @@
import { createHash } from "node:crypto";
export interface TranscriptDeletionRange {
start_seconds: number;
end_seconds: number;
}
export interface TranscriptEditPreview {
transcriptRevision: string;
deletionRanges: TranscriptDeletionRange[];
deletedSeconds: number;
confirmationToken: string;
applied: false;
}
export interface TranscriptPlacement {
placement_id: string;
track_type: "video" | "audio";
track_index: number;
source_in_seconds: number;
source_out_seconds: number;
timeline_start_seconds: number;
timeline_end_seconds: number;
}
export interface TranscriptRoughCutStep {
placement_id: string;
track_type: "video" | "audio";
track_index: number;
source_range: TranscriptDeletionRange;
timeline_range: TranscriptDeletionRange;
instructions: readonly [string, string, string, string];
}
export interface TranscriptRoughCutPlan extends TranscriptEditPreview {
planVersion: 1;
requiresDuplicateSequence: true;
requiresPostMutationRequery: true;
ripple: boolean;
steps: TranscriptRoughCutStep[];
unmappedDeletionRanges: TranscriptDeletionRange[];
}
const MAX_DELETION_RANGES = 100;
export function transcriptRevision(json: string): string {
return `sha256:${createHash("sha256").update(json).digest("hex")}`;
}
function roundSeconds(value: number): number {
return Number(value.toFixed(6));
}
export function normalizeTranscriptDeletionRanges(
value: unknown,
handleSeconds = 0,
): TranscriptDeletionRange[] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("deletions must contain at least one transcript time range");
}
if (value.length > MAX_DELETION_RANGES) {
throw new Error(`transcript edits are limited to ${MAX_DELETION_RANGES} deletion ranges`);
}
if (!Number.isFinite(handleSeconds) || handleSeconds < 0 || handleSeconds > 10) {
throw new Error("handle_seconds must be between 0 and 10");
}
const ranges = value.map((entry, index) => {
if (!entry || typeof entry !== "object") {
throw new Error(`deletion ${index} must be an object`);
}
const range = entry as Partial<TranscriptDeletionRange>;
if (!Number.isFinite(range.start_seconds) || Number(range.start_seconds) < 0) {
throw new Error(`deletion ${index} start_seconds must be a non-negative number`);
}
if (!Number.isFinite(range.end_seconds) || Number(range.end_seconds) <= Number(range.start_seconds)) {
throw new Error(`deletion ${index} end_seconds must be greater than start_seconds`);
}
return {
start_seconds: roundSeconds(Math.max(0, Number(range.start_seconds) - handleSeconds)),
end_seconds: roundSeconds(Number(range.end_seconds) + handleSeconds),
};
}).sort((left, right) => left.start_seconds - right.start_seconds || left.end_seconds - right.end_seconds);
const merged: TranscriptDeletionRange[] = [];
for (const range of ranges) {
const previous = merged.at(-1);
if (previous && range.start_seconds <= previous.end_seconds) {
previous.end_seconds = roundSeconds(Math.max(previous.end_seconds, range.end_seconds));
} else {
merged.push({ ...range });
}
}
return merged;
}
export function previewTranscriptEdit(
json: string,
requestedRevision: string,
deletions: unknown,
handleSeconds = 0,
): TranscriptEditPreview {
if (typeof json !== "string" || !json) throw new Error("Premiere returned an empty transcript");
const revision = transcriptRevision(json);
if (requestedRevision !== revision) {
throw new Error("Transcript revision does not match the current Premiere transcript; export it again before planning edits");
}
const deletionRanges = normalizeTranscriptDeletionRanges(deletions, handleSeconds);
const canonical = JSON.stringify({ transcriptRevision: revision, deletionRanges });
return {
transcriptRevision: revision,
deletionRanges,
deletedSeconds: roundSeconds(deletionRanges.reduce((total, range) => total + range.end_seconds - range.start_seconds, 0)),
confirmationToken: createHash("sha256").update(canonical).digest("hex"),
applied: false,
};
}
function finite(value: unknown, label: string): number {
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite number`);
return Number(value);
}
export function planTranscriptRoughCut(
preview: TranscriptEditPreview,
placementsValue: unknown,
ripple = true,
): TranscriptRoughCutPlan {
if (!Array.isArray(placementsValue) || placementsValue.length === 0) {
throw new Error("placements must contain at least one verified 1x sequence placement");
}
if (placementsValue.length > 256) throw new Error("transcript rough cuts are limited to 256 placements");
const placements = placementsValue.map((value, index): TranscriptPlacement => {
if (!value || typeof value !== "object") throw new Error(`placement ${index} must be an object`);
const item = value as Partial<TranscriptPlacement>;
if (typeof item.placement_id !== "string" || !item.placement_id.trim() || item.placement_id.length > 512) {
throw new Error(`placement ${index} placement_id must be 1-512 characters`);
}
if (item.track_type !== "video" && item.track_type !== "audio") {
throw new Error(`placement ${index} track_type must be video or audio`);
}
if (!Number.isInteger(item.track_index) || Number(item.track_index) < 0) {
throw new Error(`placement ${index} track_index must be a non-negative integer`);
}
const sourceIn = finite(item.source_in_seconds, `placement ${index} source_in_seconds`);
const sourceOut = finite(item.source_out_seconds, `placement ${index} source_out_seconds`);
const timelineStart = finite(item.timeline_start_seconds, `placement ${index} timeline_start_seconds`);
const timelineEnd = finite(item.timeline_end_seconds, `placement ${index} timeline_end_seconds`);
if (sourceIn < 0 || timelineStart < 0 || sourceOut <= sourceIn || timelineEnd <= timelineStart) {
throw new Error(`placement ${index} must contain increasing non-negative source and timeline ranges`);
}
if (Math.abs((sourceOut - sourceIn) - (timelineEnd - timelineStart)) > 0.001) {
throw new Error(`placement ${index} is retimed or has an unverified source-to-timeline mapping`);
}
return {
placement_id: item.placement_id,
track_type: item.track_type,
track_index: Number(item.track_index),
source_in_seconds: sourceIn,
source_out_seconds: sourceOut,
timeline_start_seconds: timelineStart,
timeline_end_seconds: timelineEnd,
};
});
const mapped = new Set<number>();
const steps: TranscriptRoughCutStep[] = [];
for (const placement of placements) {
for (let rangeIndex = 0; rangeIndex < preview.deletionRanges.length; rangeIndex++) {
const deletion = preview.deletionRanges[rangeIndex];
const start = Math.max(deletion.start_seconds, placement.source_in_seconds);
const end = Math.min(deletion.end_seconds, placement.source_out_seconds);
if (end <= start) continue;
mapped.add(rangeIndex);
const timelineStart = roundSeconds(placement.timeline_start_seconds + start - placement.source_in_seconds);
const timelineEnd = roundSeconds(placement.timeline_start_seconds + end - placement.source_in_seconds);
steps.push({
placement_id: placement.placement_id,
track_type: placement.track_type,
track_index: placement.track_index,
source_range: { start_seconds: roundSeconds(start), end_seconds: roundSeconds(end) },
timeline_range: { start_seconds: timelineStart, end_seconds: timelineEnd },
instructions: [
`Split ${placement.track_type} track ${placement.track_index} at ${timelineEnd}s.`,
`Split ${placement.track_type} track ${placement.track_index} at ${timelineStart}s.`,
"Re-query the sequence and identify the segment enclosed by those verified boundaries.",
`Remove only that segment${ripple ? " with ripple enabled" : " without ripple"}, then re-query and verify the resulting structure.`,
],
});
}
}
steps.sort((left, right) => right.timeline_range.start_seconds - left.timeline_range.start_seconds);
return {
...preview,
planVersion: 1,
requiresDuplicateSequence: true,
requiresPostMutationRequery: true,
ripple,
steps,
unmappedDeletionRanges: preview.deletionRanges.filter((_, index) => !mapped.has(index)),
};
}
+426
View File
@@ -0,0 +1,426 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getTransitionsTools(bridgeOptions: BridgeOptions) {
return {
add_transition: {
description: "Add a video transition between two clips at a cut point. Uses QE DOM.",
parameters: {
type: "object" as const,
properties: {
transition_name: {
type: "string",
description: "Name of the transition (e.g., 'Cross Dissolve', 'Dip to Black')",
},
track_index: {
type: "number",
description: "Video track index (0-based)",
},
cut_point_seconds: {
type: "number",
description: "Time position in seconds of the cut point where the transition should be placed",
},
duration_seconds: {
type: "number",
description: "Duration of the transition in seconds (default: 1.0)",
},
},
required: ["transition_name", "track_index", "cut_point_seconds"],
},
handler: async (args: {
transition_name: string;
track_index: number;
cut_point_seconds: number;
duration_seconds?: number;
}) => {
const duration = args.duration_seconds ?? 1.0;
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var qeTrack = qeSeq.getVideoTrackAt(${args.track_index});
if (!qeTrack) return __error("Track not found");
var transitionName = "${escapeForExtendScript(args.transition_name)}";
var transitionQE = null;
// Resolution path 1: getVideoTransitionByName (works on PPro 2026 where
// getVideoTransitionList() returns an empty list).
try {
if (qe.project.getVideoTransitionByName) {
transitionQE = qe.project.getVideoTransitionByName(transitionName);
}
} catch(e1) {}
// Resolution path 2: scan getVideoTransitionList (legacy).
if (!transitionQE) {
try {
var transitions = qe.project.getVideoTransitionList();
for (var i = 0; i < transitions.numItems; i++) {
if (transitions[i].name === transitionName) {
transitionQE = transitions[i];
break;
}
}
} catch(e2) {}
}
if (!transitionQE) return __error("Transition not found: " + transitionName);
var domTrack = app.project.activeSequence.videoTracks[${args.track_index}];
if (!domTrack) return __error("Track not found in the Premiere DOM");
var cutTicks = __secondsToTicks(${args.cut_point_seconds});
var outgoingClip = null;
var incomingClip = null;
for (var c = 0; c < domTrack.clips.numItems; c++) {
var candidate = domTrack.clips[c];
if (Math.abs(parseFloat(candidate.end.ticks) - cutTicks) < 1) { outgoingClip = candidate; break; }
}
for (var c2 = 0; c2 < domTrack.clips.numItems; c2++) {
var candidate2 = domTrack.clips[c2];
if (Math.abs(parseFloat(candidate2.start.ticks) - cutTicks) < 1) { incomingClip = candidate2; break; }
}
if (!incomingClip && !outgoingClip) return __error("No video clip edge exists at the requested cut point, so no transition was attempted.");
// Premiere 26.3.2 confirms arg 2 is the clip edge: true=head,
// false=tail. Prefer the incoming head and fall back to the outgoing tail.
var targetClip = incomingClip || outgoingClip;
var targetHead = !!incomingClip;
var qeClip = __findQeClipByDomClip(qeTrack, targetClip);
if (!qeClip || typeof qeClip.addTransition !== "function") {
return __error("The target QE clip does not expose addTransition; no transition was attempted. The QE track itself is not the transition write surface.");
}
var seq = app.project.activeSequence;
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) return __error("The active sequence did not expose a valid timebase for transition duration.");
var durationFrames = Math.max(1, Math.round(__secondsToTicks(${duration}) / frameTicks));
var transitionCountBefore = domTrack.transitions.numItems;
try {
// QE transition writes belong to the clip. The legacy method takes
// a clip edge, duration in sequence frames, offset, alignment, and
// single-sided flags; DOM readback below decides whether it worked.
qeClip.addTransition(transitionQE, targetHead, String(durationFrames), "0", 0.5, false, true);
} catch (transitionError) {
return __error("QE clip addTransition rejected the transition: " + transitionError.toString());
}
if (domTrack.transitions.numItems <= transitionCountBefore) {
return __error("QE clip addTransition returned without adding a transition to the track.");
}
var transitionAtCut = false;
for (var t = 0; t < domTrack.transitions.numItems; t++) {
var placedTransition = domTrack.transitions[t];
var transitionStart = parseFloat(placedTransition.start.ticks);
var transitionEnd = parseFloat(placedTransition.end.ticks);
if (!isNaN(transitionStart) && !isNaN(transitionEnd) && Math.abs(((transitionStart + transitionEnd) / 2) - cutTicks) <= frameTicks / 2) {
transitionAtCut = true;
break;
}
}
if (!transitionAtCut) return __error("Premiere added a transition, but DOM readback did not find it at the requested cut point.");
return __result({
added: true,
verified: true,
transition: transitionName,
trackIndex: ${args.track_index},
atSeconds: ${args.cut_point_seconds},
durationSeconds: ${duration}
});
`);
return sendCommand(script, bridgeOptions);
},
},
add_transition_to_clip: {
description: "Add a transition to a specific clip's start or end",
parameters: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Node ID of the clip",
},
transition_name: {
type: "string",
description: "Name of the transition",
},
position: {
type: "string",
enum: ["start", "end", "both"],
description: "Where to apply the transition (default: end)",
},
duration_seconds: {
type: "number",
description: "Duration of the transition in seconds (default: 1.0)",
},
},
required: ["node_id", "transition_name"],
},
handler: async (args: {
node_id: string;
transition_name: string;
position?: string;
duration_seconds?: number;
}) => {
const position = args.position || "end";
const duration = args.duration_seconds ?? 1.0;
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var result = __findClip("${escapeForExtendScript(args.node_id)}");
if (!result) return __error("Clip not found");
if (result.trackType !== "video") return __error("Video transitions can only be added to a video clip; no transition was attempted.");
var transitionName = "${escapeForExtendScript(args.transition_name)}";
var transitionQE = null;
try { if (qe.project.getVideoTransitionByName) transitionQE = qe.project.getVideoTransitionByName(transitionName); } catch(e1) {}
if (!transitionQE) {
try {
var transitions = qe.project.getVideoTransitionList();
for (var i = 0; i < transitions.numItems; i++) {
if (transitions[i].name === transitionName) { transitionQE = transitions[i]; break; }
}
} catch(e2) {}
}
if (!transitionQE) return __error("Transition not found: " + transitionName);
var qeTrack = qeSeq.getVideoTrackAt(result.trackIndex);
if (!qeTrack) return __error("QE video track not found");
var domTrack = app.project.activeSequence.videoTracks[result.trackIndex];
if (!domTrack) return __error("Video track not found in the Premiere DOM");
var qeClip = __findQeClipByDomClip(qeTrack, result.clip);
if (!qeClip || typeof qeClip.addTransition !== "function") {
return __error("The target QE clip does not expose addTransition; no transition was attempted. The QE track itself is not the transition write surface.");
}
var seq = app.project.activeSequence;
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) return __error("The active sequence did not expose a valid timebase for transition duration.");
var transitionCountBefore = domTrack.transitions.numItems;
var durationFrames = Math.max(1, Math.round(__secondsToTicks(${duration}) / frameTicks));
var clip = result.clip;
var position = "${position}";
var requestedCount = position === "both" ? 2 : 1;
if (position === "start" || position === "both") {
try {
qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true);
} catch (startTransitionError) {
return __error("QE clip addTransition rejected the transition at the clip start: " + startTransitionError.toString());
}
}
if (position === "end" || position === "both") {
try {
qeClip.addTransition(transitionQE, false, String(durationFrames), "0", 0.5, false, true);
} catch (endTransitionError) {
if (position === "both" && domTrack.transitions.numItems > transitionCountBefore) {
return __error("Premiere added the transition at the clip start, but rejected the transition at the clip end; the request was partially applied: " + endTransitionError.toString());
}
return __error("QE clip addTransition rejected the transition at the clip end: " + endTransitionError.toString());
}
}
var verifiedCount = domTrack.transitions.numItems - transitionCountBefore;
if (verifiedCount < requestedCount) {
return __error("QE clip addTransition returned, but Premiere added " + verifiedCount + " of " + requestedCount + " requested transition(s) to the track.");
}
var startVerified = position !== "start" && position !== "both";
var endVerified = position !== "end" && position !== "both";
var clipStartTicks = parseFloat(clip.start.ticks);
var clipEndTicks = parseFloat(clip.end.ticks);
for (var vt = 0; vt < domTrack.transitions.numItems; vt++) {
var verifiedTransition = domTrack.transitions[vt];
var verifiedStart = parseFloat(verifiedTransition.start.ticks);
var verifiedEnd = parseFloat(verifiedTransition.end.ticks);
if (isNaN(verifiedStart) || isNaN(verifiedEnd)) continue;
var verifiedMidpoint = (verifiedStart + verifiedEnd) / 2;
if (Math.abs(verifiedMidpoint - clipStartTicks) <= frameTicks / 2) startVerified = true;
if (Math.abs(verifiedMidpoint - clipEndTicks) <= frameTicks / 2) endVerified = true;
}
if (!startVerified || !endVerified) return __error("Premiere added the requested transition count, but DOM readback did not find a transition at each requested clip edge.");
return __result({
added: true,
verified: true,
transition: transitionName,
clipName: clip.name,
position: position,
durationSeconds: ${duration}
});
`);
return sendCommand(script, bridgeOptions);
},
},
batch_add_transitions: {
description: "Add the same transition to all cut points on a track",
parameters: {
type: "object" as const,
properties: {
transition_name: {
type: "string",
description: "Name of the transition (e.g., 'Cross Dissolve')",
},
track_index: {
type: "number",
description: "Video track index (0-based, default: 0)",
},
duration_seconds: {
type: "number",
description: "Duration of each transition in seconds (default: 1.0)",
},
},
required: ["transition_name"],
},
handler: async (args: {
transition_name: string;
track_index?: number;
duration_seconds?: number;
}) => {
const trackIndex = args.track_index ?? 0;
const duration = args.duration_seconds ?? 1.0;
const script = buildToolScript(`
app.enableQE();
var qeSeq = qe.project.getActiveSequence();
if (!qeSeq) return __error("No active sequence (QE)");
var seq = app.project.activeSequence;
if (!seq) return __error("No active sequence");
var transitionName = "${escapeForExtendScript(args.transition_name)}";
var transitionQE = null;
try { if (qe.project.getVideoTransitionByName) transitionQE = qe.project.getVideoTransitionByName(transitionName); } catch(e1) {}
if (!transitionQE) {
try {
var transitions = qe.project.getVideoTransitionList();
for (var i = 0; i < transitions.numItems; i++) {
if (transitions[i].name === transitionName) { transitionQE = transitions[i]; break; }
}
} catch(e2) {}
}
if (!transitionQE) return __error("Transition not found: " + transitionName);
var track = seq.videoTracks[${trackIndex}];
var qeTrack = qeSeq.getVideoTrackAt(${trackIndex});
if (!track || !qeTrack) return __error("Video track not found");
var frameTicks = parseFloat(seq.timebase);
if (!frameTicks || isNaN(frameTicks)) return __error("The active sequence did not expose a valid timebase for transition duration.");
var durationFrames = Math.max(1, Math.round(__secondsToTicks(${duration}) / frameTicks));
var transitionCountBefore = track.transitions.numItems;
var requestedCount = 0;
var failures = [];
// Add transition at each cut point (between consecutive clips)
for (var c = 0; c < track.clips.numItems - 1; c++) {
var outgoingClip = track.clips[c];
var incomingClip = track.clips[c + 1];
if (Math.abs(parseFloat(outgoingClip.end.ticks) - parseFloat(incomingClip.start.ticks)) >= 1) continue;
requestedCount++;
var qeClip = __findQeClipByDomClip(qeTrack, incomingClip);
if (!qeClip || typeof qeClip.addTransition !== "function") {
failures.push("cut " + c + ": target QE clip does not expose addTransition");
continue;
}
try {
qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true);
} catch(e) { failures.push("cut " + c + ": " + e.toString()); }
}
var transitionCountAfter = track.transitions.numItems;
var verifiedCount = transitionCountAfter - transitionCountBefore;
if (requestedCount === 0) return __error("No adjacent video clips were found, so no transitions were attempted.");
if (verifiedCount !== requestedCount) {
return __error("QE clip addTransition verified " + verifiedCount + " of " + requestedCount + " requested transitions" + (failures.length ? ": " + failures.join("; ") : "."));
}
for (var cutIndex = 0; cutIndex < track.clips.numItems - 1; cutIndex++) {
var leftClip = track.clips[cutIndex];
var rightClip = track.clips[cutIndex + 1];
var expectedCut = parseFloat(rightClip.start.ticks);
if (Math.abs(parseFloat(leftClip.end.ticks) - expectedCut) >= 1) continue;
var foundAtCut = false;
for (var transitionIndex = 0; transitionIndex < track.transitions.numItems; transitionIndex++) {
var readTransition = track.transitions[transitionIndex];
var readStart = parseFloat(readTransition.start.ticks);
var readEnd = parseFloat(readTransition.end.ticks);
if (!isNaN(readStart) && !isNaN(readEnd) && Math.abs(((readStart + readEnd) / 2) - expectedCut) <= frameTicks / 2) { foundAtCut = true; break; }
}
if (!foundAtCut) return __error("Premiere added the requested transition count, but DOM readback did not find a transition at cut " + cutIndex + ".");
}
return __result({
added: verifiedCount,
verified: true,
transition: transitionName,
trackIndex: ${trackIndex},
durationSeconds: ${duration}
});
`);
return sendCommand(script, bridgeOptions);
},
},
list_available_transitions: {
description: "List all available video transitions. Uses QE DOM. Returns a hint set on PPro 2026 where the transition registry list is empty even though by-name lookup works.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
var list = [];
try {
var transitions = qe.project.getVideoTransitionList();
for (var i = 0; i < transitions.numItems; i++) {
list.push({ name: transitions[i].name, index: i });
}
} catch(e) {}
// PPro 2026 fallback: the registry list is empty but by-name lookup
// resolves these standard transitions. Probe each so callers see
// something usable.
if (list.length === 0 && qe.project.getVideoTransitionByName) {
var names = ["Cross Dissolve","Dip to Black","Dip to White","Film Dissolve","Additive Dissolve","Morph Cut","Push","Slide","Wipe","Iris Round","Iris Box"];
for (var n = 0; n < names.length; n++) {
try {
if (qe.project.getVideoTransitionByName(names[n])) {
list.push({ name: names[n], source: "byName" });
}
} catch(e2) {}
}
}
return __result(list);
`);
return sendCommand(script, bridgeOptions);
},
},
list_available_audio_transitions: {
description: "List all available audio transitions. Uses QE DOM and reports an unavailable or empty legacy catalog as an error rather than an assumed usable list.",
parameters: {},
handler: async () => {
const script = buildToolScript(`
app.enableQE();
var transitions = null;
try { transitions = qe.project.getAudioTransitionList(); } catch (catalogError) {
return __error("Premiere did not expose an audio-transition catalog through QE: " + catalogError.toString());
}
if (!transitions || typeof transitions.numItems !== "number") {
return __error("Premiere did not expose an enumerable audio-transition catalog through QE.");
}
var list = [];
for (var i = 0; i < transitions.numItems; i++) {
list.push({ name: transitions[i].name, index: i });
}
if (list.length === 0) {
return __error("QE reported an empty audio-transition catalog. No supported by-name fallback is available, so no transition availability is claimed.");
}
return __result({ transitions: list, verified: true, source: "qe.catalog" });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}
+1441
View File
File diff suppressed because it is too large Load Diff
+951
View File
@@ -0,0 +1,951 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
import { planDerivedSilenceRemoval } from "./silence-removal.js";
const WAIT_RESPONSE_BUFFER_MS = 5_000;
type AdvancedArgs = Record<string, unknown> & {
action?: string;
operation_id?: string;
};
function invoke(
bridge: UxpWebSocketBridge,
command: string,
args: Record<string, unknown> = {},
hostWaitMs?: number,
) {
const request = hostWaitMs === undefined
? bridge.request(command, args)
: bridge.request(command, args, { minimumTimeoutMs: hostWaitMs + WAIT_RESPONSE_BUFFER_MS });
return request
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
function compact(values: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
}
function operation(args: AdvancedArgs): Record<string, unknown> {
return args.operation_id ? { operationId: args.operation_id } : {};
}
function invalidAction(value: unknown) {
return { success: false, error: `Unsupported workflow action: ${String(value)}` };
}
type ReviewedMarkerSnapshot = {
markerGuid: string;
expectedName: string;
expectedStartSeconds: number;
expectedDurationSeconds: number;
};
function reviewedMarkerSnapshots(value: unknown): ReviewedMarkerSnapshot[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 128) {
throw new Error("marker_snapshots must contain between 1 and 128 reviewed marker snapshots");
}
const seen = new Set<string>();
return value.map((rawSnapshot, index) => {
if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) {
throw new Error(`marker_snapshots[${index}] must be an object`);
}
const snapshot = rawSnapshot as Record<string, unknown>;
const allowed = ["marker_guid", "expected_name", "expected_start_seconds", "expected_duration_seconds"];
const unknown = Object.keys(snapshot).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`marker_snapshots[${index}] has an unknown field: ${unknown}`);
if (typeof snapshot.marker_guid !== "string" || !snapshot.marker_guid.trim() || snapshot.marker_guid.length > 128) {
throw new Error(`marker_snapshots[${index}].marker_guid must be a non-empty string of at most 128 characters`);
}
if (seen.has(snapshot.marker_guid)) throw new Error("marker_snapshots must not contain duplicate marker_guid values");
seen.add(snapshot.marker_guid);
if (typeof snapshot.expected_name !== "string" || snapshot.expected_name.length > 255) {
throw new Error(`marker_snapshots[${index}].expected_name must be a string of at most 255 characters`);
}
for (const [key, item] of Object.entries({
expected_start_seconds: snapshot.expected_start_seconds,
expected_duration_seconds: snapshot.expected_duration_seconds,
})) {
if (typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 86400) {
throw new Error(`marker_snapshots[${index}].${key} must be a number from 0 to 86400`);
}
}
return {
markerGuid: snapshot.marker_guid,
expectedName: snapshot.expected_name,
expectedStartSeconds: snapshot.expected_start_seconds as number,
expectedDurationSeconds: snapshot.expected_duration_seconds as number,
};
});
}
const operationId = {
type: "string",
pattern: "^[A-Za-z0-9._:-]{1,128}$",
description: "Optional idempotency key for a mutating operation.",
};
const MAX_DISPLAY_FORMAT_CODE = 2_147_483_647;
type DisplayFormatSnapshot = {
audioDisplayFormat: number;
videoDisplayFormat: number;
};
function boundedDisplayFormatCode(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > MAX_DISPLAY_FORMAT_CODE) {
throw new Error(`${name} must be a non-negative integer no greater than ${MAX_DISPLAY_FORMAT_CODE}`);
}
return value;
}
function reviewedDisplayFormats(value: unknown): DisplayFormatSnapshot {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("expected_display_formats must be an object returned by inspect");
}
const formats = value as Record<string, unknown>;
const allowed = ["audio_display_format", "video_display_format"];
const unknown = Object.keys(formats).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`expected_display_formats has an unknown field: ${unknown}`);
if (!("audio_display_format" in formats) || !("video_display_format" in formats)) {
throw new Error("expected_display_formats must include audio_display_format and video_display_format");
}
return {
audioDisplayFormat: boundedDisplayFormatCode(formats.audio_display_format, "expected_display_formats.audio_display_format"),
videoDisplayFormat: boundedDisplayFormatCode(formats.video_display_format, "expected_display_formats.video_display_format"),
};
}
function requestedDisplayFormats(value: unknown): Partial<DisplayFormatSnapshot> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("updates must be an object");
}
const formats = value as Record<string, unknown>;
const allowed = ["audio_display_format", "video_display_format"];
const unknown = Object.keys(formats).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`updates has an unknown field: ${unknown}`);
const result: Partial<DisplayFormatSnapshot> = {};
if ("audio_display_format" in formats) {
result.audioDisplayFormat = boundedDisplayFormatCode(formats.audio_display_format, "updates.audio_display_format");
}
if ("video_display_format" in formats) {
result.videoDisplayFormat = boundedDisplayFormatCode(formats.video_display_format, "updates.video_display_format");
}
if (!Object.keys(result).length) throw new Error("updates must include audio_display_format or video_display_format");
return result;
}
const sequenceId = {
type: "string",
minLength: 1,
maxLength: 128,
description: "Stable sequence GUID. Omit where documented to use the active sequence.",
};
const projectItemId = {
type: "string",
minLength: 1,
maxLength: 512,
description: "Stable project-item ID. Some actions allow omission when exactly one Project item is selected.",
};
const timelineTargetProperties = {
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
};
const timelinePositionProperties = {
time_seconds: { type: "number", minimum: 0, maximum: 86400 },
video_track_index: { type: "integer", minimum: 0 },
audio_track_index: { type: "integer", minimum: 0 },
};
export function getUxpAdvancedWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_project_selection_uxp: {
description: "List Premiere Project-panel views or inspect up to 256 selected project items without traversing the complete project tree.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["views", "selection"] },
view_id: { type: "string", minLength: 1, maxLength: 128 },
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
if (args.action === "views") return invoke(bridge, "projectSelection.views");
if (args.action === "selection") return invoke(bridge, "projectSelection.inspect", compact({ viewId: args.view_id }));
return invalidAction(args.action);
},
},
inspect_project_tree_uxp: {
description: "Read a bounded, depth-limited native Project-panel tree rooted at the active project. Returns stable IDs, names, types, parent IDs, bin state, and optional color-label indexes only; it never returns media paths, metadata, or rendered media.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
max_items: { type: "integer", minimum: 1, maximum: 512, description: "Maximum non-root project items to snapshot; defaults to 256." },
max_depth: { type: "integer", minimum: 0, maximum: 16, description: "Maximum depth below the Project root to inspect; defaults to 6." },
},
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "26.3",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: ["Available only through an authenticated UXP bridge whose runtime capability handshake advertises projectTree.inspect."],
},
handler: async (args: AdvancedArgs) => invoke(bridge, "projectTree.inspect", compact({ maxItems: args.max_items, maxDepth: args.max_depth })),
},
manage_markers_uxp: {
description: "Inspect, add, update/move, remove, or explicitly review-then-remove a bounded marker batch by stable GUID using documented, undoable Premiere actions; mutations for one marker owner are serialized through preflight and readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "add", "update", "remove", "remove_many"] },
owner_type: { type: "string", enum: ["sequence", "project_item"] },
sequence_id: sequenceId,
project_item_id: projectItemId,
marker_guid: { type: "string", minLength: 1, maxLength: 128 },
expected_name: { type: "string", maxLength: 255, description: "Optional stale-marker guard for update/remove." },
name: { type: "string", minLength: 1, maxLength: 255 },
marker_type: { type: "string", minLength: 1, maxLength: 128 },
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
duration_seconds: { type: "number", minimum: 0, maximum: 86400 },
comments: { type: "string", maxLength: 4000 },
color_index: { type: "integer", minimum: 0, maximum: 6 },
marker_snapshots: {
type: "array",
minItems: 1,
maxItems: 128,
description: "Required for remove_many. Explicit read snapshots from inspect: every target GUID, name, start, and duration must still match before action creation.",
items: {
type: "object",
additionalProperties: false,
properties: {
marker_guid: { type: "string", minLength: 1, maxLength: 128 },
expected_name: { type: "string", maxLength: 255 },
expected_start_seconds: { type: "number", minimum: 0, maximum: 86400 },
expected_duration_seconds: { type: "number", minimum: 0, maximum: 86400 },
},
required: ["marker_guid", "expected_name", "expected_start_seconds", "expected_duration_seconds"],
},
},
confirm_destructive: { type: "boolean", description: "Required true for remove_many after reviewing every explicit marker snapshot." },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
const markerOwner = compact({
ownerType: args.owner_type === "project_item" ? "projectItem" : args.owner_type,
sequenceId: args.sequence_id,
projectItemId: args.project_item_id,
});
const common = {
...markerOwner,
...compact({
markerGuid: args.marker_guid,
expectedName: args.expected_name,
}),
};
if (args.action === "inspect") return invoke(bridge, "markers.inspect", common);
if (args.action === "add") return invoke(bridge, "markers.add", { ...common, ...compact({
name: args.name, markerType: args.marker_type, startSeconds: args.start_seconds,
durationSeconds: args.duration_seconds, comments: args.comments,
}), ...operation(args) });
if (args.action === "update") return invoke(bridge, "markers.update", { ...common, ...compact({
name: args.name, markerType: args.marker_type, startSeconds: args.start_seconds,
durationSeconds: args.duration_seconds, comments: args.comments, colorIndex: args.color_index,
}), ...operation(args) });
if (args.action === "remove") return invoke(bridge, "markers.remove", { ...common, ...operation(args) });
if (args.action === "remove_many") {
try {
if (args.confirm_destructive !== true) {
return { success: false, error: "remove_many is destructive; pass confirm_destructive=true after reviewing every marker snapshot" };
}
return invoke(bridge, "markers.removeMany", {
...markerOwner,
markerSnapshots: reviewedMarkerSnapshots(args.marker_snapshots),
confirmDestructive: true,
...operation(args),
});
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
return invalidAction(args.action);
},
},
apply_beat_markers_uxp: {
description: "Apply a reviewed beat grid as native sequence markers in one undoable Premiere 26.3+ UXP transaction, with bounded inputs and GUID/time readback for every marker.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
beat_times_seconds: { type: "array", minItems: 1, maxItems: 512, items: { type: "number", minimum: 0, maximum: 86400 }, description: "Strictly increasing unique beat times, such as beatTimesSeconds returned by detect_beats." },
sequence_id: sequenceId,
offset_seconds: { type: "number", minimum: -86400, maximum: 86400, description: "Timeline offset added to every beat time; every result must remain between 0 and 86400 seconds." },
name_prefix: { type: "string", minLength: 1, maxLength: 64, description: "Marker label prefix; defaults to Beat." },
comments: { type: "string", maxLength: 1000 },
marker_type: { type: "string", minLength: 1, maxLength: 128 },
operation_id: operationId,
},
required: ["beat_times_seconds"],
},
handler: async (args: AdvancedArgs) => invoke(bridge, "markers.addBeatGrid", {
beatTimesSeconds: args.beat_times_seconds,
...compact({ sequenceId: args.sequence_id, offsetSeconds: args.offset_seconds, namePrefix: args.name_prefix, comments: args.comments, markerType: args.marker_type }),
...operation(args),
}),
},
create_silence_cut_source_stringout_uxp: {
description: "Create a new single-source rough-cut stringout from reviewed silence ranges using documented Premiere 26.3+ hard-bounded linked A/V subclips. It does not preserve or modify an existing edited timeline.",
parameters: {
type: "object" as const, additionalProperties: false,
properties: {
source_project_item_id: projectItemId,
sequence_name: { type: "string", minLength: 1, maxLength: 255 },
duration_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400 },
frame_rate: { type: "number", exclusiveMinimum: 0, maximum: 240 },
silence_ranges: { type: "array", minItems: 1, maxItems: 512, items: { type: "object", additionalProperties: false, properties: {
start_seconds: { type: "number", minimum: 0, maximum: 86400 }, end_seconds: { type: "number", minimum: 0, maximum: 86400 },
}, required: ["start_seconds", "end_seconds"] } },
keep_handle_frames: { type: "integer", minimum: 0, maximum: 2400 },
maximum_removals: { type: "integer", minimum: 1, maximum: 512 },
target_bin_id: projectItemId,
confirm_non_undoable: { type: "boolean", description: "Required true. Derived sequence creation is a direct host call and partial generated artifacts can remain." },
operation_id: operationId,
},
required: ["source_project_item_id", "sequence_name", "duration_seconds", "frame_rate", "silence_ranges", "confirm_non_undoable"],
},
handler: async (args: AdvancedArgs) => {
try {
const plan = planDerivedSilenceRemoval({
durationSeconds: Number(args.duration_seconds), frameRate: Number(args.frame_rate),
silenceRanges: (args.silence_ranges as Array<{ start_seconds: number; end_seconds: number }>).map((range) => ({ startSeconds: range.start_seconds, endSeconds: range.end_seconds })),
keepHandleFrames: args.keep_handle_frames as number | undefined,
maximumRemovals: args.maximum_removals as number | undefined,
});
if (plan.keepRanges.length > 64) return { success: false, error: "Source stringout requires more than 64 keep segments; narrow the reviewed plan." };
const keepRanges = plan.keepRanges.map((range) => ({ ...range, startSeconds: range.startFrame / plan.frameRate, endSeconds: range.endFrame / plan.frameRate }));
const result = await invoke(bridge, "silence.deriveSequence", {
sourceProjectItemId: args.source_project_item_id, name: args.sequence_name, keepRanges,
...compact({ targetBinId: args.target_bin_id, confirmNonUndoable: args.confirm_non_undoable }), ...operation(args),
});
return "data" in result ? { ...result, data: { ...result.data, plan } } : result;
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
organize_project_items_uxp: {
description: "Inspect a bin or transactionally create, rename, move, color-label, and remove project items with stable-ID guards.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect_bin", "create_bin", "create_smart_bin", "rename", "move", "set_color", "remove"] },
bin_id: projectItemId,
parent_bin_id: projectItemId,
destination_bin_id: projectItemId,
project_item_id: projectItemId,
expected_name: { type: "string", maxLength: 255 },
expected_parent_id: { type: "string", maxLength: 512 },
name: { type: "string", minLength: 1, maxLength: 255 },
make_unique: { type: "boolean" },
search_query: { type: "string", minLength: 1, maxLength: 4000 },
color_index: { type: "integer", minimum: 0, maximum: 14 },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
if (args.action === "inspect_bin") return invoke(bridge, "bins.inspect", compact({ binId: args.bin_id }));
if (args.action === "create_bin") return invoke(bridge, "bins.create", { ...compact({ parentBinId: args.parent_bin_id, name: args.name, makeUnique: args.make_unique }), ...operation(args) });
if (args.action === "create_smart_bin") return invoke(bridge, "bins.createSmart", { ...compact({ parentBinId: args.parent_bin_id, name: args.name, searchQuery: args.search_query }), ...operation(args) });
if (args.action === "rename") return invoke(bridge, "bins.rename", { ...compact({ projectItemId: args.project_item_id, expectedName: args.expected_name, name: args.name }), ...operation(args) });
if (args.action === "move") return invoke(bridge, "bins.move", { ...compact({ projectItemId: args.project_item_id, destinationBinId: args.destination_bin_id, expectedParentId: args.expected_parent_id }), ...operation(args) });
if (args.action === "set_color") return invoke(bridge, "bins.color", { ...compact({ projectItemId: args.project_item_id, colorIndex: args.color_index }), ...operation(args) });
if (args.action === "remove") return invoke(bridge, "bins.remove", { ...compact({ projectItemId: args.project_item_id, expectedName: args.expected_name }), ...operation(args) });
return invalidAction(args.action);
},
},
manage_sequence_settings_uxp: {
description: "Inspect sequence settings or apply a bounded settings profile in one documented, undoable UXP transaction with readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["get", "update"] },
sequence_id: sequenceId,
updates: {
type: "object",
additionalProperties: false,
properties: {
maximum_bit_depth: { type: "boolean" },
maximum_render_quality: { type: "boolean" },
composite_in_linear_color: { type: "boolean" },
audio_sample_rate: { type: "number", minimum: 1, maximum: 384000 },
video_frame_rate: { type: "number", minimum: 1, maximum: 240 },
video_field_type: { type: "integer", minimum: 0, maximum: 2 },
video_pixel_aspect_ratio: { type: "string", minLength: 1, maxLength: 64 },
editing_mode: { type: "string", minLength: 1, maxLength: 255 },
preview_file_format: { type: "string", minLength: 1, maxLength: 255 },
preview_codec: { type: "string", minLength: 1, maxLength: 255 },
video_width: { type: "integer", minimum: 16, maximum: 32768 },
video_height: { type: "integer", minimum: 16, maximum: 32768 },
},
},
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
if (args.action === "get") return invoke(bridge, "sequenceSettings.get", compact({ sequenceId: args.sequence_id }));
if (args.action !== "update") return invalidAction(args.action);
const source = (args.updates && typeof args.updates === "object" ? args.updates : {}) as Record<string, unknown>;
const updates = compact({
maximumBitDepth: source.maximum_bit_depth,
maxRenderQuality: source.maximum_render_quality,
compositeInLinearColor: source.composite_in_linear_color,
audioSampleRate: source.audio_sample_rate,
videoFrameRate: source.video_frame_rate,
videoFieldType: source.video_field_type,
videoPixelAspectRatio: source.video_pixel_aspect_ratio,
editingMode: source.editing_mode,
previewFileFormat: source.preview_file_format,
previewCodec: source.preview_codec,
videoWidth: source.video_width,
videoHeight: source.video_height,
});
return invoke(bridge, "sequenceSettings.update", { ...compact({ sequenceId: args.sequence_id }), updates, ...operation(args) });
},
},
manage_sequence_display_format_uxp: {
description: "Inspect or update a sequence's native audio/video time-display formats. Updates require the exact inspected sequence GUID and complete display-format snapshot, serialize competing updates per sequence, commit one undoable UXP transaction, and verify native readback. Cancellation is not supported after dispatch.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
sequence_id: sequenceId,
expected_sequence_guid: {
type: "string", minLength: 1, maxLength: 128,
description: "Required for update; copy sequence.guid from inspect. The update targets this GUID even if the active sequence changes.",
},
expected_display_formats: {
type: "object", additionalProperties: false,
properties: {
audio_display_format: { type: "integer", minimum: 0, maximum: MAX_DISPLAY_FORMAT_CODE },
video_display_format: { type: "integer", minimum: 0, maximum: MAX_DISPLAY_FORMAT_CODE },
},
required: ["audio_display_format", "video_display_format"],
description: "Required for update; copy both display-format codes returned by inspect without changes.",
},
updates: {
type: "object", additionalProperties: false,
properties: {
audio_display_format: { type: "integer", minimum: 0, maximum: MAX_DISPLAY_FORMAT_CODE },
video_display_format: { type: "integer", minimum: 0, maximum: MAX_DISPLAY_FORMAT_CODE },
},
description: "One or both documented display-format codes returned in supportedDisplayFormats by inspect.",
},
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
if (args.action === "inspect") return invoke(bridge, "sequence.displayFormat.inspect", compact({ sequenceId: args.sequence_id }));
if (args.action !== "update") return invalidAction(args.action);
if (typeof args.expected_sequence_guid !== "string" || !args.expected_sequence_guid.trim() || args.expected_sequence_guid.length > 128) {
return { success: false, error: "update requires expected_sequence_guid from inspect" };
}
try {
const expectedDisplayFormats = reviewedDisplayFormats(args.expected_display_formats);
const updates = requestedDisplayFormats(args.updates);
return invoke(bridge, "sequence.displayFormat.update", {
...compact({ sequenceId: args.sequence_id }),
expectedSequenceGuid: args.expected_sequence_guid,
expectedDisplayFormats,
updates,
...operation(args),
});
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
},
import_project_media_uxp: {
description: "Import workspace-contained media files, sequences, or After Effects compositions through documented Project APIs with post-state evidence.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["files", "sequences", "ae_comps", "all_ae_comps"] },
paths: { type: "array", minItems: 1, maxItems: 100, items: { type: "string", minLength: 1, maxLength: 4096 } },
project_path: { type: "string", minLength: 1, maxLength: 4096 },
sequence_ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1, maxLength: 128 } },
aep_path: { type: "string", minLength: 1, maxLength: 4096 },
comp_names: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1, maxLength: 255 } },
target_bin_id: projectItemId,
suppress_ui: { type: "boolean" },
as_numbered_stills: { type: "boolean" },
confirm_non_undoable: { type: "boolean" },
operation_id: operationId,
},
required: ["action", "confirm_non_undoable"],
},
handler: async (args: AdvancedArgs) => {
const modes: Record<string, string> = { files: "files", sequences: "sequences", ae_comps: "aeComps", all_ae_comps: "allAEComps" };
if (!args.action || !modes[args.action]) return invalidAction(args.action);
return invoke(bridge, "project.import", { ...compact({
mode: modes[args.action], paths: args.paths, projectPath: args.project_path,
sequenceIds: args.sequence_ids, aepPath: args.aep_path, compNames: args.comp_names,
targetBinId: args.target_bin_id, suppressUI: args.suppress_ui,
asNumberedStills: args.as_numbered_stills,
confirmNonUndoable: args.confirm_non_undoable,
}), ...operation(args) });
},
},
automate_effect_parameters_uxp: {
description: "Inspect or transactionally set scalar effect parameters; inspect or guardedly set static PointF x/y and Color RGBA parameters; locate individual keyframes including a bounded native nearest-range lookup; adjust keyframes/interpolation; and control explicit time-varying animation mode through documented UXP actions. Disabling animation requires a complete inspected keyframe-time snapshot and confirmation.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "inspect_point_value", "inspect_point_displacement", "set_point_value", "inspect_color_value", "set_color_value", "inspect_keyframe", "set_value", "add_keyframe", "remove_keyframe", "remove_keyframe_range", "set_interpolation", "inspect_time_varying", "set_time_varying"] },
...timelineTargetProperties,
component_index: { type: "integer", minimum: 0 },
param_index: { type: "integer", minimum: 0 },
expected_component_id: { type: "string", minLength: 1, maxLength: 256 },
expected_param_name: { type: "string", maxLength: 255 },
value: { type: ["number", "string", "boolean"] },
point: {
type: "object", additionalProperties: false,
properties: {
x: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
y: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
},
required: ["x", "y"],
description: "Requested static PointF x/y value for set_point_value.",
},
color: {
type: "object", additionalProperties: false,
properties: {
red: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
green: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
blue: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
alpha: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
},
required: ["red", "green", "blue", "alpha"],
description: "Requested static Color RGBA value for set_color_value. Values are raw host components, not color-managed or rendered-appearance proof.",
},
expected_point_snapshot: {
type: "object", additionalProperties: false,
properties: {
project_id: { type: "string", minLength: 1, maxLength: 128 },
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
component_index: { type: "integer", minimum: 0 },
component_id: { type: "string", minLength: 1, maxLength: 256 },
param_index: { type: "integer", minimum: 0 },
param_name: { type: "string", maxLength: 255 },
time_varying: { type: "boolean" },
point: {
type: "object", additionalProperties: false,
properties: {
x: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
y: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
},
required: ["x", "y"],
},
},
required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "point"],
description: "Required for set_point_value; copy the complete inspect_point_value snapshot unchanged.",
},
expected_color_snapshot: {
type: "object", additionalProperties: false,
properties: {
project_id: { type: "string", minLength: 1, maxLength: 128 },
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
component_index: { type: "integer", minimum: 0 },
component_id: { type: "string", minLength: 1, maxLength: 256 },
param_index: { type: "integer", minimum: 0 },
param_name: { type: "string", maxLength: 255 },
time_varying: { type: "boolean" },
color: {
type: "object", additionalProperties: false,
properties: {
red: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
green: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
blue: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
alpha: { type: "number", minimum: -1_000_000, maximum: 1_000_000 },
},
required: ["red", "green", "blue", "alpha"],
},
},
required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "color"],
description: "Required for set_color_value; copy the complete inspect_color_value snapshot unchanged.",
},
confirm_set_point: { type: "boolean", description: "Required true for set_point_value after reviewing the complete point snapshot." },
confirm_set_color: { type: "boolean", description: "Required true for set_color_value after reviewing the complete color snapshot." },
time_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400, description: "Required with inspect_keyframe direction nearest as the documented native outTime, inspect_point_displacement as the strictly later PointF sample, and used as the inclusive end for remove_keyframe_range." },
interpolation: { type: "string", enum: ["linear", "hold", "bezier", "time"] },
keyframe_direction: { type: "string", enum: ["at", "next", "previous", "nearest"], description: "Required for inspect_keyframe. nearest requires end_seconds greater than or equal to time_seconds and passes both documented native lookup bounds to Premiere." },
expected_sequence_id: { type: "string", minLength: 1, maxLength: 128, description: "Required for set_time_varying; exact sequence ID from inspect_time_varying." },
expected_time_varying: { type: "boolean", description: "Required for set_time_varying; exact animation-mode value from inspect_time_varying." },
expected_keyframe_times_seconds: { type: "array", maxItems: 256, uniqueItems: true, items: { type: "number", minimum: 0, maximum: 86400 }, description: "Required complete, strictly increasing keyframe-time snapshot for set_time_varying." },
time_varying: { type: "boolean", description: "Requested parameter animation mode for set_time_varying." },
confirm_disable_time_varying: { type: "boolean", description: "Required true when set_time_varying disables animation, because Premiere can discard its editable animation state." },
operation_id: operationId,
},
required: ["action", "media_type", "track_index", "clip_index", "component_index", "param_index"],
},
handler: async (args: AdvancedArgs) => {
const common = compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
timeSeconds: args.time_seconds,
});
const commands: Record<string, string> = {
inspect: "parameters.inspect", set_value: "parameters.set", add_keyframe: "parameters.keyframeAdd",
remove_keyframe: "parameters.keyframeRemove", remove_keyframe_range: "parameters.keyframeRemoveRange",
set_interpolation: "parameters.keyframeInterpolation",
inspect_keyframe: "parameters.keyframe.inspect",
inspect_time_varying: "parameters.timeVarying.inspect", set_time_varying: "parameters.timeVarying.set",
inspect_point_value: "parameters.point.inspect", set_point_value: "parameters.point.set",
inspect_point_displacement: "parameters.point.displacement.inspect",
inspect_color_value: "parameters.color.inspect", set_color_value: "parameters.color.set",
};
if (!args.action || !commands[args.action]) return invalidAction(args.action);
if (args.action === "inspect_keyframe") {
return invoke(bridge, commands[args.action], { ...common, ...compact({ direction: args.keyframe_direction, endSeconds: args.end_seconds }) });
}
if (args.action === "inspect_time_varying") {
return invoke(bridge, commands[args.action], compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
}));
}
if (args.action === "set_time_varying") {
return invoke(bridge, commands[args.action], {
...compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedSequenceId: args.expected_sequence_id, expectedComponentId: args.expected_component_id,
expectedParamName: args.expected_param_name, expectedTimeVarying: args.expected_time_varying,
expectedKeyframeTimesSeconds: args.expected_keyframe_times_seconds,
timeVarying: args.time_varying, confirmDisableTimeVarying: args.confirm_disable_time_varying,
}), ...operation(args),
});
}
if (args.action === "inspect_point_value") {
return invoke(bridge, commands[args.action], compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
}));
}
if (args.action === "inspect_point_displacement") {
return invoke(bridge, commands[args.action], compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
startSeconds: args.time_seconds, endSeconds: args.end_seconds,
}));
}
if (args.action === "set_point_value") {
const raw = args.expected_point_snapshot;
const snapshot = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : null;
if (!snapshot) return { success: false, error: "set_point_value requires expected_point_snapshot from inspect_point_value" };
return invoke(bridge, commands[args.action], {
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
point: args.point,
expectedSnapshot: {
projectId: snapshot.project_id, sequenceId: snapshot.sequence_id, mediaType: snapshot.media_type,
trackIndex: snapshot.track_index, clipIndex: snapshot.clip_index, componentIndex: snapshot.component_index,
componentId: snapshot.component_id, paramIndex: snapshot.param_index, paramName: snapshot.param_name,
timeVarying: snapshot.time_varying,
point: snapshot.point,
},
confirmSetPoint: args.confirm_set_point,
...operation(args),
});
}
if (args.action === "inspect_color_value") {
return invoke(bridge, commands[args.action], compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
}));
}
if (args.action === "set_color_value") {
const raw = args.expected_color_snapshot;
const snapshot = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : null;
if (!snapshot) return { success: false, error: "set_color_value requires expected_color_snapshot from inspect_color_value" };
return invoke(bridge, commands[args.action], {
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
componentIndex: args.component_index, paramIndex: args.param_index,
expectedComponentId: args.expected_component_id, expectedParamName: args.expected_param_name,
color: args.color,
expectedSnapshot: {
projectId: snapshot.project_id, sequenceId: snapshot.sequence_id, mediaType: snapshot.media_type,
trackIndex: snapshot.track_index, clipIndex: snapshot.clip_index, componentIndex: snapshot.component_index,
componentId: snapshot.component_id, paramIndex: snapshot.param_index, paramName: snapshot.param_name,
timeVarying: snapshot.time_varying,
color: snapshot.color,
},
confirmSetColor: args.confirm_set_color,
...operation(args),
});
}
return invoke(bridge, commands[args.action], { ...common, ...compact({ value: args.value, endSeconds: args.end_seconds, interpolation: args.interpolation }), ...operation(args) });
},
},
transform_track_item_uxp: {
description: "Inspect or atomically move, trim, rename, and enable/disable one audio or video track item with stale-position guards and readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
...timelineTargetProperties,
expected_start_seconds: { type: "number", minimum: 0, maximum: 86400 },
expected_end_seconds: { type: "number", minimum: 0, maximum: 86400 },
move_by_seconds: { type: "number", minimum: -86400, maximum: 86400 },
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400 },
in_seconds: { type: "number", minimum: 0, maximum: 86400 },
out_seconds: { type: "number", minimum: 0, maximum: 86400 },
disabled: { type: "boolean" },
name: { type: "string", minLength: 1, maxLength: 255 },
operation_id: operationId,
},
required: ["action", "media_type", "track_index", "clip_index"],
},
handler: async (args: AdvancedArgs) => {
const values = compact({
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
expectedStartSeconds: args.expected_start_seconds, expectedEndSeconds: args.expected_end_seconds,
moveBySeconds: args.move_by_seconds, startSeconds: args.start_seconds, endSeconds: args.end_seconds,
inSeconds: args.in_seconds, outSeconds: args.out_seconds, disabled: args.disabled, name: args.name,
});
if (args.action === "inspect") return invoke(bridge, "trackItem.inspect", values);
if (args.action === "update") return invoke(bridge, "trackItem.update", { ...values, ...operation(args) });
return invalidAction(args.action);
},
},
inspect_sequence_structure_uxp: {
description: "Read a bounded native UXP timeline structure for one sequence: selected video and/or audio tracks with clip timing and state. Opt in to source Project-item IDs only when needed, then optionally classify those sources as sequence, merged, multicam, or offline or return their documented broad content category (any, sequence, or media); a further opt-in returns only a nested source sequence GUID when that classification is exactly sequence. No Project-panel metadata, names, type codes, paths, nested timeline content, or traversal are read. track_counts contains only the requested media types, never a zero placeholder for an unqueried type. The response is capped at 64 tracks and 512 items, omits components, rendered pixels, audio analysis, and caption cues, and is not a locked cross-object snapshot or proof of playback or editorial correctness.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
expected_sequence_id: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable sequence ID from a prior UXP snapshot. Rejects an active-sequence request if it changed." },
media_type: { type: "string", enum: ["all", "video", "audio"] },
track_indices: {
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
items: { type: "integer", minimum: 0, maximum: 1023 },
},
include_empty_tracks: { type: "boolean", description: "Return selected empty tracks instead of omitting them." },
include_source_project_items: { type: "boolean", description: "Opt in to each returned timeline clip's stable source Project-item ID. The default omits it; this never reads Project-panel metadata, names, types, or media paths." },
include_source_project_item_classification: { type: "boolean", description: "Requires include_source_project_items. Also return documented read-only source flags for sequence, merged-clip, multicam-clip, and offline status; unavailable flags are null. It never reads Project-panel metadata, names, types, paths, or project-tree state." },
include_source_project_item_content_type: { type: "boolean", description: "Requires include_source_project_items. Also return only the documented ClipProjectItem content category: any, sequence, or media; unavailable or unrecognized values are null. It never returns a Project-panel type code, name, metadata, path, or tree state." },
include_source_nested_sequence_identity: { type: "boolean", description: "Requires source Project-item IDs and classification. For a source classified exactly as a sequence, return only its documented nested sequence GUID; unavailable or non-sequence sources return null. It never reads the nested sequence's content or Project-panel state." },
max_items: { type: "integer", minimum: 1, maximum: 512, description: "Hard limit for all returned clip snapshots; the request fails rather than returning a partial timeline." },
},
},
handler: async (args: AdvancedArgs) => invoke(bridge, "timeline.structure.inspect", compact({
sequenceId: args.sequence_id, expectedSequenceId: args.expected_sequence_id,
mediaType: args.media_type, trackIndices: args.track_indices,
includeEmptyTracks: args.include_empty_tracks, includeSourceProjectItems: args.include_source_project_items,
includeSourceProjectItemClassification: args.include_source_project_item_classification,
includeSourceProjectItemContentType: args.include_source_project_item_content_type,
includeSourceNestedSequenceIdentity: args.include_source_nested_sequence_identity, maxItems: args.max_items,
})),
},
make_split_edit_uxp: {
description: "Create an undoable J-cut or L-cut by extending one aligned 1x audio item while preserving source sync, with atomic UXP actions and edge/source readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
kind: { type: "string", enum: ["j_cut", "l_cut"] },
audio_track_index: { type: "integer", minimum: 0 }, audio_clip_index: { type: "integer", minimum: 0 },
video_track_index: { type: "integer", minimum: 0 }, video_clip_index: { type: "integer", minimum: 0 },
extension_seconds: { type: "number", minimum: 0.001, maximum: 60 },
operation_id: operationId,
},
required: ["kind", "audio_track_index", "audio_clip_index", "video_track_index", "video_clip_index", "extension_seconds"],
},
handler: async (args: AdvancedArgs) => invoke(bridge, "trackItem.splitEdit", {
kind: args.kind, audioTrackIndex: args.audio_track_index, audioClipIndex: args.audio_clip_index,
videoTrackIndex: args.video_track_index, videoClipIndex: args.video_clip_index,
extensionSeconds: args.extension_seconds, ...operation(args),
}),
},
edit_timeline_uxp: {
description: "Use the documented SequenceEditor to insert, overwrite, clone, remove, or insert MOGRT content without undocumented QE calls.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["insert", "overwrite", "clone_selection", "remove_selection", "insert_mogrt_path", "insert_mogrt_library"] },
project_item_id: projectItemId,
...timelinePositionProperties,
limit_shift: { type: "boolean" },
time_offset_seconds: { type: "number", minimum: -86400, maximum: 86400 },
video_track_offset: { type: "integer", minimum: -128, maximum: 128 },
audio_track_offset: { type: "integer", minimum: -128, maximum: 128 },
align_to_video: { type: "boolean" },
insert: { type: "boolean" },
ripple: { type: "boolean" },
media_type: { type: "string", enum: ["any", "video", "audio"] },
shift_overlapping: { type: "boolean" },
file_path: { type: "string", minLength: 1, maxLength: 4096 },
library_name: { type: "string", minLength: 1, maxLength: 255 },
element_name: { type: "string", minLength: 1, maxLength: 255 },
confirm_non_undoable: { type: "boolean" },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
const common = compact({
projectItemId: args.project_item_id, timeSeconds: args.time_seconds,
videoTrackIndex: args.video_track_index, audioTrackIndex: args.audio_track_index,
limitShift: args.limit_shift, timeOffsetSeconds: args.time_offset_seconds,
videoTrackOffset: args.video_track_offset, audioTrackOffset: args.audio_track_offset,
alignToVideo: args.align_to_video, insert: args.insert, ripple: args.ripple,
mediaType: args.media_type, shiftOverlapping: args.shift_overlapping,
filePath: args.file_path, libraryName: args.library_name, elementName: args.element_name,
confirmNonUndoable: args.confirm_non_undoable,
});
const commands: Record<string, string> = {
insert: "timeline.insert", overwrite: "timeline.overwrite", clone_selection: "timeline.cloneSelection",
remove_selection: "timeline.removeSelection", insert_mogrt_path: "timeline.mogrtPath",
insert_mogrt_library: "timeline.mogrtLibrary",
};
if (!args.action || !commands[args.action]) return invalidAction(args.action);
return invoke(bridge, commands[args.action], { ...common, ...operation(args) });
},
},
manage_sequences_uxp: {
description: "Inspect, create-from-media, clone, derive, activate, open, close, or explicitly delete sequences through documented stable UXP APIs.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "create_from_media", "clone", "subsequence", "activate", "open", "close", "delete"] },
sequence_id: sequenceId,
expected_name: { type: "string", maxLength: 255 },
name: { type: "string", minLength: 1, maxLength: 255 },
project_item_ids: { type: "array", minItems: 1, maxItems: 64, items: projectItemId },
target_bin_id: projectItemId,
ignore_track_targeting: { type: "boolean" },
confirm_non_undoable: { type: "boolean" },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
const common = compact({
sequenceId: args.sequence_id, expectedName: args.expected_name, name: args.name,
projectItemIds: args.project_item_ids, targetBinId: args.target_bin_id,
ignoreTrackTargeting: args.ignore_track_targeting, confirmNonUndoable: args.confirm_non_undoable,
});
const commands: Record<string, string> = {
inspect: "sequences.inspect", create_from_media: "sequences.createFromMedia", clone: "sequences.clone",
subsequence: "sequences.subsequence", activate: "sequences.activate", open: "sequences.open",
close: "sequences.close", delete: "sequences.delete",
};
if (!args.action || !commands[args.action]) return invalidAction(args.action);
return invoke(bridge, commands[args.action], { ...common, ...operation(args) });
},
},
encode_media_uxp: {
description: "Preflight, queue, or inspect and wait for conservatively correlated AME receipts inside the approved workspace. A terminal event is not output-file verification.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["preflight", "jobs", "wait", "sequence", "project_item", "file"] },
job_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
timeout_ms: { type: "integer", minimum: 0, maximum: 60000 },
limit: { type: "integer", minimum: 1, maximum: 64 },
sequence_id: sequenceId,
project_item_id: projectItemId,
export_type: { type: "string", enum: ["queue_to_ame", "queue_to_app", "immediately"] },
file_path: { type: "string", minLength: 1, maxLength: 4096 },
output_file: { type: "string", minLength: 1, maxLength: 4096 },
preset_file: { type: "string", minLength: 1, maxLength: 4096 },
export_full: { type: "boolean" },
in_seconds: { type: "number", minimum: 0, maximum: 86400 },
out_seconds: { type: "number", minimum: 0, maximum: 86400 },
work_area: { type: "integer", minimum: 0, maximum: 16 },
remove_upon_completion: { type: "boolean" },
start_queue_immediately: { type: "boolean" },
confirm_external_write: { type: "boolean" },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: AdvancedArgs) => {
const exportTypes: Record<string, string> = { queue_to_ame: "queueToAme", queue_to_app: "queueToApp", immediately: "immediately" };
const common = compact({
sequenceId: args.sequence_id, projectItemId: args.project_item_id,
exportType: typeof args.export_type === "string" ? exportTypes[args.export_type] : undefined,
filePath: args.file_path, outputFile: args.output_file, presetFile: args.preset_file,
exportFull: args.export_full, inSeconds: args.in_seconds, outSeconds: args.out_seconds,
workArea: args.work_area, removeUponCompletion: args.remove_upon_completion,
startQueueImmediately: args.start_queue_immediately, confirmExternalWrite: args.confirm_external_write,
});
const commands: Record<string, string> = {
preflight: "encoder.preflight", jobs: "encoder.jobs", wait: "encoder.wait", sequence: "encoder.sequence",
project_item: "encoder.projectItem", file: "encoder.file",
};
if (!args.action || !commands[args.action]) return invalidAction(args.action);
const jobQuery = compact({ jobId: args.job_id, timeoutMs: args.timeout_ms, limit: args.limit });
if (args.action === "wait") {
const hostWaitMs = typeof args.timeout_ms === "number" ? args.timeout_ms : 0;
return invoke(bridge, commands[args.action], jobQuery, hostWaitMs);
}
return invoke(bridge, commands[args.action], args.action === "jobs"
? jobQuery
: { ...common, ...operation(args) });
},
},
};
}
+109
View File
@@ -0,0 +1,109 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type ItemSnapshot = {
project_item_id: string;
start_seconds: number; end_seconds: number; in_seconds: number; out_seconds: number;
duration_seconds: number; speed: number; reversed: boolean;
};
type CloneArgs = {
action: "inspect" | "apply";
media_type: "video" | "audio";
track_index: number;
clip_index: number;
expected_snapshot?: {
project_guid: string; sequence_id: string; media_type: "video" | "audio";
track_index: number; clip_index: number; track_item_count: number; source: ItemSnapshot;
};
confirm_duplicate?: boolean;
operation_id?: string;
};
const itemSnapshotProperties = {
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400 },
in_seconds: { type: "number", minimum: 0, maximum: 86400 },
out_seconds: { type: "number", minimum: 0, maximum: 86400 },
duration_seconds: { type: "number", minimum: 0, maximum: 86400 },
speed: { type: "number", minimum: 0, maximum: 100 },
reversed: { type: "boolean" },
} as const;
const itemSnapshotRequired = ["project_item_id", "start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed"];
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function strictItemSnapshot(value: unknown, name: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) if (!(key in itemSnapshotProperties)) throw new Error(`${name} has an unknown field: ${key}`);
for (const key of itemSnapshotRequired) if (!(key in record)) throw new Error(`${name}.${key} is required`);
return {
projectItemId: record.project_item_id,
startSeconds: record.start_seconds, endSeconds: record.end_seconds, inSeconds: record.in_seconds,
outSeconds: record.out_seconds, durationSeconds: record.duration_seconds, speed: record.speed, reversed: record.reversed,
};
}
function expectedCloneSnapshot(value: CloneArgs["expected_snapshot"]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected_snapshot is required for action: apply");
const record = value as Record<string, unknown>;
const allowed = ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "source"];
for (const key of Object.keys(record)) if (!allowed.includes(key)) throw new Error(`expected_snapshot has an unknown field: ${key}`);
for (const key of allowed) if (!(key in record)) throw new Error(`expected_snapshot.${key} is required`);
return {
projectGuid: record.project_guid, sequenceId: record.sequence_id, mediaType: record.media_type,
trackIndex: record.track_index, clipIndex: record.clip_index, trackItemCount: record.track_item_count,
source: strictItemSnapshot(record.source, "expected_snapshot.source"),
};
}
/** A bounded documented-UXP append-only counterpart to a timeline duplicate. */
export function getUxpCloneWorkflowTools(bridge: UxpWebSocketBridge) {
return {
duplicate_track_item_uxp: {
description: "Inspect or append one guarded duplicate of the final audio or video clip on a track using documented UXP SequenceEditor actions. Apply requires the complete source snapshot, explicit confirmation, and an operation ID; it serializes with guarded slips and slides on that track, commits one transaction, and reads back only the original and appended item. It intentionally cannot duplicate into an occupied range, another track, or a linked A/V pair. It does not prove media handles, linked A/V synchronization, rendered frames, playback, persistence, or Undo behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "apply"] },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511, description: "The final clip item on the requested track. Inspection rejects a non-final item." },
expected_snapshot: {
type: "object", additionalProperties: false,
required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "source"],
properties: {
project_guid: { type: "string", minLength: 1, maxLength: 128 }, sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] }, track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511 }, track_item_count: { type: "integer", minimum: 1, maximum: 512 },
source: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
},
},
confirm_duplicate: { type: "boolean", description: "Must be true for action: apply." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required replay-safe operation identifier for action: apply." },
},
required: ["action", "media_type", "track_index", "clip_index"],
},
annotations: { readOnlyHint: false, destructiveHint: true },
availability: {
backend: "uxp", minimumPremiereVersion: "25.6.0", capability: "trackItem.clone.inspect|trackItem.clone",
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises trackItem.clone.inspect and trackItem.clone.", "A verification failure can follow a committed host transaction; inspect the source and appended item before another edit."],
},
handler: async (args: CloneArgs) => {
const target = { mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index };
if (args.action === "inspect") return invoke(bridge, "trackItem.clone.inspect", target);
if (args.action === "apply") return invoke(bridge, "trackItem.clone", {
...target, expectedSnapshot: expectedCloneSnapshot(args.expected_snapshot),
confirmDuplicate: args.confirm_duplicate, operationId: args.operation_id,
});
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+162
View File
@@ -0,0 +1,162 @@
import { createHash } from "node:crypto";
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type Mode = "talking_head" | "podcast";
type DerivedSegment = {
id: string;
source_project_item_id: string;
transcript_revision: string;
source_start_seconds: number;
source_end_seconds: number;
speaker_label?: string;
master_audio_start_seconds?: number;
master_audio_end_seconds?: number;
};
export type DerivedDialoguePlan = {
schema_version: 1;
project_guid: string;
mode: Mode;
sequence_name: string;
target_bin_id?: string;
master_audio_project_item_id?: string;
segments: DerivedSegment[];
output_duration_seconds: number;
original_sources_unchanged: true;
render_verified: false;
};
function requiredText(value: unknown, label: string, max: number): string {
if (typeof value !== "string" || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
return value.trim();
}
function seconds(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 86_400) throw new Error(`${label} must be a finite number from 0 to 86400`);
return Number(value.toFixed(6));
}
function token(plan: DerivedDialoguePlan): string { return `sha256:${createHash("sha256").update(JSON.stringify(plan)).digest("hex")}`; }
export function validateDerivedDialoguePlan(value: unknown): DerivedDialoguePlan {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("plan must be an object");
const input = value as Record<string, unknown>;
const allowed = ["schema_version", "project_guid", "mode", "sequence_name", "target_bin_id", "master_audio_project_item_id", "segments", "output_duration_seconds", "original_sources_unchanged", "render_verified"];
const unknown = Object.keys(input).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`plan has an unknown field: ${unknown}`);
if (input.schema_version !== 1) throw new Error("plan.schema_version must be 1");
if (input.mode !== "talking_head" && input.mode !== "podcast") throw new Error("plan.mode must be talking_head or podcast");
if (!Array.isArray(input.segments) || input.segments.length === 0 || input.segments.length > 64) throw new Error("plan.segments must contain between 1 and 64 entries");
const ids = new Set<string>();
const segments = input.segments.map((raw, index) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`plan.segments[${index}] must be an object`);
const item = raw as Record<string, unknown>;
const keys = ["id", "source_project_item_id", "transcript_revision", "source_start_seconds", "source_end_seconds", "speaker_label", "master_audio_start_seconds", "master_audio_end_seconds"];
const extra = Object.keys(item).find((key) => !keys.includes(key));
if (extra) throw new Error(`plan.segments[${index}] has an unknown field: ${extra}`);
const id = requiredText(item.id, `plan.segments[${index}].id`, 128);
if (ids.has(id)) throw new Error(`plan.segments contains duplicate id: ${id}`); ids.add(id);
const start = seconds(item.source_start_seconds, `plan.segments[${index}].source_start_seconds`);
const end = seconds(item.source_end_seconds, `plan.segments[${index}].source_end_seconds`);
if (end <= start) throw new Error(`plan.segments[${index}] source range must have positive duration`);
const revision = requiredText(item.transcript_revision, `plan.segments[${index}].transcript_revision`, 71);
if (!/^sha256:[a-f0-9]{64}$/.test(revision)) throw new Error(`plan.segments[${index}].transcript_revision must be a sha256 revision`);
const speaker = item.speaker_label === undefined ? undefined : requiredText(item.speaker_label, `plan.segments[${index}].speaker_label`, 128);
const masterStart = item.master_audio_start_seconds === undefined ? undefined : seconds(item.master_audio_start_seconds, `plan.segments[${index}].master_audio_start_seconds`);
const masterEnd = item.master_audio_end_seconds === undefined ? undefined : seconds(item.master_audio_end_seconds, `plan.segments[${index}].master_audio_end_seconds`);
if (input.mode === "podcast" && (masterStart === undefined || masterEnd === undefined || masterEnd <= masterStart || Math.abs((masterEnd - masterStart) - (end - start)) > 0.001)) throw new Error(`plan.segments[${index}] requires a positive master-audio range matching the video duration in podcast mode`);
if (input.mode === "talking_head" && (masterStart !== undefined || masterEnd !== undefined)) throw new Error(`plan.segments[${index}] must omit master-audio ranges in talking_head mode`);
return { id, source_project_item_id: requiredText(item.source_project_item_id, `plan.segments[${index}].source_project_item_id`, 512), transcript_revision: revision, source_start_seconds: start, source_end_seconds: end, ...(speaker ? { speaker_label: speaker } : {}), ...(masterStart === undefined ? {} : { master_audio_start_seconds: masterStart, master_audio_end_seconds: masterEnd }) };
});
if (input.mode === "talking_head" && new Set(segments.map((item) => item.source_project_item_id)).size !== 1) throw new Error("talking_head mode requires every segment to use the same source project item");
const masterAudio = input.master_audio_project_item_id === undefined ? undefined : requiredText(input.master_audio_project_item_id, "plan.master_audio_project_item_id", 512);
if (input.mode === "podcast" && !masterAudio) throw new Error("podcast mode requires master_audio_project_item_id");
if (input.mode === "talking_head" && masterAudio) throw new Error("talking_head mode must omit master_audio_project_item_id");
const duration = Number(segments.reduce((sum, item) => sum + item.source_end_seconds - item.source_start_seconds, 0).toFixed(6));
if (typeof input.output_duration_seconds !== "number" || Math.abs(input.output_duration_seconds - duration) > 0.001) throw new Error("plan.output_duration_seconds does not match its segments");
if (input.original_sources_unchanged !== true || input.render_verified !== false) throw new Error("plan must retain original_sources_unchanged true and render_verified false");
return { schema_version: 1, project_guid: requiredText(input.project_guid, "plan.project_guid", 512), mode: input.mode, sequence_name: requiredText(input.sequence_name, "plan.sequence_name", 255), ...(input.target_bin_id === undefined ? {} : { target_bin_id: requiredText(input.target_bin_id, "plan.target_bin_id", 512) }), ...(masterAudio ? { master_audio_project_item_id: masterAudio } : {}), segments, output_duration_seconds: duration, original_sources_unchanged: true, render_verified: false };
}
async function currentRevisions(bridge: UxpWebSocketBridge, plan: DerivedDialoguePlan) {
const expected = new Map<string, string>();
for (const segment of plan.segments) {
const prior = expected.get(segment.source_project_item_id);
if (prior && prior !== segment.transcript_revision) throw new Error(`source ${segment.source_project_item_id} has conflicting transcript revisions`);
expected.set(segment.source_project_item_id, segment.transcript_revision);
}
for (const [projectItemId, revision] of expected) {
const result = await bridge.request("transcript.export", { projectItemId }) as { json?: unknown; projectGuid?: unknown };
if (result.projectGuid !== plan.project_guid) throw new Error("active project changed while validating the dialogue plan");
if (typeof result.json !== "string") throw new Error(`Premiere returned no transcript for source ${projectItemId}`);
const actual = `sha256:${createHash("sha256").update(result.json).digest("hex")}`;
if (actual !== revision) throw new Error(`transcript changed for source ${projectItemId}; preview again`);
}
}
const segmentSchema = { type: "object" as const, additionalProperties: false, properties: {
id: { type: "string", minLength: 1, maxLength: 128, description: "Stable approved-segment ID." },
source_project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Exact video or linked A/V source project-item ID." },
transcript_revision: { type: "string", pattern: "^sha256:[a-f0-9]{64}$", description: "Exact current source transcript revision." },
source_start_seconds: { type: "number", minimum: 0, maximum: 86400, description: "Approved source-video start." },
source_end_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400, description: "Approved source-video end." },
speaker_label: { type: "string", minLength: 1, maxLength: 128, description: "Optional reviewed speaker label." },
master_audio_start_seconds: { type: "number", minimum: 0, maximum: 86400, description: "Podcast master-audio source start matching this video segment." },
master_audio_end_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400, description: "Podcast master-audio source end matching this video segment." },
}, required: ["id", "source_project_item_id", "transcript_revision", "source_start_seconds", "source_end_seconds"] };
const planSchema = { type: "object" as const, additionalProperties: false, description: "Canonical plan returned by preview_derived_dialogue_sequence_uxp.", properties: {
schema_version: { type: "integer", enum: [1], description: "Plan schema version." },
project_guid: { type: "string", minLength: 1, maxLength: 512, description: "Active project GUID." },
mode: { type: "string", enum: ["talking_head", "podcast"], description: "Derivative assembly mode." },
sequence_name: { type: "string", minLength: 1, maxLength: 255, description: "Unique name for the new ordinary sequence." },
target_bin_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional destination-bin ID for the derivative sequence." },
master_audio_project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Required podcast master-audio project-item ID." },
segments: { type: "array", minItems: 1, maxItems: 64, items: segmentSchema, description: "Approved output segments in final sequence order." },
output_duration_seconds: { type: "number", exclusiveMinimum: 0, maximum: 86400, description: "Sum of approved segment durations." },
original_sources_unchanged: { type: "boolean", description: "Must remain true." },
render_verified: { type: "boolean", description: "Must remain false until rendered output is reviewed." },
}, required: ["schema_version", "project_guid", "mode", "sequence_name", "segments", "output_duration_seconds", "original_sources_unchanged", "render_verified"] };
export function getUxpDialogueWorkflowTools(bridge: UxpWebSocketBridge) {
return {
preview_derived_dialogue_sequence_uxp: {
description: "Validate transcript revisions and preview a talking-head or speaker-reviewed podcast derivative. It creates nothing and returns an exact confirmation token.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
project_guid: { type: "string", minLength: 1, maxLength: 512, description: "Active project GUID from UXP inspection." },
mode: { type: "string", enum: ["talking_head", "podcast"], description: "Talking-head linked A/V assembly or podcast video plus reviewed master-audio assembly." },
sequence_name: { type: "string", minLength: 1, maxLength: 255, description: "Unique name for the new derivative sequence." },
target_bin_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional destination-bin ID." },
master_audio_project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Required in podcast mode; source for audio-only subclips." },
segments: { type: "array", minItems: 1, maxItems: 64, items: segmentSchema, description: "Reviewed segments in desired output order." },
}, required: ["project_guid", "mode", "sequence_name", "segments"] },
operationalCapability: { backend: "UXP" as const, backends: ["uxp" as const], minimumPremiereVersion: "26.3", verificationBoundary: "transcript_revision_readback" as const, hostVerificationRequired: true, notes: ["Requires an authenticated UXP bridge and current native transcripts.", "Does not prove editorial correctness, rendered pixels, or playback."] },
handler: async (args: Record<string, unknown>) => {
try {
const rawSegments = Array.isArray(args.segments) ? args.segments as Array<Record<string, unknown>> : [];
const duration = Number(rawSegments.reduce((sum, item) => sum + (Number(item.source_end_seconds) - Number(item.source_start_seconds)), 0).toFixed(6));
const plan = validateDerivedDialoguePlan({ schema_version: 1, ...args, output_duration_seconds: duration, original_sources_unchanged: true, render_verified: false });
await currentRevisions(bridge, plan);
return { success: true, data: { backend: "uxp", plan, confirmation_token: token(plan), applied: false, limitations: ["The derivative is an ordinary sequence, not a native multicam angle edit.", "Speaker and synchronization assignments are caller-reviewed.", "Rendered pixels, playback, persistence, and Undo remain unverified."] } };
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
apply_derived_dialogue_sequence_uxp: {
description: "Create a new ordinary dialogue derivative from an exact reviewed plan. It revalidates every transcript and never edits or deletes an original source or sequence.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
plan: planSchema,
confirmation_token: { type: "string", pattern: "^sha256:[a-f0-9]{64}$", description: "Exact token returned by preview_derived_dialogue_sequence_uxp." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required idempotency key for the host mutation." },
}, required: ["plan", "confirmation_token", "operation_id"] },
operationalCapability: { backend: "UXP" as const, backends: ["uxp" as const], minimumPremiereVersion: "26.3", verificationBoundary: "structural_uxp_readback" as const, hostVerificationRequired: true, notes: ["Creates an ordinary derivative sequence from a reviewed plan; native multicam mutation is unsupported.", "The receipt does not prove rendered pixels, playback, persistence after reopen, or Undo behavior."] },
handler: async (args: Record<string, unknown>) => {
try {
const plan = validateDerivedDialoguePlan(args.plan);
if (args.confirmation_token !== token(plan)) throw new Error("confirmation_token does not match this dialogue plan; preview again");
const operationId = requiredText(args.operation_id, "operation_id", 128);
if (!/^[A-Za-z0-9._:-]+$/.test(operationId)) throw new Error("operation_id contains unsupported characters");
await currentRevisions(bridge, plan);
const result = await bridge.request("dialogue.deriveSequence", { plan, operationId });
return { success: true, data: { backend: "uxp", result } };
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
};
}
+57
View File
@@ -0,0 +1,57 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type EffectParameterCatalogArgs = {
media_type: "video" | "audio";
track_index: number;
clip_index: number;
component_index: number;
expected_sequence_guid?: string;
expected_component_id?: string;
};
function invoke(bridge: UxpWebSocketBridge, args: Record<string, unknown>) {
return bridge.request("parameters.catalog.inspect", args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
/** Bounded component-parameter discovery without reading raw parameter values. */
export function getUxpEffectParameterCatalogWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_effect_parameter_catalog_uxp: {
description: "Inspect the bounded native descriptor catalog for one effect component on one active-sequence audio or video clip. The returned parameter index and display name can be used with existing parameter automation. It returns only animation capability/state, never raw parameter values (including Color or PointF), media paths, rendered pixels, or playback results. The bridge reads the complete catalog twice and rejects a changed project, active sequence, component identity, or parameter descriptor set.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511 },
component_index: { type: "integer", minimum: 0, maximum: 511 },
expected_sequence_guid: { type: "string", minLength: 1, maxLength: 512, description: "Optional active-sequence GUID from a prior catalog inspection. A changed sequence is rejected." },
expected_component_id: { type: "string", minLength: 1, maxLength: 512, description: "Optional component match or display name returned by a prior catalog inspection. A changed component is rejected." },
},
required: ["media_type", "track_index", "clip_index", "component_index"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: ["Requires an authenticated UXP bridge whose capability handshake advertises parameters.catalog.inspect.", "Contract tests do not prove a licensed Premiere host, parameter values, editability, rendered output, playback, persistence, or Undo behavior."],
},
handler: async (args: EffectParameterCatalogArgs) => invoke(bridge, {
mediaType: args.media_type,
trackIndex: args.track_index,
clipIndex: args.clip_index,
componentIndex: args.component_index,
...(args.expected_sequence_guid === undefined ? {} : { expectedSequenceGuid: args.expected_sequence_guid }),
...(args.expected_component_id === undefined ? {} : { expectedComponentId: args.expected_component_id }),
}),
},
};
}
+626
View File
@@ -0,0 +1,626 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
const WAIT_RESPONSE_BUFFER_MS = 5_000;
type EventArgs = {
action?: string;
after_revision?: number;
categories?: string[];
event_names?: string[];
limit?: number;
timeout_ms?: number;
};
type ReadinessArgs = {
action?: string;
sequence_id?: string;
expected_sequence_id?: string;
operation_type?: string;
after_revision?: number;
timeout_ms?: number;
poll_min_ms?: number;
poll_max_ms?: number;
};
type ProjectSessionArgs = {
action?: string;
project_id?: string;
expected_path?: string;
path?: string;
paths?: string[];
include_paths?: boolean;
show_dialogs?: boolean;
add_to_mru?: boolean;
save_before_close?: boolean;
confirm_external_write?: boolean;
confirm_overwrite?: boolean;
confirm_close?: boolean;
confirm_discard_unsaved?: boolean;
operation_id?: string;
};
type GrowingMediaArgs = {
action?: string;
project_id?: string;
expected_path?: string;
lease_ms?: number;
confirm_pause?: boolean;
operation_id?: string;
};
type CheckpointArgs = {
action?: string;
owner?: string;
sequence_id?: string;
expected_owner_id?: string;
name?: string;
value_type?: string;
value?: string | number | boolean;
persistence?: string;
operation_id?: string;
};
type MediaHealthArgs = {
action?: string;
project_item_ids?: string[];
project_item_id?: string;
expected_offline?: boolean;
confirm_set_offline?: boolean;
match_path?: string;
ignore_subclips?: boolean;
include_paths?: boolean;
include_media_timing?: boolean;
operation_id?: string;
};
type SourceMediaTimingArgs = {
action?: string;
project_item_id?: string;
expected_timing?: {
start_seconds?: number;
duration_seconds?: number;
};
start_seconds?: number;
confirm_set_start?: boolean;
operation_id?: string;
};
type SourceMediaOverridesArgs = {
action?: string;
project_item_id?: string;
expected_overrides?: {
project_guid?: string;
frame_rate?: number;
pixel_aspect_ratio?: number;
};
frame_rate?: number;
pixel_aspect_ratio?: {
numerator?: number;
denominator?: number;
};
confirm_media_interpretation?: boolean;
operation_id?: string;
};
type TrackStateArgs = {
action?: string;
sequence_id?: string;
expected_sequence_id?: string;
media_type?: string;
track_indices?: number[];
muted?: boolean;
expected_muted?: boolean;
operation_id?: string;
};
type SourceClipItem = {
project_item_id?: string;
media_type?: string;
expected_in_seconds?: number;
expected_out_seconds?: number;
in_seconds?: number;
out_seconds?: number;
clear_in_out?: boolean;
scale_to_frame?: boolean;
};
type SourceClipArgs = {
action?: string;
items?: SourceClipItem[];
operation_id?: string;
};
function invoke(
bridge: UxpWebSocketBridge,
command: string,
args: Record<string, unknown> = {},
hostWaitMs?: number,
) {
const request = hostWaitMs === undefined
? bridge.request(command, args)
: bridge.request(command, args, { minimumTimeoutMs: hostWaitMs + WAIT_RESPONSE_BUFFER_MS });
return request
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
function eventQuery(args: EventArgs, includeTimeout: boolean) {
return {
...(args.after_revision !== undefined ? { afterRevision: args.after_revision } : {}),
...(args.categories !== undefined ? { categories: args.categories } : {}),
...(args.event_names !== undefined ? { eventNames: args.event_names } : {}),
...(args.limit !== undefined ? { limit: args.limit } : {}),
...(includeTimeout && args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {}),
};
}
export function getUxpNextWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_premiere_events_uxp: {
description: "List or briefly wait for bounded, redacted Premiere host-event receipts without polling the complete project state. Compatible hosts can also emit timeline.snap.* receipts plus operation.clip.extend.reached and coalesced operation.effect.drag.over receipts for documented root notifications; raw event payloads are never returned.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["list", "wait"] },
after_revision: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
categories: {
type: "array", maxItems: 32, uniqueItems: true,
items: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
event_names: {
type: "array", maxItems: 32, uniqueItems: true,
items: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
limit: { type: "integer", minimum: 1, maximum: 256 },
timeout_ms: {
type: "integer", minimum: 0, maximum: 60000,
description: "Used only by wait; zero performs a non-blocking check.",
},
},
required: ["action"],
},
handler: async (args: EventArgs) => {
if (args.action === "list") return invoke(bridge, "events.list", eventQuery(args, false));
if (args.action === "wait") {
return invoke(bridge, "events.wait", eventQuery(args, true), args.timeout_ms ?? 0);
}
return { success: false, error: `Unsupported event action: ${String(args.action)}` };
},
},
wait_for_host_readiness_uxp: {
description: "Capture a pre-dispatch readiness revision or wait, without retrying, for video-effect analysis or one documented operation-completion receipt.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["snapshot", "analysis", "operation"] },
sequence_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
expected_sequence_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
operation_type: { type: "string", enum: ["import", "export", "effect_drop", "generative_extend"] },
after_revision: {
type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER,
description: "Required for operation waits; capture it with snapshot before dispatching the operation.",
},
timeout_ms: { type: "integer", minimum: 0, maximum: 60000 },
poll_min_ms: { type: "integer", minimum: 100, maximum: 2000 },
poll_max_ms: { type: "integer", minimum: 100, maximum: 5000 },
},
required: ["action"],
},
handler: async (args: ReadinessArgs) => {
if (args.action === "snapshot") {
return invoke(bridge, "readiness.snapshot", {
...(args.sequence_id !== undefined ? { sequenceId: args.sequence_id } : {}),
});
}
if (args.action === "analysis") {
return invoke(bridge, "readiness.analysis.wait", {
...(args.sequence_id !== undefined ? { sequenceId: args.sequence_id } : {}),
...(args.expected_sequence_id !== undefined ? { expectedSequenceId: args.expected_sequence_id } : {}),
...(args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {}),
...(args.poll_min_ms !== undefined ? { pollMinMs: args.poll_min_ms } : {}),
...(args.poll_max_ms !== undefined ? { pollMaxMs: args.poll_max_ms } : {}),
}, args.timeout_ms ?? 30_000);
}
if (args.action === "operation") {
const operations: Record<string, string> = {
import: "import", export: "export", effect_drop: "effectDrop", generative_extend: "generativeExtend",
};
return invoke(bridge, "readiness.operation.wait", {
...(args.operation_type !== undefined ? { operationType: operations[args.operation_type] } : {}),
...(args.after_revision !== undefined ? { afterRevision: args.after_revision } : {}),
...(args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {}),
}, args.timeout_ms ?? 30_000);
}
return { success: false, error: `Unsupported readiness action: ${String(args.action)}` };
},
},
manage_project_sessions_uxp: {
description: "List or explicitly create, open, save, branch, and close Premiere project sessions. Path writes stay inside the approved UXP workspace; Save As handle changes are read back and branch copies reopen the source after every copy.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: {
type: "string",
enum: ["list", "validate", "create", "open", "save", "save_as", "branch_copies", "close"],
},
project_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
expected_path: { type: "string", minLength: 1, maxLength: 4096 },
path: { type: "string", minLength: 1, maxLength: 4096 },
paths: {
type: "array", minItems: 1, maxItems: 16, uniqueItems: true,
items: { type: "string", minLength: 1, maxLength: 4096 },
},
include_paths: { type: "boolean", description: "List project paths only when explicitly requested; defaults to redacted." },
show_dialogs: { type: "boolean", description: "For open only; defaults to false." },
add_to_mru: { type: "boolean", description: "For open only; defaults to false." },
save_before_close: { type: "boolean", description: "For close only; defaults to true." },
confirm_external_write: { type: "boolean" },
confirm_overwrite: { type: "boolean" },
confirm_close: { type: "boolean" },
confirm_discard_unsaved: { type: "boolean" },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action"],
},
handler: async (args: ProjectSessionArgs) => {
const common = {
...(args.project_id !== undefined ? { projectId: args.project_id } : {}),
...(args.expected_path !== undefined ? { expectedPath: args.expected_path } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
};
if (args.action === "list") return invoke(bridge, "project.sessions.list", {
...(args.include_paths !== undefined ? { includePaths: args.include_paths } : {}),
});
if (args.action === "validate") return invoke(bridge, "project.sessions.validate", { path: args.path });
if (args.action === "create") return invoke(bridge, "project.sessions.create", {
path: args.path,
...(args.confirm_external_write !== undefined ? { confirmExternalWrite: args.confirm_external_write } : {}),
...(args.confirm_overwrite !== undefined ? { confirmOverwrite: args.confirm_overwrite } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "open") return invoke(bridge, "project.sessions.open", {
path: args.path,
...(args.show_dialogs !== undefined ? { showDialogs: args.show_dialogs } : {}),
...(args.add_to_mru !== undefined ? { addToMru: args.add_to_mru } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "save") return invoke(bridge, "project.sessions.save", common);
if (args.action === "save_as") return invoke(bridge, "project.sessions.saveAs", {
...common, path: args.path,
...(args.confirm_external_write !== undefined ? { confirmExternalWrite: args.confirm_external_write } : {}),
...(args.confirm_overwrite !== undefined ? { confirmOverwrite: args.confirm_overwrite } : {}),
});
if (args.action === "branch_copies") return invoke(bridge, "project.sessions.branchCopies", {
...common, paths: args.paths,
...(args.confirm_external_write !== undefined ? { confirmExternalWrite: args.confirm_external_write } : {}),
...(args.confirm_overwrite !== undefined ? { confirmOverwrite: args.confirm_overwrite } : {}),
});
if (args.action === "close") return invoke(bridge, "project.sessions.close", {
...common,
...(args.save_before_close !== undefined ? { saveBeforeClose: args.save_before_close } : {}),
...(args.confirm_close !== undefined ? { confirmClose: args.confirm_close } : {}),
...(args.confirm_discard_unsaved !== undefined ? { confirmDiscardUnsaved: args.confirm_discard_unsaved } : {}),
});
return { success: false, error: `Unsupported project-session action: ${String(args.action)}` };
},
},
manage_growing_media_uxp: {
description: "Inspect, pause under a bounded lease, or resume Premiere growing-media swaps. Pause expires within ten minutes and is resumed on ordinary panel or bridge shutdown; status is panel-local and never claims a host readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["status", "pause", "resume"] },
project_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
expected_path: { type: "string", minLength: 1, maxLength: 4096 },
lease_ms: { type: "integer", minimum: 1000, maximum: 600000 },
confirm_pause: { type: "boolean", description: "Must be true for pause." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action"],
},
handler: async (args: GrowingMediaArgs) => {
if (args.action === "status") return invoke(bridge, "growing.status");
if (args.action === "pause") return invoke(bridge, "growing.pause", {
...(args.project_id !== undefined ? { projectId: args.project_id } : {}),
...(args.expected_path !== undefined ? { expectedPath: args.expected_path } : {}),
...(args.lease_ms !== undefined ? { leaseMs: args.lease_ms } : {}),
...(args.confirm_pause !== undefined ? { confirmPause: args.confirm_pause } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "resume") return invoke(bridge, "growing.resume", {
...(args.project_id !== undefined ? { projectId: args.project_id } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
return { success: false, error: `Unsupported growing-media action: ${String(args.action)}` };
},
},
manage_workflow_checkpoints_uxp: {
description: "Read or transactionally write small, namespaced workflow checkpoints on the active project or a targeted sequence. Persistent values may sync with cloud projects; never store secrets, native paths, transcripts, or media names.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["has", "get", "set", "clear"] },
owner: { type: "string", enum: ["project", "sequence"] },
sequence_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
expected_owner_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
name: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$" },
value_type: { type: "string", enum: ["string", "int", "float", "bool"] },
value: { type: ["string", "number", "boolean"], maxLength: 8192 },
persistence: { type: "string", enum: ["session", "persistent"] },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action", "name"],
},
handler: async (args: CheckpointArgs) => {
const common = {
...(args.owner !== undefined ? { owner: args.owner } : {}),
...(args.sequence_id !== undefined ? { sequenceId: args.sequence_id } : {}),
...(args.expected_owner_id !== undefined ? { expectedOwnerId: args.expected_owner_id } : {}),
name: args.name,
};
if (args.action === "has") return invoke(bridge, "checkpoint.has", common);
if (args.action === "get") return invoke(bridge, "checkpoint.get", {
...common, ...(args.value_type !== undefined ? { valueType: args.value_type } : {}),
});
if (args.action === "set") return invoke(bridge, "checkpoint.set", {
...common,
...(args.value_type !== undefined ? { valueType: args.value_type } : {}),
...(args.value !== undefined ? { value: args.value } : {}),
...(args.persistence !== undefined ? { persistence: args.persistence } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "clear") return invoke(bridge, "checkpoint.clear", {
...common, ...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
return { success: false, error: `Unsupported checkpoint action: ${String(args.action)}` };
},
},
maintain_media_health_uxp: {
description: "Inspect up to 64 source media items, refresh them serially, transactionally set them offline, or find project items matching an approved media path. Native paths are redacted unless explicitly requested; opt-in media timing is read-only, bounded, and runtime-compatible.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "refresh", "set_offline", "find_by_media_path"] },
project_item_ids: {
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
items: { type: "string", minLength: 1, maxLength: 512 },
},
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
expected_offline: { type: "boolean" },
confirm_set_offline: { type: "boolean" },
match_path: { type: "string", minLength: 1, maxLength: 4096 },
ignore_subclips: { type: "boolean" },
include_paths: { type: "boolean", description: "Explicitly include media/proxy/origin paths; defaults to redacted." },
include_media_timing: { type: "boolean", description: "For inspect only, include bounded source start/duration readback; defaults to false." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action"],
},
handler: async (args: MediaHealthArgs) => {
if (args.action === "inspect") return invoke(bridge, "media.health.inspect", {
...(args.project_item_ids !== undefined ? { projectItemIds: args.project_item_ids } : {}),
...(args.include_paths !== undefined ? { includePaths: args.include_paths } : {}),
...(args.include_media_timing !== undefined ? { includeMediaTiming: args.include_media_timing } : {}),
});
if (args.action === "refresh") return invoke(bridge, "media.health.refresh", {
...(args.project_item_ids !== undefined ? { projectItemIds: args.project_item_ids } : {}),
...(args.expected_offline !== undefined ? { expectedOffline: args.expected_offline } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "set_offline") return invoke(bridge, "media.health.setOffline", {
...(args.project_item_ids !== undefined ? { projectItemIds: args.project_item_ids } : {}),
...(args.expected_offline !== undefined ? { expectedOffline: args.expected_offline } : {}),
...(args.confirm_set_offline !== undefined ? { confirmSetOffline: args.confirm_set_offline } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
if (args.action === "find_by_media_path") return invoke(bridge, "media.health.findByPath", {
...(args.project_item_id !== undefined ? { projectItemId: args.project_item_id } : {}),
...(args.match_path !== undefined ? { matchPath: args.match_path } : {}),
...(args.ignore_subclips !== undefined ? { ignoreSubclips: args.ignore_subclips } : {}),
...(args.include_paths !== undefined ? { includePaths: args.include_paths } : {}),
});
return { success: false, error: `Unsupported media-health action: ${String(args.action)}` };
},
},
manage_source_media_timing_uxp: {
description: "Inspect or transactionally set one source media item's timecode start through stable Premiere 26.3 UXP APIs. Setting requires the exact bounded timing snapshot, explicit confirmation, one undoable transaction, per-item serialization, and native readback.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "set_start"] },
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
expected_timing: {
type: "object", additionalProperties: false,
properties: {
start_seconds: { type: "number", minimum: 0, maximum: 86400000 },
duration_seconds: { type: "number", minimum: 0, maximum: 86400000 },
},
required: ["start_seconds", "duration_seconds"],
description: "Required for set_start. Copy the complete snapshot returned by inspect; a changed start or duration rejects the request before action creation.",
},
start_seconds: { type: "number", minimum: 0, maximum: 86400000 },
confirm_set_start: { type: "boolean", description: "Required true for set_start because source timecode changes can affect editorial synchronization." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action", "project_item_id"],
},
handler: async (args: SourceMediaTimingArgs) => {
const common = {
...(args.project_item_id === undefined ? {} : { projectItemId: args.project_item_id }),
};
if (args.action === "inspect") return invoke(bridge, "source.mediaTiming.inspect", common);
if (args.action === "set_start") return invoke(bridge, "source.mediaTiming.setStart", {
...common,
...(args.expected_timing === undefined ? {} : { expectedTiming: {
...(args.expected_timing.start_seconds === undefined ? {} : { startSeconds: args.expected_timing.start_seconds }),
...(args.expected_timing.duration_seconds === undefined ? {} : { durationSeconds: args.expected_timing.duration_seconds }),
} }),
...(args.start_seconds === undefined ? {} : { startSeconds: args.start_seconds }),
...(args.confirm_set_start === undefined ? {} : { confirmSetStart: args.confirm_set_start }),
...(args.operation_id === undefined ? {} : { operationId: args.operation_id }),
});
return { success: false, error: `Unsupported source-media timing action: ${String(args.action)}` };
},
},
manage_source_media_overrides_uxp: {
description: "Inspect or transactionally set one source media item's explicit frame-rate and/or pixel-aspect-ratio override through stable Premiere 26.3 UXP actions. Updates require the complete effective-interpretation snapshot, explicit confirmation, an operation_id, per-item serialization, one undoable transaction, and native effective-value readback. Premiere does not expose an override-presence getter, so this tool cannot clear or distinguish an explicit override from matching file-native interpretation.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
expected_overrides: {
type: "object", additionalProperties: false,
properties: {
project_guid: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
frame_rate: { type: "number", minimum: 1, maximum: 240 },
pixel_aspect_ratio: { type: "number", minimum: 0.01, maximum: 100 },
},
required: ["project_guid", "frame_rate", "pixel_aspect_ratio"],
description: "Required for update; copy the complete snapshot returned by inspect. Any changed effective value or active project rejects before the transaction.",
},
frame_rate: { type: "number", minimum: 1, maximum: 240 },
pixel_aspect_ratio: {
type: "object", additionalProperties: false,
properties: {
numerator: { type: "integer", minimum: 1, maximum: 10000 },
denominator: { type: "integer", minimum: 1, maximum: 10000 },
},
required: ["numerator", "denominator"],
},
confirm_media_interpretation: { type: "boolean", description: "Required true for update because source interpretation can alter editorial timing and framing." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required for update so an interrupted mutation can be safely replayed." },
},
required: ["action", "project_item_id"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "26.3",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: [
"The documented UXP actions and effective-value readback are covered by mocks and static declaration inventory.",
"No licensed Premiere host validation has run; the API has no override-presence or clear-override getter.",
],
},
handler: async (args: SourceMediaOverridesArgs) => {
const common = {
...(args.project_item_id === undefined ? {} : { projectItemId: args.project_item_id }),
};
if (args.action === "inspect") return invoke(bridge, "source.mediaOverrides.inspect", common);
if (args.action === "update") return invoke(bridge, "source.mediaOverrides.update", {
...common,
...(args.expected_overrides === undefined ? {} : { expectedOverrides: {
...(args.expected_overrides.project_guid === undefined ? {} : { projectGuid: args.expected_overrides.project_guid }),
...(args.expected_overrides.frame_rate === undefined ? {} : { frameRate: args.expected_overrides.frame_rate }),
...(args.expected_overrides.pixel_aspect_ratio === undefined ? {} : { pixelAspectRatio: args.expected_overrides.pixel_aspect_ratio }),
} }),
...(args.frame_rate === undefined ? {} : { frameRate: args.frame_rate }),
...(args.pixel_aspect_ratio === undefined ? {} : { pixelAspectRatio: {
...(args.pixel_aspect_ratio.numerator === undefined ? {} : { numerator: args.pixel_aspect_ratio.numerator }),
...(args.pixel_aspect_ratio.denominator === undefined ? {} : { denominator: args.pixel_aspect_ratio.denominator }),
} }),
...(args.confirm_media_interpretation === undefined ? {} : { confirmMediaInterpretation: args.confirm_media_interpretation }),
...(args.operation_id === undefined ? {} : { operationId: args.operation_id }),
});
return { success: false, error: `Unsupported source-media override action: ${String(args.action)}` };
},
},
manage_track_state_uxp: {
description: "Inspect audio, video, and caption track mute state or set one media type serially with stale-state preflight and per-track readback. Adobe exposes this as direct promises, so no undo transaction is claimed.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "set_mute"] },
sequence_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
expected_sequence_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
media_type: { type: "string", enum: ["all", "video", "audio", "caption"] },
track_indices: {
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
items: { type: "integer", minimum: 0, maximum: 1023 },
},
muted: { type: "boolean" },
expected_muted: { type: "boolean" },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action"],
},
handler: async (args: TrackStateArgs) => {
const common = {
...(args.sequence_id !== undefined ? { sequenceId: args.sequence_id } : {}),
...(args.expected_sequence_id !== undefined ? { expectedSequenceId: args.expected_sequence_id } : {}),
...(args.media_type !== undefined ? { mediaType: args.media_type } : {}),
...(args.track_indices !== undefined ? { trackIndices: args.track_indices } : {}),
};
if (args.action === "inspect") return invoke(bridge, "track.state.inspect", common);
if (args.action === "set_mute") return invoke(bridge, "track.state.set", {
...common,
...(args.muted !== undefined ? { muted: args.muted } : {}),
...(args.expected_muted !== undefined ? { expectedMuted: args.expected_muted } : {}),
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
return { success: false, error: `Unsupported track-state action: ${String(args.action)}` };
},
},
manage_source_clip_uxp: {
description: "Inspect or transactionally update source-clip in/out points and request scale-to-frame for up to 64 media items. In/out values are read back; Adobe exposes no getter for clear or scale state, so those requests remain committed-unverified.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
items: {
type: "array", minItems: 1, maxItems: 64,
items: {
type: "object", additionalProperties: false,
properties: {
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
media_type: { type: "string", enum: ["video", "audio"] },
expected_in_seconds: { type: "number", minimum: 0, maximum: 86400000 },
expected_out_seconds: { type: "number", minimum: 0, maximum: 86400000 },
in_seconds: { type: "number", minimum: 0, maximum: 86400000 },
out_seconds: { type: "number", minimum: 0, maximum: 86400000 },
clear_in_out: { type: "boolean" },
scale_to_frame: { type: "boolean", enum: [true] },
},
required: ["project_item_id"],
},
},
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" },
},
required: ["action", "items"],
},
handler: async (args: SourceClipArgs) => {
const items = args.items?.map((item) => ({
...(item.project_item_id !== undefined ? { projectItemId: item.project_item_id } : {}),
...(item.media_type !== undefined ? { mediaType: item.media_type } : {}),
...(item.expected_in_seconds !== undefined ? { expectedInSeconds: item.expected_in_seconds } : {}),
...(item.expected_out_seconds !== undefined ? { expectedOutSeconds: item.expected_out_seconds } : {}),
...(item.in_seconds !== undefined ? { inSeconds: item.in_seconds } : {}),
...(item.out_seconds !== undefined ? { outSeconds: item.out_seconds } : {}),
...(item.clear_in_out !== undefined ? { clearInOut: item.clear_in_out } : {}),
...(item.scale_to_frame !== undefined ? { scaleToFrame: item.scale_to_frame } : {}),
}));
if (args.action === "inspect") return invoke(bridge, "source.clip.inspect", { items });
if (args.action === "update") return invoke(bridge, "source.clip.update", {
items,
...(args.operation_id !== undefined ? { operationId: args.operation_id } : {}),
});
return { success: false, error: `Unsupported source-clip action: ${String(args.action)}` };
},
},
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type ObjectMaskAuditArgs = {
expected_project_guid?: string;
sequence_ids?: string[];
};
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
/** A bounded, double-read inventory of document-reported object-mask presence. */
export function getUxpObjectMaskAuditWorkflowTools(bridge: UxpWebSocketBridge) {
return {
audit_object_masks_uxp: {
description: "Audit documented Object Mask presence across up to 64 Premiere sequences without changing the project. Omit sequence_ids only when the entire active project has at most 64 sequences; otherwise pass explicit exact GUIDs. The bridge double-reads the active-project identity, aggregate result, and every selected sequence result, rejecting drift instead of returning a mixed audit. It reports only yes/no presence—not mask count, location, tracking, editability, rendered pixels, or playback correctness.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
expected_project_guid: {
type: "string", minLength: 1, maxLength: 512,
description: "Optional active-project GUID from a prior native snapshot. Rejects a changed active project before the audit begins.",
},
sequence_ids: {
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
description: "Optional exact Premiere sequence GUIDs to audit. Omit only to audit all sequences in a project containing at most 64 sequences.",
items: { type: "string", minLength: 1, maxLength: 512 },
},
},
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "26.3",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises objectMask.audit.", "Read-only contract tests do not prove a licensed host, mask visuals, tracking state, or playback."],
},
handler: async (args: ObjectMaskAuditArgs = {}) => invoke(bridge, "objectMask.audit", {
...(args.expected_project_guid === undefined ? {} : { expectedProjectGuid: args.expected_project_guid }),
...(args.sequence_ids === undefined ? {} : { sequenceIds: args.sequence_ids }),
}),
},
};
}
+112
View File
@@ -0,0 +1,112 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type ItemSnapshot = {
project_item_id: string;
start_seconds: number; end_seconds: number; in_seconds: number; out_seconds: number;
duration_seconds: number; speed: number; reversed: boolean;
};
type RippleDeleteArgs = {
action: "inspect" | "apply";
media_type: "video" | "audio";
track_index: number;
clip_index: number;
expected_snapshot?: {
project_guid: string; sequence_id: string; media_type: "video" | "audio";
track_index: number; clip_index: number; track_item_count: number;
target: ItemSnapshot; following: ItemSnapshot;
};
confirm_ripple_delete?: boolean;
operation_id?: string;
};
const itemSnapshotProperties = {
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400 },
in_seconds: { type: "number", minimum: 0, maximum: 86400 },
out_seconds: { type: "number", minimum: 0, maximum: 86400 },
duration_seconds: { type: "number", minimum: 0, maximum: 86400 },
speed: { type: "number", minimum: 0, maximum: 100 },
reversed: { type: "boolean" },
} as const;
const itemSnapshotRequired = ["project_item_id", "start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed"];
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function strictItemSnapshot(value: unknown, name: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) if (!(key in itemSnapshotProperties)) throw new Error(`${name} has an unknown field: ${key}`);
for (const key of itemSnapshotRequired) if (!(key in record)) throw new Error(`${name}.${key} is required`);
return {
projectItemId: record.project_item_id,
startSeconds: record.start_seconds, endSeconds: record.end_seconds, inSeconds: record.in_seconds,
outSeconds: record.out_seconds, durationSeconds: record.duration_seconds, speed: record.speed, reversed: record.reversed,
};
}
function expectedRippleSnapshot(value: RippleDeleteArgs["expected_snapshot"]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected_snapshot is required for action: apply");
const record = value as Record<string, unknown>;
const allowed = ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "target", "following"];
for (const key of Object.keys(record)) if (!allowed.includes(key)) throw new Error(`expected_snapshot has an unknown field: ${key}`);
for (const key of allowed) if (!(key in record)) throw new Error(`expected_snapshot.${key} is required`);
return {
projectGuid: record.project_guid, sequenceId: record.sequence_id, mediaType: record.media_type,
trackIndex: record.track_index, clipIndex: record.clip_index, trackItemCount: record.track_item_count,
target: strictItemSnapshot(record.target, "expected_snapshot.target"),
following: strictItemSnapshot(record.following, "expected_snapshot.following"),
};
}
/** A bounded documented-UXP ripple delete with contiguous-successor readback. */
export function getUxpRippleDeleteWorkflowTools(bridge: UxpWebSocketBridge) {
return {
ripple_delete_track_item_uxp: {
description: "Inspect or perform one guarded ripple delete on an audio or video timeline item using documented UXP SequenceEditor actions. Apply requires complete target and contiguous-successor snapshots, explicit confirmation, and an operation ID; it serializes with guarded slips, slides, and append duplicates on that track, commits one transaction, and reads back the successor at the removed coordinate. It intentionally cannot ripple a final item, a gap, another track, or a linked A/V pair. It does not prove media handles, linked A/V synchronization, rendered frames, playback, persistence, or Undo behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "apply"] },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 510, description: "One clip item with an immediately contiguous same-track successor." },
expected_snapshot: {
type: "object", additionalProperties: false,
required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "target", "following"],
properties: {
project_guid: { type: "string", minLength: 1, maxLength: 128 }, sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] }, track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 510 }, track_item_count: { type: "integer", minimum: 2, maximum: 512 },
target: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
following: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
},
},
confirm_ripple_delete: { type: "boolean", description: "Must be true for action: apply." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required replay-safe operation identifier for action: apply." },
},
required: ["action", "media_type", "track_index", "clip_index"],
},
annotations: { readOnlyHint: false, destructiveHint: true },
availability: {
backend: "uxp", minimumPremiereVersion: "25.6.0", capability: "trackItem.rippleDelete.inspect|trackItem.rippleDelete",
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises trackItem.rippleDelete.inspect and trackItem.rippleDelete.", "A verification failure can follow a committed host transaction; inspect the successor before another edit."],
},
handler: async (args: RippleDeleteArgs) => {
const target = { mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index };
if (args.action === "inspect") return invoke(bridge, "trackItem.rippleDelete.inspect", target);
if (args.action === "apply") return invoke(bridge, "trackItem.rippleDelete", {
...target, expectedSnapshot: expectedRippleSnapshot(args.expected_snapshot),
confirmRippleDelete: args.confirm_ripple_delete, operationId: args.operation_id,
});
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+88
View File
@@ -0,0 +1,88 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type PreviewFrameSnapshot = {
project_guid: string;
sequence_id: string;
preview_width: number;
preview_height: number;
};
type PreviewFrameArgs = {
action: "inspect" | "update";
sequence_id: string;
preview_width?: number;
preview_height?: number;
expected_snapshot?: PreviewFrameSnapshot;
confirm_set_preview_frame?: boolean;
operation_id?: string;
};
const snapshotProperties = {
project_guid: { type: "string", minLength: 1, maxLength: 128 },
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
preview_width: { type: "integer", minimum: 16, maximum: 10240 },
preview_height: { type: "integer", minimum: 16, maximum: 8192 },
} as const;
const snapshotRequired = ["project_guid", "sequence_id", "preview_width", "preview_height"];
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function strictSnapshot(value: PreviewFrameArgs["expected_snapshot"]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected_snapshot is required for action: update");
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) if (!(key in snapshotProperties)) throw new Error(`expected_snapshot has an unknown field: ${key}`);
for (const key of snapshotRequired) if (!(key in record)) throw new Error(`expected_snapshot.${key} is required`);
return {
projectGuid: record.project_guid,
sequenceId: record.sequence_id,
previewWidth: record.preview_width,
previewHeight: record.preview_height,
};
}
/** A bounded documented-UXP preview-frame workflow for one explicit sequence GUID. */
export function getUxpSequencePreviewFrameWorkflowTools(bridge: UxpWebSocketBridge) {
return {
manage_sequence_preview_frame_uxp: {
description: "Inspect or set one explicit sequence's documented preview-frame rectangle. Update requires the complete inspected snapshot, explicit confirmation, and an operation ID; it serializes preview-frame updates by reviewed project and sequence, commits one undoable settings transaction, then reads the same sequence back. It does not set sequence video dimensions, alter media or exports, prove rendered preview output, persistence, Undo behavior, or coordinate with Premiere UI or other extensions between native calls.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
sequence_id: { type: "string", minLength: 1, maxLength: 128, description: "Exact sequence GUID returned by an existing sequence inspector." },
preview_width: { type: "integer", minimum: 16, maximum: 10240, description: "Requested native preview-frame width. Required for action: update." },
preview_height: { type: "integer", minimum: 16, maximum: 8192, description: "Requested native preview-frame height. Required for action: update." },
expected_snapshot: { type: "object", additionalProperties: false, required: snapshotRequired, properties: snapshotProperties },
confirm_set_preview_frame: { type: "boolean", description: "Must be true for action: update after reviewing expected_snapshot." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required replay-safe operation identifier for action: update." },
},
required: ["action", "sequence_id"],
},
annotations: { readOnlyHint: false, destructiveHint: true },
availability: {
backend: "uxp", minimumPremiereVersion: "26.3.0", capability: "sequence.previewFrame.inspect|sequence.previewFrame.update",
notes: [
"Requires an authenticated UXP bridge whose runtime capability handshake advertises sequence.previewFrame.inspect and sequence.previewFrame.update.",
"The reviewed snapshot is revalidated and updates are serialized inside this bridge process per project/sequence. Native UXP does not provide compare-and-swap or a cross-extension/UI lock, so it cannot prove another actor did not change the setting between native calls.",
],
},
handler: async (args: PreviewFrameArgs) => {
if (args.action === "inspect") return invoke(bridge, "sequence.previewFrame.inspect", { sequenceId: args.sequence_id });
if (args.action === "update") return invoke(bridge, "sequence.previewFrame.update", {
sequenceId: args.sequence_id,
previewWidth: args.preview_width,
previewHeight: args.preview_height,
expectedSnapshot: strictSnapshot(args.expected_snapshot),
confirmSetPreviewFrame: args.confirm_set_preview_frame,
operationId: args.operation_id,
});
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+111
View File
@@ -0,0 +1,111 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type ItemSnapshot = {
start_seconds: number; end_seconds: number; in_seconds: number; out_seconds: number;
duration_seconds: number; speed: number; reversed: boolean;
};
type SlideArgs = {
action: "inspect" | "apply";
media_type: "video" | "audio";
track_index: number;
clip_index: number;
expected_snapshot?: {
project_guid: string; sequence_id: string; media_type: "video" | "audio"; track_index: number; clip_index: number;
previous: ItemSnapshot; target: ItemSnapshot; following: ItemSnapshot;
};
slide_by_seconds?: number;
confirm_slide?: boolean;
operation_id?: string;
};
const itemSnapshotProperties = {
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400 },
in_seconds: { type: "number", minimum: 0, maximum: 86400 },
out_seconds: { type: "number", minimum: 0, maximum: 86400 },
duration_seconds: { type: "number", minimum: 0, maximum: 86400 },
speed: { type: "number", minimum: 0, maximum: 100 },
reversed: { type: "boolean" },
} as const;
const itemSnapshotRequired = ["start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed"];
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function strictItemSnapshot(value: unknown, name: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) if (!(key in itemSnapshotProperties)) throw new Error(`${name} has an unknown field: ${key}`);
for (const key of itemSnapshotRequired) if (!(key in record)) throw new Error(`${name}.${key} is required`);
return {
startSeconds: record.start_seconds, endSeconds: record.end_seconds, inSeconds: record.in_seconds,
outSeconds: record.out_seconds, durationSeconds: record.duration_seconds, speed: record.speed, reversed: record.reversed,
};
}
function expectedSlideSnapshot(value: SlideArgs["expected_snapshot"]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected_snapshot is required for action: apply");
const record = value as Record<string, unknown>;
const allowed = ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "previous", "target", "following"];
for (const key of Object.keys(record)) if (!allowed.includes(key)) throw new Error(`expected_snapshot has an unknown field: ${key}`);
for (const key of allowed) if (!(key in record)) throw new Error(`expected_snapshot.${key} is required`);
return {
projectGuid: record.project_guid, sequenceId: record.sequence_id, mediaType: record.media_type,
trackIndex: record.track_index, clipIndex: record.clip_index,
previous: strictItemSnapshot(record.previous, "expected_snapshot.previous"),
target: strictItemSnapshot(record.target, "expected_snapshot.target"),
following: strictItemSnapshot(record.following, "expected_snapshot.following"),
};
}
/** A bounded documented-UXP composition for one contiguous three-item slide. */
export function getUxpSlideWorkflowTools(bridge: UxpWebSocketBridge) {
return {
slide_track_item_uxp: {
description: "Inspect or perform one guarded slide on an audio or video timeline item using documented UXP track-item actions. Apply requires the complete three-item snapshot, explicit confirmation, and an operation ID; it serializes slides and source-only slips on the affected track, commits one transaction, and verifies every affected source and timeline boundary. Only contiguous forward 1x clips are supported. It does not prove source handles, linked A/V synchronization, rendered frames, playback, persistence, or Undo behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "apply"] },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511, description: "The center item. It must have immediate previous and following clip items on the same track." },
expected_snapshot: {
type: "object", additionalProperties: false,
required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "previous", "target", "following"],
properties: {
project_guid: { type: "string", minLength: 1, maxLength: 128 }, sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] }, track_index: { type: "integer", minimum: 0, maximum: 511 }, clip_index: { type: "integer", minimum: 0, maximum: 511 },
previous: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
target: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
following: { type: "object", additionalProperties: false, required: itemSnapshotRequired, properties: itemSnapshotProperties },
},
},
slide_by_seconds: { type: "number", minimum: -60, maximum: 60, description: "Non-zero offset. Positive moves the center item later and lengthens the previous neighbour." },
confirm_slide: { type: "boolean", description: "Must be true for action: apply." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required replay-safe operation identifier for action: apply." },
},
required: ["action", "media_type", "track_index", "clip_index"],
},
annotations: { readOnlyHint: false, destructiveHint: true },
availability: {
backend: "uxp", minimumPremiereVersion: "25.6.0", capability: "trackItem.slide.inspect|trackItem.slide",
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises trackItem.slide.inspect and trackItem.slide.", "A verification failure can follow a committed host transaction; inspect all three items before another edit."],
},
handler: async (args: SlideArgs) => {
const target = { mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index };
if (args.action === "inspect") return invoke(bridge, "trackItem.slide.inspect", target);
if (args.action === "apply") return invoke(bridge, "trackItem.slide", {
...target, expectedSnapshot: expectedSlideSnapshot(args.expected_snapshot), slideBySeconds: args.slide_by_seconds,
confirmSlide: args.confirm_slide, operationId: args.operation_id,
});
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+128
View File
@@ -0,0 +1,128 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type SlipArgs = Record<string, unknown> & {
action?: string;
operation_id?: string;
expected_snapshot?: unknown;
};
const operationId = {
type: "string" as const,
pattern: "^[A-Za-z0-9._:-]{1,128}$",
description: "Required replay key for a source-only slip mutation.",
};
const targetProperties = {
media_type: { type: "string" as const, enum: ["video", "audio"] },
track_index: { type: "integer" as const, minimum: 0, maximum: 511 },
clip_index: { type: "integer" as const, minimum: 0, maximum: 511 },
};
const expectedSnapshot = {
type: "object" as const,
additionalProperties: false,
description: "Complete snapshot returned by inspect. Apply rejects any changed project, sequence, coordinate, timeline, source, speed, or reverse state.",
properties: {
project_guid: { type: "string" as const, minLength: 1, maxLength: 128 },
sequence_id: { type: "string" as const, minLength: 1, maxLength: 128 },
media_type: { type: "string" as const, enum: ["video", "audio"] },
track_index: { type: "integer" as const, minimum: 0, maximum: 511 },
clip_index: { type: "integer" as const, minimum: 0, maximum: 511 },
start_seconds: { type: "number" as const, minimum: 0, maximum: 86400 },
end_seconds: { type: "number" as const, minimum: 0, maximum: 86400 },
in_seconds: { type: "number" as const, minimum: 0, maximum: 86400 },
out_seconds: { type: "number" as const, minimum: 0, maximum: 86400 },
duration_seconds: { type: "number" as const, minimum: 0, maximum: 86400 },
speed: { type: "number" as const, minimum: 0, maximum: 100 },
reversed: { type: "boolean" as const },
},
required: [
"project_guid", "sequence_id", "media_type", "track_index", "clip_index",
"start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed",
],
};
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function expectedSlipSnapshot(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("expected_snapshot must be the complete object returned by inspect");
}
const snapshot = value as Record<string, unknown>;
const allowed = [
"project_guid", "sequence_id", "media_type", "track_index", "clip_index",
"start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed",
];
const unknown = Object.keys(snapshot).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`expected_snapshot has an unknown field: ${unknown}`);
const missing = allowed.find((key) => !(key in snapshot));
if (missing) throw new Error(`expected_snapshot.${missing} is required`);
return {
projectGuid: snapshot.project_guid,
sequenceId: snapshot.sequence_id,
mediaType: snapshot.media_type,
trackIndex: snapshot.track_index,
clipIndex: snapshot.clip_index,
startSeconds: snapshot.start_seconds,
endSeconds: snapshot.end_seconds,
inSeconds: snapshot.in_seconds,
outSeconds: snapshot.out_seconds,
durationSeconds: snapshot.duration_seconds,
speed: snapshot.speed,
reversed: snapshot.reversed,
};
}
export function getUxpSlipWorkflowTools(bridge: UxpWebSocketBridge) {
return {
slip_track_item_uxp: {
description: "Inspect or perform one guarded source-only slip on an audio or video timeline item. Apply requires the complete inspected snapshot, explicit confirmation, and an operation ID; it serializes slip operations per target, creates the documented source in/out actions in one undoable transaction, and reads back unchanged timeline timing plus the exact shifted source range. It supports only forward 1x clips, does not infer available media handles, and does not prove rendered frames, playback, linked-item sync, persistence, or Undo behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string" as const, enum: ["inspect", "apply"] },
...targetProperties,
expected_snapshot: expectedSnapshot,
slip_by_seconds: { type: "number" as const, minimum: -60, maximum: 60, description: "Non-zero source-time offset. Timeline start/end remain unchanged." },
confirm_slip: { type: "boolean" as const, description: "Required true after reviewing the complete inspect snapshot." },
operation_id: operationId,
},
required: ["action", "media_type", "track_index", "clip_index"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: [
"Requires an authenticated UXP bridge whose runtime capability handshake advertises trackItem.slip.inspect and trackItem.slip.",
"An apply verification failure can follow a committed host transaction; inspect before issuing another edit.",
],
},
handler: async (args: SlipArgs) => {
const target = {
mediaType: args.media_type,
trackIndex: args.track_index,
clipIndex: args.clip_index,
};
if (args.action === "inspect") return invoke(bridge, "trackItem.slip.inspect", target);
if (args.action === "apply") {
return invoke(bridge, "trackItem.slip", {
...target,
expectedSnapshot: expectedSlipSnapshot(args.expected_snapshot),
slipBySeconds: args.slip_by_seconds,
confirmSlip: args.confirm_slip,
operationId: args.operation_id,
});
}
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+46
View File
@@ -0,0 +1,46 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type SourceMediaProvenanceArgs = {
project_item_id: string;
include_media_file_path?: boolean;
include_originating_project_path?: boolean;
};
function invoke(bridge: UxpWebSocketBridge, args: Record<string, unknown>) {
return bridge.request("source.provenance.inspect", args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
/** Read selected native source-media provenance paths for one explicit project item. */
export function getUxpSourceMediaProvenanceWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_source_media_provenance_uxp: {
description: "Read selected documented source-media provenance paths for one explicit media-backed Project item. At least one explicit include flag is required before either native path is queried or returned. The command resolves the requested item through a bounded Project-item tree twice and rejects a changed project, target item, or requested path; it does not enumerate folders in its response, access the filesystem, inspect media contents or metadata, validate a path's existence, modify Premiere, prove an atomic snapshot, origin lineage, rights, persistence, or licensed-host behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Exact media-backed Project-item ID from a recent native project inspection." },
include_media_file_path: { type: "boolean", description: "Must be true to query and return the native media-file path; otherwise it is not read." },
include_originating_project_path: { type: "boolean", description: "Must be true to query and return the native originating-project path; otherwise it is not read." },
},
required: ["project_item_id"],
},
annotations: { readOnlyHint: true, destructiveHint: false },
availability: {
backend: "uxp", minimumPremiereVersion: "26.3.0", capability: "source.provenance.inspect",
notes: [
"Requires an authenticated UXP bridge whose runtime capability handshake advertises source.provenance.inspect.",
"At least one path-disclosure opt-in must be true. The bridge bounds traversal to 4096 reachable items and each selected native path to 4096 characters.",
"The complete selected snapshot is read twice, but Adobe exposes no atomic read transaction; a UI or another extension can still change a value between native calls.",
],
},
handler: async (args: SourceMediaProvenanceArgs) => invoke(bridge, {
projectItemId: args.project_item_id,
...(args.include_media_file_path === undefined ? {} : { includeMediaFilePath: args.include_media_file_path }),
...(args.include_originating_project_path === undefined ? {} : { includeOriginatingProjectPath: args.include_originating_project_path }),
}),
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type SourceProxyArgs = {
project_item_id: string;
include_proxy_path?: boolean;
};
function invoke(bridge: UxpWebSocketBridge, args: Record<string, unknown>) {
return bridge.request("source.proxy.inspect", args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
/** Read bounded native proxy readiness for one explicit media-backed Project item. */
export function getUxpSourceProxyWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_source_proxy_uxp: {
description: "Read documented proxy-readiness state for one explicit media-backed Project item. Proxy attachment state, offline state, and native capability booleans are read twice through a bounded Project-item tree; a native proxy path is queried and returned only when include_proxy_path is true and a proxy is reported attached. The command rejects changed project, target, or selected proxy state. It does not enumerate folders in its response, does not access the filesystem, validate a path's existence, inspect media contents, attach or relink proxy media, modify Premiere, prove an atomic snapshot, proxy compatibility, playback, persistence, Undo, or licensed-host behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Exact media-backed Project-item ID from a recent native Project inspection." },
include_proxy_path: { type: "boolean", description: "When true, query and return the native proxy path only if Premiere reports an attached proxy. Otherwise the path getter is not called." },
},
required: ["project_item_id"],
},
annotations: { readOnlyHint: true, destructiveHint: false },
availability: {
backend: "uxp", minimumPremiereVersion: "26.3.0", capability: "source.proxy.inspect",
notes: [
"Requires an authenticated UXP bridge whose runtime capability handshake advertises source.proxy.inspect.",
"The bridge bounds traversal to 4096 reachable items and an explicitly requested native proxy path to 4096 characters.",
"The complete selected snapshot is read twice, but Adobe exposes no atomic read transaction; a UI or another extension can still change a value between native calls.",
],
},
handler: async (args: SourceProxyArgs) => invoke(bridge, {
projectItemId: args.project_item_id,
...(args.include_proxy_path === undefined ? {} : { includeProxyPath: args.include_proxy_path }),
}),
},
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type TickTimeArithmeticArgs = {
operation: "add" | "subtract" | "multiply" | "divide";
base_ticks: string;
operand_ticks?: string;
factor?: number;
};
function invoke(bridge: UxpWebSocketBridge, args: Record<string, unknown>) {
return bridge.request("time.tickArithmetic.inspect", args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
/** Native, read-only TickTime arithmetic over already-quantized Premiere ticks. */
export function getUxpTickTimeArithmeticWorkflowTools(bridge: UxpWebSocketBridge) {
return {
calculate_tick_time_uxp: {
description: "Calculate one bounded add, subtract, multiply, or divide operation using Premiere's documented native TickTime arithmetic over canonical tick integers. It returns native ticks and seconds readback only. It deliberately does not accept seconds or frame rates, align to frames, infer timecode, inspect any Premiere project object, mutate Premiere, or prove a licensed host.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] },
base_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$", description: "Canonical signed Premiere tick integer (at most 18 digits)." },
operand_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$", description: "Required for add or subtract; forbidden for multiply or divide." },
factor: { type: "integer", minimum: -1_000_000, maximum: 1_000_000, not: { const: 0 }, description: "Required non-zero integer factor for multiply or divide; forbidden for add or subtract." },
},
required: ["operation", "base_ticks"],
},
annotations: { readOnlyHint: true, destructiveHint: false },
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises time.tickArithmetic.inspect.", "Contract tests verify the documented-call protocol, not a licensed Premiere host, frame alignment, sequence timecode, rendered output, or playback."],
},
handler: async (args: TickTimeArithmeticArgs) => invoke(bridge, {
operation: args.operation,
baseTicks: args.base_ticks,
...(args.operand_ticks === undefined ? {} : { operandTicks: args.operand_ticks }),
...(args.factor === undefined ? {} : { factor: args.factor }),
}),
},
};
}
+90
View File
@@ -0,0 +1,90 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type SourceLabelSnapshot = {
project_guid: string; sequence_id: string; media_type: "video" | "audio";
track_index: number; clip_index: number; track_item_count: number;
source_project_item_id: string; source_color_label_index: number;
start_seconds: number; end_seconds: number;
};
type SourceLabelArgs = {
action: "inspect" | "update";
media_type: "video" | "audio";
track_index: number;
clip_index: number;
color_index?: number;
expected_snapshot?: SourceLabelSnapshot;
confirm_set_label?: boolean;
operation_id?: string;
};
const snapshotProperties = {
project_guid: { type: "string", minLength: 1, maxLength: 128 },
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511 },
track_item_count: { type: "integer", minimum: 1, maximum: 512 },
source_project_item_id: { type: "string", minLength: 1, maxLength: 512 },
source_color_label_index: { type: "integer", minimum: 0, maximum: 15 },
start_seconds: { type: "number", minimum: 0, maximum: 86400 },
end_seconds: { type: "number", minimum: 0, maximum: 86400 },
} as const;
const snapshotRequired = ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "source_project_item_id", "source_color_label_index", "start_seconds", "end_seconds"];
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({ success: false, error: error instanceof Error ? error.message : String(error) }));
}
function strictSnapshot(value: SourceLabelArgs["expected_snapshot"]) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected_snapshot is required for action: update");
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) if (!(key in snapshotProperties)) throw new Error(`expected_snapshot has an unknown field: ${key}`);
for (const key of snapshotRequired) if (!(key in record)) throw new Error(`expected_snapshot.${key} is required`);
return {
projectGuid: record.project_guid, sequenceId: record.sequence_id, mediaType: record.media_type,
trackIndex: record.track_index, clipIndex: record.clip_index, trackItemCount: record.track_item_count,
sourceProjectItemId: record.source_project_item_id, sourceColorLabelIndex: record.source_color_label_index,
startSeconds: record.start_seconds, endSeconds: record.end_seconds,
};
}
/** A bounded documented-UXP source-label workflow resolved from one active timeline coordinate. */
export function getUxpTimelineSourceLabelWorkflowTools(bridge: UxpWebSocketBridge) {
return {
manage_timeline_source_label_uxp: {
description: "Inspect or set the documented source Project-item color label resolved from one active audio or video timeline coordinate. Update requires the complete reviewed snapshot, explicit confirmation, and an operation ID; it serializes color-label changes by source item, commits one undoable transaction, then re-reads the coordinate and source label. A source label is project-global: another use of the same source can reflect the change. It does not label a timeline-only instance, change clip timing, prove rendered appearance, playback, persistence, or Undo behavior.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0, maximum: 511 },
clip_index: { type: "integer", minimum: 0, maximum: 511 },
color_index: { type: "integer", minimum: 0, maximum: 15, description: "Requested native source color-label index." },
expected_snapshot: { type: "object", additionalProperties: false, required: snapshotRequired, properties: snapshotProperties },
confirm_set_label: { type: "boolean", description: "Must be true for action: update after reviewing expected_snapshot." },
operation_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Required replay-safe operation identifier for action: update." },
},
required: ["action", "media_type", "track_index", "clip_index"],
},
annotations: { readOnlyHint: false, destructiveHint: true },
availability: {
backend: "uxp", minimumPremiereVersion: "25.6.0", capability: "timeline.sourceLabel.inspect|timeline.sourceLabel.update",
notes: ["Requires an authenticated UXP bridge whose runtime capability handshake advertises timeline.sourceLabel.inspect and timeline.sourceLabel.update.", "Source labels are project-item state rather than timeline-instance state; an unrelated use of the same source can reflect a successful update."],
},
handler: async (args: SourceLabelArgs) => {
const target = { mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index };
if (args.action === "inspect") return invoke(bridge, "timeline.sourceLabel.inspect", target);
if (args.action === "update") return invoke(bridge, "timeline.sourceLabel.update", {
...target, colorIndex: args.color_index, expectedSnapshot: strictSnapshot(args.expected_snapshot),
confirmSetLabel: args.confirm_set_label, operationId: args.operation_id,
});
return { success: false, error: `Unsupported workflow action: ${String(args.action)}` };
},
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type UniqueIdentityArgs = {
project_item_id?: string;
sequence_guid?: string;
expected_project_guid?: string;
expected_unique_id?: string;
};
function invoke(bridge: UxpWebSocketBridge, command: string, args: Record<string, unknown>) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
/** Read an opaque native serializable identity without retaining or mutating it. */
export function getUxpUniqueIdentityWorkflowTools(bridge: UxpWebSocketBridge) {
return {
inspect_unique_object_identity_uxp: {
description: "Read the opaque documented Premiere unique serializable identity for exactly one existing project item or sequence, without changing the project. Select exactly one locator. The bridge independently resolves and reads the target twice, rejecting an active-project, locator, or unique-identity change rather than returning a mixed snapshot. It does not expose paths, metadata, content, timeline placement, editability, rendering, playback, persistence guarantees, or a licensed-host result.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
project_item_id: {
type: "string", minLength: 1, maxLength: 512,
description: "Exact Premiere project-item ID to resolve within at most 512 project-tree items. Provide this or sequence_guid, but not both.",
},
sequence_guid: {
type: "string", minLength: 1, maxLength: 512,
description: "Exact Premiere sequence GUID to resolve. Provide this or project_item_id, but not both.",
},
expected_project_guid: {
type: "string", minLength: 1, maxLength: 512,
description: "Optional active-project GUID from a prior native snapshot. Rejects inspection if it no longer matches before the second read.",
},
expected_unique_id: {
type: "string", minLength: 1, maxLength: 512,
description: "Optional expected opaque serializable identity. Rejects inspection if the first native target read does not match.",
},
},
oneOf: [
{ required: ["project_item_id"] },
{ required: ["sequence_guid"] },
],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "26.3",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: [
"Requires an authenticated UXP bridge whose runtime capability handshake advertises object.uniqueIdentity.inspect.",
"Contract tests do not prove a licensed host, identity lifetime across sessions, or that an identity authorizes a later edit.",
],
},
handler: async (args: UniqueIdentityArgs = {}) => invoke(bridge, "object.uniqueIdentity.inspect", {
...(args.project_item_id === undefined ? {} : { projectItemId: args.project_item_id }),
...(args.sequence_guid === undefined ? {} : { sequenceGuid: args.sequence_guid }),
...(args.expected_project_guid === undefined ? {} : { expectedProjectGuid: args.expected_project_guid }),
...(args.expected_unique_id === undefined ? {} : { expectedUniqueId: args.expected_unique_id }),
}),
},
};
}
+620
View File
@@ -0,0 +1,620 @@
import type { UxpWebSocketBridge } from "../bridge/uxp-websocket-bridge.js";
type WorkflowArgs = {
action?: string;
media_type?: string;
track_index?: number;
clip_index?: number;
expected_sequence_guid?: string;
effect_id?: string;
insertion_index?: number;
component_index?: number;
expected_effect_id?: string;
mode?: string;
project_item_id?: string;
project_item_name?: string;
media_path?: string;
is_high_resolution?: boolean;
make_alternate_link_in_team_projects?: boolean;
replace_existing_proxy?: boolean;
enabled?: boolean;
confirm_non_undoable?: boolean;
new_path?: string;
expected_current_path?: string;
override_compatibility_check?: boolean;
require_offline?: boolean;
project_metadata?: string;
xmp_metadata?: string;
updated_fields?: string[];
expected_project_guid?: string;
expected_project_panel_metadata?: string;
project_panel_metadata?: string;
confirm_update?: boolean;
field_name?: string;
field_label?: string;
schema_field_type?: string;
confirm_create?: boolean;
frame_rate?: number;
pixel_aspect_ratio?: number;
field_type?: number;
remove_pulldown?: boolean;
alpha_usage?: number;
ignore_alpha?: boolean;
invert_alpha?: boolean;
vr_conform?: number;
vr_layout?: number;
vr_horizontal_view?: number;
vr_vertical_view?: number;
input_lut_id?: string;
file_path?: string;
seconds?: number;
speed?: number;
folder_types?: string[];
destination?: string;
operation_id?: string;
selection_items?: TimelineSelectionItemArgs[];
selection_targets?: TimelineSelectionTargetArgs[];
};
type TimelineSelectionTargetArgs = {
media_type?: string;
track_index?: number;
clip_index?: number;
};
type TimelineSelectionItemArgs = {
media_type?: string;
track_index?: number;
clip_index?: number;
expected_project_item_id?: string;
expected_start_seconds?: number;
expected_end_seconds?: number;
};
function invoke(
bridge: UxpWebSocketBridge,
command: string,
args: Record<string, unknown> = {},
) {
return bridge.request(command, args)
.then((result) => ({ success: true, data: { backend: "uxp", result } }))
.catch((error: unknown) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}));
}
function target(args: WorkflowArgs): Record<string, unknown> {
return {
...(args.project_item_id !== undefined ? { projectItemId: args.project_item_id } : {}),
...(args.project_item_name !== undefined ? { projectItemName: args.project_item_name } : {}),
};
}
function operation(args: WorkflowArgs): Record<string, unknown> {
return args.operation_id ? { operationId: args.operation_id } : {};
}
function invalidAction(value: unknown) {
return { success: false, error: `Unsupported workflow action: ${String(value)}` };
}
const projectItemProperties = {
project_item_id: { type: "string", minLength: 1, maxLength: 512, description: "Stable project-item ID. Omit with project_item_name to use exactly one Project panel selection." },
project_item_name: { type: "string", minLength: 1, maxLength: 255, description: "Unique media-clip name. Not allowed together with project_item_id." },
};
const operationId = {
type: "string",
pattern: "^[A-Za-z0-9._:-]{1,128}$",
description: "Optional idempotency key for mutating operations.",
};
export function getUxpWorkflowTools(bridge: UxpWebSocketBridge) {
return {
manage_clip_effects_uxp: {
description: "List native audio/video effects, inspect one clip's component chain, or add/remove one effect in a locked Premiere UXP transaction.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["catalog", "inspect", "add", "remove"] },
media_type: { type: "string", enum: ["video", "audio", "all"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
effect_id: { type: "string", minLength: 1, maxLength: 256, description: "Video match name or audio display name returned by catalog." },
insertion_index: { type: "integer", minimum: 0 },
component_index: { type: "integer", minimum: 0 },
expected_effect_id: { type: "string", minLength: 1, maxLength: 256, description: "Required stale-chain guard for removal; must match the inspected match or display name." },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "catalog") return invoke(bridge, "effects.catalog", args.media_type ? { mediaType: args.media_type } : {});
const coordinates = {
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
};
if (args.action === "inspect") return invoke(bridge, "effects.chain.get", coordinates);
if (args.action === "add") return invoke(bridge, "effects.chain.add", {
...coordinates, effectId: args.effect_id,
...(args.insertion_index === undefined ? {} : { insertionIndex: args.insertion_index }),
...operation(args),
});
if (args.action === "remove") return invoke(bridge, "effects.chain.remove", {
...coordinates, componentIndex: args.component_index, expectedEffectId: args.expected_effect_id, ...operation(args),
});
return invalidAction(args.action);
},
},
inspect_track_item_identity_uxp: {
description: "Inspect one active-sequence clip's native match name, item type, media UUID, reported track index, and selection state through documented UXP APIs. It rechecks the active sequence identity before returning and does not expose media paths, effect parameters, or rendered output.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
expected_sequence_guid: { type: "string", minLength: 1, maxLength: 512, description: "Optional stale-snapshot guard from a recent identity inspection." },
},
required: ["media_type", "track_index", "clip_index"],
},
handler: async (args: WorkflowArgs) => invoke(bridge, "trackItem.identity.inspect", {
mediaType: args.media_type, trackIndex: args.track_index, clipIndex: args.clip_index,
...(args.expected_sequence_guid === undefined ? {} : { expectedSequenceGuid: args.expected_sequence_guid }),
}),
},
batch_selected_clips_uxp: {
description: "Inspect the current timeline selection or apply one native effect add/remove across up to 64 same-type selected clips as a single compound transaction.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "add_effect", "remove_effect"] },
media_type: { type: "string", enum: ["video", "audio"] },
effect_id: { type: "string", minLength: 1, maxLength: 256 },
insertion_index: { type: "integer", minimum: 0 },
component_index: { type: "integer", minimum: 0 },
expected_effect_id: { type: "string", minLength: 1, maxLength: 256 },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "inspect") return invoke(bridge, "selection.inspect");
if (args.action === "add_effect") return invoke(bridge, "effects.selection.add", {
mediaType: args.media_type, effectId: args.effect_id,
...(args.insertion_index === undefined ? {} : { insertionIndex: args.insertion_index }),
...operation(args),
});
if (args.action === "remove_effect") return invoke(bridge, "effects.selection.remove", {
mediaType: args.media_type, componentIndex: args.component_index, expectedEffectId: args.expected_effect_id, ...operation(args),
});
return invalidAction(args.action);
},
},
manage_timeline_selection_uxp: {
description: "Inspect, replace, add to, remove from, or clear the active sequence's native UXP clip selection with sequence and clip fingerprint stale-state guards.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "inspect_targets", "replace", "add", "remove", "clear"] },
selection_targets: {
type: "array", minItems: 1, maxItems: 64,
description: "Required for inspect_targets; returns the project-item and timeline-time fingerprints needed by mutation actions.",
items: {
type: "object", additionalProperties: false,
properties: {
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
},
required: ["media_type", "track_index", "clip_index"],
},
},
expected_sequence_guid: {
type: "string", minLength: 1, maxLength: 512,
description: "Required for mutations; copy sequenceGuid from a recent inspect result.",
},
selection_items: {
type: "array", minItems: 1, maxItems: 64,
description: "Required for replace/add/remove. Every coordinate must include the project-item and timeline-time fingerprint returned by inspect.",
items: {
type: "object", additionalProperties: false,
properties: {
media_type: { type: "string", enum: ["video", "audio"] },
track_index: { type: "integer", minimum: 0 },
clip_index: { type: "integer", minimum: 0 },
expected_project_item_id: { type: "string", minLength: 1, maxLength: 512 },
expected_start_seconds: { type: "number", minimum: 0 },
expected_end_seconds: { type: "number", minimum: 0 },
},
required: [
"media_type", "track_index", "clip_index", "expected_project_item_id",
"expected_start_seconds", "expected_end_seconds",
],
},
},
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "inspect") return invoke(bridge, "selection.fingerprints.inspect");
if (args.action === "inspect_targets") {
if (!args.selection_targets?.length) {
return { success: false, error: "inspect_targets requires one or more selection_targets" };
}
return invoke(bridge, "selection.targets.inspect", {
items: args.selection_targets.map((item) => ({
mediaType: item.media_type, trackIndex: item.track_index, clipIndex: item.clip_index,
})),
});
}
if (["replace", "add", "remove", "clear"].includes(args.action ?? "")) {
if (!args.expected_sequence_guid) {
return { success: false, error: `${args.action} requires expected_sequence_guid from a recent inspection` };
}
if (args.action === "clear" && args.selection_items !== undefined) {
return { success: false, error: "clear requires selection_items to be omitted" };
}
if (args.action !== "clear" && !args.selection_items?.length) {
return { success: false, error: `${args.action} requires one or more selection_items` };
}
return invoke(bridge, "selection.update", {
mode: args.action,
expectedSequenceGuid: args.expected_sequence_guid,
...(args.selection_items === undefined ? {} : {
items: args.selection_items.map((item) => ({
mediaType: item.media_type,
trackIndex: item.track_index,
clipIndex: item.clip_index,
expectedProjectItemId: item.expected_project_item_id,
expectedStartSeconds: item.expected_start_seconds,
expectedEndSeconds: item.expected_end_seconds,
})),
}),
...operation(args),
});
}
return invalidAction(args.action);
},
},
detect_scene_edits_uxp: {
description: "Run Premiere's documented scene-edit detection on the current timeline selection using cuts, markers, or subclips. This direct host mutation is not claimed undoable.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
mode: { type: "string", enum: ["apply_cuts", "create_markers", "create_subclips"] },
operation_id: operationId,
},
required: ["mode"],
},
handler: async (args: WorkflowArgs) => {
const modes: Record<string, string> = { apply_cuts: "applyCuts", create_markers: "createMarkers", create_subclips: "createSubclips" };
if (!args.mode || !modes[args.mode]) return invalidAction(args.mode);
return invoke(bridge, "sceneEdit.detect", { mode: modes[args.mode], ...operation(args) });
},
},
manage_proxy_ingest_uxp: {
description: "Inspect or attach proxy/high-resolution media for one clip, or read/update project ingest state. Attach operations are non-undoable and workspace-contained.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect_proxy", "attach_proxy", "get_ingest", "set_ingest"] },
...projectItemProperties,
media_path: { type: "string", minLength: 1, maxLength: 4096 },
is_high_resolution: { type: "boolean", description: "False attaches proxy media; true attaches high-resolution media." },
make_alternate_link_in_team_projects: { type: "boolean" },
replace_existing_proxy: { type: "boolean", description: "Required to replace a different attached proxy." },
enabled: { type: "boolean" },
confirm_non_undoable: { type: "boolean" },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "inspect_proxy") return invoke(bridge, "proxy.inspect", target(args));
if (args.action === "get_ingest") return invoke(bridge, "ingest.get");
if (args.action === "set_ingest") return invoke(bridge, "ingest.configure", { enabled: args.enabled, ...operation(args) });
if (args.action === "attach_proxy") return invoke(bridge, "proxy.attach", {
...target(args), mediaPath: args.media_path,
...(args.is_high_resolution === undefined ? {} : { isHiRes: args.is_high_resolution }),
...(args.make_alternate_link_in_team_projects === undefined ? {} : { makeAlternateLinkInTeamProjects: args.make_alternate_link_in_team_projects }),
...(args.replace_existing_proxy === undefined ? {} : { replaceExistingProxy: args.replace_existing_proxy }),
confirmNonUndoable: args.confirm_non_undoable, ...operation(args),
});
return invalidAction(args.action);
},
},
relink_offline_media_uxp: {
description: "Relink one offline clip to a workspace-contained media path after stale-path and capability checks. This Premiere API is non-undoable and requires explicit confirmation.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
...projectItemProperties,
new_path: { type: "string", minLength: 1, maxLength: 4096 },
expected_current_path: { type: "string", minLength: 1, maxLength: 4096 },
override_compatibility_check: { type: "boolean" },
require_offline: { type: "boolean", description: "Defaults to true." },
confirm_non_undoable: { type: "boolean" },
operation_id: operationId,
},
required: ["new_path", "confirm_non_undoable"],
},
handler: async (args: WorkflowArgs) => invoke(bridge, "media.relink", {
...target(args), newPath: args.new_path,
...(args.expected_current_path ? { expectedCurrentPath: args.expected_current_path } : {}),
...(args.override_compatibility_check === undefined ? {} : { overrideCompatibilityCheck: args.override_compatibility_check }),
...(args.require_offline === undefined ? {} : { requireOffline: args.require_offline }),
confirmNonUndoable: args.confirm_non_undoable, ...operation(args),
}),
},
manage_metadata_uxp: {
description: "Read bounded project/XMP metadata or update either form together in one locked, undoable Premiere transaction with readback evidence.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["get", "update"] },
...projectItemProperties,
project_metadata: { type: "string", maxLength: 350000, description: "Project metadata; combined readback is limited to a 900,000-byte serialized UTF-8 result." },
xmp_metadata: { type: "string", maxLength: 350000, description: "XMP metadata; combined readback is limited to a 900,000-byte serialized UTF-8 result." },
updated_fields: { type: "array", minItems: 1, maxItems: 128, items: { type: "string", minLength: 1, maxLength: 512 } },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "get") return invoke(bridge, "metadata.get", target(args));
if (args.action === "update") return invoke(bridge, "metadata.update", {
...target(args),
...(args.project_metadata === undefined ? {} : { projectMetadata: args.project_metadata }),
...(args.xmp_metadata === undefined ? {} : { xmpMetadata: args.xmp_metadata }),
...(args.updated_fields === undefined ? {} : { updatedFields: args.updated_fields }),
...operation(args),
});
return invalidAction(args.action);
},
},
inspect_project_panel_metadata_uxp: {
description: "Read bounded native Project-panel metadata: either the active project's panel schema or one media item's column metadata. This is read-only; it neither creates metadata schema fields nor writes Project-panel state.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["panel", "item_columns"] },
...projectItemProperties,
},
required: ["action"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "structured_uxp_readback" as const,
hostVerificationRequired: true,
notes: ["Available only through an authenticated UXP bridge whose runtime capability handshake advertises the requested metadata Project-panel command."],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "panel") return invoke(bridge, "metadata.projectPanel.get");
if (args.action === "item_columns") return invoke(bridge, "metadata.columns.get", target(args));
return invalidAction(args.action);
},
},
manage_project_panel_metadata_uxp: {
description: "Inspect or guardedly replace native Project-panel metadata. Update requires the exact inspected project GUID and XML, explicit confirmation, a replay key, local per-project serialization, and exact native readback; Premiere does not expose an atomic compare-and-set for this direct non-undoable setter.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "update"] },
expected_project_guid: { type: "string", minLength: 1, maxLength: 512, description: "Required for update; must exactly match inspect's active-project GUID." },
expected_project_panel_metadata: { type: "string", maxLength: 12288, description: "Required for update; exact inspected Project-panel XML. The UXP host enforces a 12 KiB UTF-8 bound." },
project_panel_metadata: { type: "string", maxLength: 12288, description: "Required replacement Project-panel XML. The UXP host enforces a 12 KiB UTF-8 bound." },
confirm_update: { type: "boolean", description: "Required true for update because this direct setter is non-undoable." },
operation_id: { ...operationId, description: "Required replay key for a guarded Project-panel metadata replacement." },
},
required: ["action"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "project_panel_metadata_exact_readback" as const,
hostVerificationRequired: true,
notes: [
"Update is available only when the authenticated UXP bridge advertises metadata.projectPanel.update.",
"The direct documented setter is non-undoable and has no cancellation support; exact readback proves only the active project returned the requested XML after the setter completed.",
],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "inspect") return invoke(bridge, "metadata.projectPanel.get");
if (args.action === "update") return invoke(bridge, "metadata.projectPanel.update", {
expectedProjectGuid: args.expected_project_guid,
expectedProjectPanelMetadata: args.expected_project_panel_metadata,
projectPanelMetadata: args.project_panel_metadata,
confirmUpdate: args.confirm_update,
operationId: args.operation_id,
});
return invalidAction(args.action);
},
},
create_project_metadata_field_uxp: {
description: "Inspect a bounded native Project-panel schema or request one typed Project metadata field through Adobe's documented direct UXP API. Creation requires the exact inspected project GUID/XML, explicit confirmation, and a replay key; it is non-undoable and reports host acceptance plus schema-change readback without claiming field-level verification or atomic compare-and-set semantics.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["inspect", "create"] },
expected_project_guid: { type: "string", minLength: 1, maxLength: 512, description: "Required for create; must exactly match inspect's active-project GUID." },
expected_project_panel_metadata: { type: "string", maxLength: 12288, description: "Required for create; exact inspected Project-panel XML. The UXP host enforces a 12 KiB UTF-8 bound." },
field_name: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z][A-Za-z0-9_.-]{0,127}$", description: "Required stable metadata field identifier. Starts with a letter; only letters, digits, periods, underscores, and hyphens are accepted." },
field_label: { type: "string", minLength: 1, maxLength: 255, description: "Required user-visible label for the new metadata field." },
schema_field_type: { type: "string", enum: ["integer", "real", "text", "boolean"], description: "Required documented Premiere metadata-field type." },
confirm_create: { type: "boolean", description: "Required true for create because this direct schema API is non-undoable." },
operation_id: { ...operationId, description: "Required replay key for a guarded Project metadata schema field creation." },
},
required: ["action"],
},
operationalCapability: {
backend: "UXP" as const,
backends: ["uxp" as const],
minimumPremiereVersion: "25.6",
verificationBoundary: "project_panel_metadata_change_readback" as const,
hostVerificationRequired: true,
notes: [
"Creation is available only when the authenticated UXP bridge advertises metadata.projectSchema.create.",
"Adobe supplies no atomic compare-and-set or field-level schema getter; success is always committed_unverified even when post-call Project-panel XML changes.",
],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "inspect") return invoke(bridge, "metadata.projectSchema.inspect");
if (args.action === "create") return invoke(bridge, "metadata.projectSchema.create", {
expectedProjectGuid: args.expected_project_guid,
expectedProjectPanelMetadata: args.expected_project_panel_metadata,
fieldName: args.field_name,
fieldLabel: args.field_label,
fieldType: args.schema_field_type,
confirmCreate: args.confirm_create,
operationId: args.operation_id,
});
return invalidAction(args.action);
},
},
manage_color_conformance_uxp: {
description: "Preflight project graphics-white/LUT/footage interpretation state, or update bounded footage-conformance fields in one undoable UXP transaction.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["preflight", "update"] },
...projectItemProperties,
frame_rate: { type: "number", minimum: 1, maximum: 240 },
pixel_aspect_ratio: { type: "number", minimum: 0.01, maximum: 100 },
field_type: { type: "integer", minimum: 0, maximum: 64 },
remove_pulldown: { type: "boolean" },
alpha_usage: { type: "integer", minimum: 0, maximum: 64 },
ignore_alpha: { type: "boolean" },
invert_alpha: { type: "boolean" },
vr_conform: { type: "integer", minimum: 0, maximum: 64 },
vr_layout: { type: "integer", minimum: 0, maximum: 64 },
vr_horizontal_view: { type: "number", minimum: 1, maximum: 360 },
vr_vertical_view: { type: "number", minimum: 1, maximum: 180 },
input_lut_id: { type: "string", maxLength: 512 },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "preflight") return invoke(bridge, "color.preflight", target(args));
if (args.action === "update") return invoke(bridge, "footage.conform", {
...target(args),
...(args.frame_rate === undefined ? {} : { frameRate: args.frame_rate }),
...(args.pixel_aspect_ratio === undefined ? {} : { pixelAspectRatio: args.pixel_aspect_ratio }),
...(args.field_type === undefined ? {} : { fieldType: args.field_type }),
...(args.remove_pulldown === undefined ? {} : { removePullDown: args.remove_pulldown }),
...(args.alpha_usage === undefined ? {} : { alphaUsage: args.alpha_usage }),
...(args.ignore_alpha === undefined ? {} : { ignoreAlpha: args.ignore_alpha }),
...(args.invert_alpha === undefined ? {} : { invertAlpha: args.invert_alpha }),
...(args.vr_conform === undefined ? {} : { vrConform: args.vr_conform }),
...(args.vr_layout === undefined ? {} : { vrLayout: args.vr_layout }),
...(args.vr_horizontal_view === undefined ? {} : { vrHorzView: args.vr_horizontal_view }),
...(args.vr_vertical_view === undefined ? {} : { vrVertView: args.vr_vertical_view }),
...(args.input_lut_id === undefined ? {} : { inputLutId: args.input_lut_id }),
...operation(args),
});
return invalidAction(args.action);
},
},
audition_source_monitor_uxp: {
description: "Open a selected project item or approved file, inspect/set position, play at bounded speed, or close Source Monitor media through documented UXP APIs.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["state", "open_project_item", "open_file", "set_position", "play", "close", "close_all"] },
...projectItemProperties,
file_path: { type: "string", minLength: 1, maxLength: 4096 },
seconds: { type: "number", minimum: 0 },
speed: { type: "number", minimum: -16, maximum: 16 },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "state") return invoke(bridge, "sourceMonitor.state");
if (args.action === "open_project_item") return invoke(bridge, "sourceMonitor.open", { ...target(args), ...operation(args) });
if (args.action === "open_file") return invoke(bridge, "sourceMonitor.open", { filePath: args.file_path, ...operation(args) });
if (args.action === "set_position") return invoke(bridge, "sourceMonitor.position.set", { seconds: args.seconds, ...operation(args) });
if (args.action === "play") return invoke(bridge, "sourceMonitor.play", { ...(args.speed === undefined ? {} : { speed: args.speed }), ...operation(args) });
if (args.action === "close" || args.action === "close_all") return invoke(bridge, "sourceMonitor.close", { all: args.action === "close_all", ...operation(args) });
return invalidAction(args.action);
},
},
preflight_production_storage_uxp: {
description: "Inspect project/Production scratch disks and ingest state, or set supported project scratch categories to Premiere's symbolic destinations in one undoable transaction.",
parameters: {
type: "object" as const,
additionalProperties: false,
properties: {
action: { type: "string", enum: ["preflight", "configure_project"] },
folder_types: {
type: "array", minItems: 1, maxItems: 6, uniqueItems: true,
items: { type: "string", enum: ["capture", "audio_preview", "video_preview", "auto_save", "cc_libraries", "capsule_media"] },
},
destination: { type: "string", enum: ["same_as_project", "my_documents"] },
operation_id: operationId,
},
required: ["action"],
},
handler: async (args: WorkflowArgs) => {
if (args.action === "preflight") return invoke(bridge, "storage.preflight");
if (args.action !== "configure_project") return invalidAction(args.action);
if (!args.folder_types?.length ||
(args.destination !== "same_as_project" && args.destination !== "my_documents")) {
return { success: false, error: "configure_project requires folder_types and destination" };
}
const typeMap: Record<string, string> = {
capture: "capture", audio_preview: "audioPreview", video_preview: "videoPreview",
auto_save: "autoSave", cc_libraries: "ccLibraries", capsule_media: "capsuleMedia",
};
return invoke(bridge, "scratch.configure", {
folderTypes: args.folder_types.map((value) => typeMap[value]),
destination: args.destination === "same_as_project" ? "sameAsProject" : "myDocuments",
...operation(args),
});
},
},
inspect_premiere_environment_uxp: {
description: "Inspect After Effects interoperability and the active Premiere project's current and supported graphics-white luminance values through documented read-only UXP APIs.",
parameters: {},
handler: async () => invoke(bridge, "environment.inspect"),
},
get_uxp_workspace_access: {
description: "Report whether the Premiere panel has an operator-approved persistent workspace folder. The native path and persistent token are never returned.",
parameters: {},
handler: async () => invoke(bridge, "workspace.status"),
},
};
}
+1009
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
import { createHash } from "node:crypto";
import { readFileSync, realpathSync, statSync } from "node:fs";
import path from "node:path";
const MAX_RECIPE_BYTES = 1024 * 1024;
const MAX_RECIPES = 64;
const MAX_STEPS = 24;
const STEP_ROUTES = {
verify_connection: ["verify_premiere_connection"],
inspect_sequence: ["get_active_sequence", "get_sequence_structure"],
export_transcript: ["get_clip_transcript_uxp"],
analyze_dialogue: ["analyze_dialogue_edit_candidates"],
preview_dialogue_derivative: ["preview_derived_dialogue_sequence_uxp"],
apply_dialogue_derivative: ["apply_derived_dialogue_sequence_uxp"],
detect_silence: ["detect_silence"],
inspect_watched_media: ["manage_media_watch", "preview_watched_media_import"],
import_approved_media: ["import_project_media_uxp"],
clone_sequence: ["manage_sequences_uxp"],
auto_reframe: ["auto_reframe_sequence"],
caption_artifact: ["create_caption_track"],
verify_sequence: ["get_sequence_structure", "inspect_sequence_review_report"],
review_frames: ["export_sequence_review_frames"],
export_delivery: ["validate_project_for_export", "export_sequence", "verify_delivery_file"],
} as const;
export type WorkflowRecipeStep = keyof typeof STEP_ROUTES;
export type WorkflowRecipe = {
schema_version: 1;
id: string;
title: string;
description: string;
tags: string[];
required_inputs: string[];
steps: WorkflowRecipeStep[];
};
const BUILT_INS: readonly WorkflowRecipe[] = [
{ schema_version: 1, id: "talking-head-cleanup", title: "Talking-head cleanup", description: "Review transcript and silence evidence, then create and verify a dialogue-cleaned derivative sequence.", tags: ["dialogue", "rough-cut"], required_inputs: ["source_project_item_id", "transcript_revision", "approved_segments", "sequence_name"], steps: ["verify_connection", "export_transcript", "detect_silence", "analyze_dialogue", "preview_dialogue_derivative", "apply_dialogue_derivative", "verify_sequence"] },
{ schema_version: 1, id: "podcast-first-cut", title: "Podcast first cut", description: "Review speaker and camera assignments, then create a standard derivative sequence with continuous approved master audio.", tags: ["podcast", "multicamera", "rough-cut"], required_inputs: ["camera_source_ids", "master_audio_project_item_id", "approved_segments", "sequence_name"], steps: ["verify_connection", "inspect_sequence", "export_transcript", "analyze_dialogue", "preview_dialogue_derivative", "apply_dialogue_derivative", "verify_sequence"] },
{ schema_version: 1, id: "shorts-cutdown", title: "Shorts cutdown", description: "Create a reviewed dialogue derivative, clone it, reframe it, add a supplied caption artifact, and verify delivery evidence.", tags: ["social", "vertical", "captions"], required_inputs: ["approved_segments", "target_dimensions", "caption_artifact", "export_preset"], steps: ["verify_connection", "preview_dialogue_derivative", "apply_dialogue_derivative", "clone_sequence", "auto_reframe", "caption_artifact", "review_frames", "export_delivery"] },
{ schema_version: 1, id: "watched-media-intake", title: "Watched-media intake", description: "Review new or changed files in an approved local folder before importing selected media into Premiere.", tags: ["intake", "media", "organization"], required_inputs: ["approved_workspace_path", "watch_path", "allowed_extensions"], steps: ["verify_connection", "inspect_watched_media", "import_approved_media", "verify_sequence"] },
];
function requiredText(value: unknown, label: string, max = 255): string {
if (typeof value !== "string" || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
return value.trim();
}
function containedFile(workspace: string, candidate: string): string {
if (!path.isAbsolute(workspace) || !path.isAbsolute(candidate)) throw new Error("approved_workspace_path and recipe_file must be absolute paths");
const root = realpathSync(workspace);
const file = realpathSync(candidate);
const relative = path.relative(root, file);
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("recipe_file must be contained within approved_workspace_path");
if (!statSync(file).isFile()) throw new Error("recipe_file must be a regular file");
return file;
}
export function validateWorkflowRecipe(value: unknown, label = "recipe"): WorkflowRecipe {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
const input = value as Record<string, unknown>;
const allowed = ["schema_version", "id", "title", "description", "tags", "required_inputs", "steps"];
const unknown = Object.keys(input).find((key) => !allowed.includes(key));
if (unknown) throw new Error(`${label} has an unknown field: ${unknown}`);
if (input.schema_version !== 1) throw new Error(`${label}.schema_version must be 1`);
const strings = (raw: unknown, field: string, maxItems: number) => {
if (!Array.isArray(raw) || raw.length > maxItems) throw new Error(`${field} must be an array with at most ${maxItems} entries`);
const values = raw.map((item, index) => requiredText(item, `${field}[${index}]`, 128));
if (new Set(values).size !== values.length) throw new Error(`${field} contains duplicates`);
return values;
};
const rawSteps = strings(input.steps, `${label}.steps`, MAX_STEPS);
if (!rawSteps.length) throw new Error(`${label}.steps must not be empty`);
const steps = rawSteps.map((step) => {
if (!(step in STEP_ROUTES)) throw new Error(`${label}.steps contains unsupported declarative step: ${step}`);
return step as WorkflowRecipeStep;
});
return {
schema_version: 1,
id: requiredText(input.id, `${label}.id`, 128),
title: requiredText(input.title, `${label}.title`),
description: requiredText(input.description, `${label}.description`, 1000),
tags: strings(input.tags ?? [], `${label}.tags`, 16),
required_inputs: strings(input.required_inputs ?? [], `${label}.required_inputs`, 32),
steps,
};
}
function customRecipes(workspace?: unknown, file?: unknown): WorkflowRecipe[] {
if (workspace === undefined && file === undefined) return [];
const workspacePath = requiredText(workspace, "approved_workspace_path", 4096);
const filePath = containedFile(workspacePath, requiredText(file, "recipe_file", 4096));
if (statSync(filePath).size > MAX_RECIPE_BYTES) throw new Error("recipe_file exceeds 1 MiB");
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
if (!Array.isArray(parsed) || parsed.length > MAX_RECIPES) throw new Error(`recipe_file must contain a JSON array of at most ${MAX_RECIPES} recipes`);
const recipes = parsed.map((entry, index) => validateWorkflowRecipe(entry, `recipes[${index}]`));
const ids = new Set(BUILT_INS.map((recipe) => recipe.id));
for (const recipe of recipes) {
if (ids.has(recipe.id)) throw new Error(`recipe id conflicts with another recipe: ${recipe.id}`);
ids.add(recipe.id);
}
return recipes;
}
function recipeDigest(recipe: WorkflowRecipe): string {
return `sha256:${createHash("sha256").update(JSON.stringify(recipe)).digest("hex")}`;
}
export function getWorkflowRecipeTools() {
const customProperties = {
approved_workspace_path: { type: "string", maxLength: 4096, description: "Absolute approved workspace containing recipe_file; required with recipe_file." },
recipe_file: { type: "string", maxLength: 4096, description: "Optional contained JSON file with closed-schema custom recipes." },
};
return {
search_workflow_recipes: {
description: "Search audited built-in and explicitly supplied workspace-local workflow recipes. Recipes are declarative previews and cannot execute arbitrary tools or scripts.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
query: { type: "string", maxLength: 256, description: "Optional case-insensitive title, description, tag, or ID query." },
...customProperties,
} },
handler: async (args: Record<string, unknown>) => {
try {
const query = typeof args.query === "string" ? args.query.trim().toLocaleLowerCase() : "";
const recipes = [...BUILT_INS, ...customRecipes(args.approved_workspace_path, args.recipe_file)].filter((recipe) => !query || `${recipe.id} ${recipe.title} ${recipe.description} ${recipe.tags.join(" ")}`.toLocaleLowerCase().includes(query));
return { success: true, data: { recipes: recipes.map((recipe) => ({ id: recipe.id, title: recipe.title, description: recipe.description, tags: recipe.tags, source: BUILT_INS.some((item) => item.id === recipe.id) ? "built_in" : "workspace_file" })), count: recipes.length } };
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
preview_workflow_recipe: {
description: "Validate and expand one declarative workflow recipe into guarded MCP routes. It does not invoke any route or change Premiere.",
parameters: { type: "object" as const, additionalProperties: false, properties: {
recipe_id: { type: "string", minLength: 1, maxLength: 128, description: "Exact built-in or workspace recipe ID." },
provided_inputs: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 128 }, description: "Names of inputs already available to the caller; values are intentionally not persisted in the recipe preview." },
...customProperties,
}, required: ["recipe_id"] },
handler: async (args: Record<string, unknown>) => {
try {
const id = requiredText(args.recipe_id, "recipe_id", 128);
const recipe = [...BUILT_INS, ...customRecipes(args.approved_workspace_path, args.recipe_file)].find((item) => item.id === id);
if (!recipe) throw new Error(`workflow recipe not found: ${id}`);
const provided = Array.isArray(args.provided_inputs) ? args.provided_inputs.map((item, index) => requiredText(item, `provided_inputs[${index}]`, 128)) : [];
const missing = recipe.required_inputs.filter((name) => !provided.includes(name));
return { success: true, data: { recipe: { ...recipe, digest: recipeDigest(recipe) }, missing_inputs: missing, ready: missing.length === 0, applied: false, execution_manifest: recipe.steps.map((step, index) => ({ index, step, routes: [...STEP_ROUTES[step]], authority: "Each route retains its own capability, revision, confirmation, and verification checks." })) } };
} catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
},
},
};
}
+45
View File
@@ -0,0 +1,45 @@
import { buildToolScript, escapeForExtendScript } from "../bridge/script-builder.js";
import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
export function getWorkspaceTools(bridgeOptions: BridgeOptions) {
return {
get_workspaces: {
description: "List all available workspace layouts in Premiere Pro",
parameters: {},
handler: async () => {
const script = buildToolScript(`
var workspaces = app.getWorkspaces();
if (!workspaces) return __error("Could not retrieve workspaces");
var list = [];
for (var i = 0; i < workspaces.length; i++) {
list.push(workspaces[i]);
}
return __result({ workspaces: list, count: list.length });
`);
return sendCommand(script, bridgeOptions);
},
},
set_workspace: {
description: "Switch to a specific workspace layout (e.g., 'Editing', 'Color', 'Audio', 'Effects', 'Graphics')",
parameters: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Name of the workspace to activate (use get_workspaces to see available options)",
},
},
required: ["name"],
},
handler: async (args: { name: string }) => {
const script = buildToolScript(`
var result = app.setWorkspace("${escapeForExtendScript(args.name)}");
if (!result) return __error("Failed to set workspace: ${escapeForExtendScript(args.name)}");
return __result({ set: true, workspace: "${escapeForExtendScript(args.name)}" });
`);
return sendCommand(script, bridgeOptions);
},
},
};
}