2026-04-07 22:05:47 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Wild Dragon MAM - Premiere Pro ExtendScript
|
|
|
|
|
|
*
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* This file runs in the Premiere Pro host context (not the browser panel).
|
|
|
|
|
|
* It is registered via <ScriptPath> in manifest.xml and called from the
|
|
|
|
|
|
* panel via csInterface.evalScript().
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* ExtendScript is ES3-level JavaScript — no arrow functions, no const/let,
|
|
|
|
|
|
* no template literals, no destructuring.
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// Core Import Functions
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Imports a media file into the active Premiere Pro project.
|
2026-04-07 22:05:47 -04:00
|
|
|
|
* @param {string} filePath - Full path to the file to import
|
|
|
|
|
|
* @returns {string} JSON string with success status and message
|
|
|
|
|
|
*/
|
|
|
|
|
|
function importFileToProject(filePath) {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: "",
|
|
|
|
|
|
itemID: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active Premiere Pro project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var file = new File(filePath);
|
|
|
|
|
|
if (!file.exists) {
|
|
|
|
|
|
result.message = "File does not exist: " + filePath;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.project.importFiles([filePath]);
|
|
|
|
|
|
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "File imported successfully";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "Error importing file: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Gets the active sequence in the project.
|
|
|
|
|
|
* @returns {string} JSON string with sequence name and ID, or nulls
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function getActiveSequence() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
sequenceName: null,
|
|
|
|
|
|
sequenceID: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
var activeSequence = app.project.activeSequence;
|
|
|
|
|
|
if (activeSequence) {
|
|
|
|
|
|
result.sequenceName = activeSequence.name;
|
2026-05-15 21:36:15 -04:00
|
|
|
|
result.sequenceID = activeSequence.sequenceID;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Inserts a clip into the active sequence at the playhead position.
|
|
|
|
|
|
* @param {string} filePath - Full path to the media file
|
2026-04-07 22:05:47 -04:00
|
|
|
|
* @param {number} trackIndex - Video track index (1-based)
|
|
|
|
|
|
* @returns {string} JSON string with success status
|
|
|
|
|
|
*/
|
|
|
|
|
|
function insertClipToSequence(filePath, trackIndex) {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: ""
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.project.importFiles([filePath]);
|
|
|
|
|
|
|
|
|
|
|
|
var file = new File(filePath);
|
|
|
|
|
|
var fileName = file.displayName;
|
|
|
|
|
|
var rootBin = app.project.rootItem;
|
2026-05-15 21:36:15 -04:00
|
|
|
|
var projectItem = findProjectItemByName(rootBin, fileName);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
if (!projectItem) {
|
|
|
|
|
|
result.message = "Could not find imported file in project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (trackIndex < 1 || trackIndex > sequence.videoTracks.numTracks) {
|
|
|
|
|
|
result.message = "Invalid track index: " + trackIndex;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var track = sequence.videoTracks[trackIndex - 1];
|
2026-05-15 21:36:15 -04:00
|
|
|
|
var playheadTime = sequence.getPlayerPosition();
|
2026-04-07 22:05:47 -04:00
|
|
|
|
var trackItem = track.insertClip(projectItem, playheadTime);
|
|
|
|
|
|
|
|
|
|
|
|
if (trackItem) {
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "Clip inserted at track " + trackIndex;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.message = "Failed to insert clip into track";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "Error inserting clip: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Gets the current project file path.
|
|
|
|
|
|
* @returns {string} JSON string with project path and name
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function getProjectPath() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
projectPath: null,
|
|
|
|
|
|
projectName: null
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
2026-05-15 21:36:15 -04:00
|
|
|
|
result.projectPath = app.project.path;
|
|
|
|
|
|
result.projectName = app.project.name;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Gets information about all video tracks in the active sequence.
|
|
|
|
|
|
* @returns {string} JSON string with an array of track objects
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function getSequenceTracks() {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
var result = { tracks: [] };
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!sequence) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < sequence.videoTracks.numTracks; i++) {
|
|
|
|
|
|
var track = sequence.videoTracks[i];
|
|
|
|
|
|
result.tracks.push({
|
|
|
|
|
|
index: i + 1,
|
2026-05-15 21:36:15 -04:00
|
|
|
|
name: track.name || ("V" + (i + 1)),
|
|
|
|
|
|
type: "video"
|
2026-04-07 22:05:47 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Gets the current playhead position in the active sequence.
|
|
|
|
|
|
* @returns {string} JSON string with timeInSeconds and SMPTE timeCode
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function getPlayheadPosition() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
timeInSeconds: 0,
|
|
|
|
|
|
timeCode: ""
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!sequence) return JSON.stringify(result);
|
|
|
|
|
|
|
|
|
|
|
|
var time = sequence.getPlayerPosition();
|
|
|
|
|
|
var ticks = parseFloat(time.ticks);
|
|
|
|
|
|
// Premiere Pro ticks: 254016000000 ticks per second
|
|
|
|
|
|
var TICKS_PER_SECOND = 254016000000;
|
|
|
|
|
|
var totalSeconds = ticks / TICKS_PER_SECOND;
|
|
|
|
|
|
result.timeInSeconds = totalSeconds;
|
|
|
|
|
|
|
|
|
|
|
|
// Build SMPTE timecode — use sequence frame rate
|
|
|
|
|
|
var frameRate = sequence.timebase ? (TICKS_PER_SECOND / parseFloat(sequence.timebase)) : 25;
|
|
|
|
|
|
var totalFrames = Math.floor(totalSeconds * frameRate);
|
|
|
|
|
|
var hours = Math.floor(totalFrames / (frameRate * 3600));
|
|
|
|
|
|
var minutes = Math.floor((totalFrames % (frameRate * 3600)) / (frameRate * 60));
|
|
|
|
|
|
var seconds = Math.floor((totalFrames % (frameRate * 60)) / frameRate);
|
|
|
|
|
|
var frames = totalFrames % Math.round(frameRate);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
result.timeCode = pad(hours, 2) + ":" + pad(minutes, 2) + ":" +
|
2026-05-15 21:36:15 -04:00
|
|
|
|
pad(seconds, 2) + ":" + pad(frames, 2);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Gets basic project information.
|
|
|
|
|
|
* @returns {string} JSON string with project name, path, and sequence count
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function getProjectInfo() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
projectName: "",
|
|
|
|
|
|
projectPath: "",
|
2026-05-15 21:36:15 -04:00
|
|
|
|
sequenceCount: 0
|
2026-04-07 22:05:47 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
result.projectName = app.project.name;
|
2026-05-15 21:36:15 -04:00
|
|
|
|
result.projectPath = app.project.path;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
|
|
|
|
|
var rootBin = app.project.rootItem;
|
|
|
|
|
|
if (rootBin && rootBin.children) {
|
|
|
|
|
|
for (var i = 0; i < rootBin.children.numItems; i++) {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (rootBin.children[i].type === ProjectItemType.SEQUENCE) {
|
|
|
|
|
|
result.sequenceCount++;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-15 21:36:15 -04:00
|
|
|
|
* Exports the current sequence using Adobe Media Encoder.
|
|
|
|
|
|
*
|
|
|
|
|
|
* AME must be installed. This function launches AME (if not already running)
|
|
|
|
|
|
* and queues the export job — it returns immediately; encoding happens in the
|
|
|
|
|
|
* background and the output file appears when AME finishes.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} outputPath - Absolute path for the output file
|
|
|
|
|
|
* @param {string} presetPath - Absolute path to an AME preset file (.epr);
|
|
|
|
|
|
* pass an empty string to use the sequence's
|
|
|
|
|
|
* current export settings.
|
|
|
|
|
|
* @returns {string} JSON string with success flag, message, and jobId
|
2026-04-07 22:05:47 -04:00
|
|
|
|
*/
|
2026-05-15 21:36:15 -04:00
|
|
|
|
function exportSequence(outputPath, presetPath) {
|
2026-04-07 22:05:47 -04:00
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
2026-05-15 21:36:15 -04:00
|
|
|
|
message: "",
|
|
|
|
|
|
jobId: null
|
2026-04-07 22:05:47 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 21:36:15 -04:00
|
|
|
|
// Ensure Adobe Media Encoder is running before queuing
|
|
|
|
|
|
app.encoder.launchEncoder();
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
2026-05-15 21:36:15 -04:00
|
|
|
|
// encodeSequence(sequence, outputFilePath, presetPath, workAreaType, removeOnCompletion)
|
|
|
|
|
|
// workAreaType: ENCODE_ENTIRE = 0, ENCODE_IN_TO_OUT = 1
|
|
|
|
|
|
var jobId = app.encoder.encodeSequence(
|
|
|
|
|
|
sequence,
|
|
|
|
|
|
outputPath,
|
|
|
|
|
|
presetPath || "",
|
|
|
|
|
|
app.encoder.ENCODE_ENTIRE,
|
|
|
|
|
|
false // keep job in AME queue after completion
|
|
|
|
|
|
);
|
2026-04-07 22:05:47 -04:00
|
|
|
|
|
2026-05-15 21:36:15 -04:00
|
|
|
|
if (jobId) {
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "Export queued in Adobe Media Encoder";
|
|
|
|
|
|
result.jobId = jobId;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.message = "AME returned no job ID — verify that the preset path is valid";
|
2026-04-07 22:05:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
2026-05-15 21:36:15 -04:00
|
|
|
|
result.message = "Export error: " + error.message;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-05-20 00:35:18 -04:00
|
|
|
|
// Timeline Export — read sequence clips for MAM round-trip
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Reads all clips from all video tracks in the active sequence.
|
|
|
|
|
|
* Returns JSON with sequence settings and a flat clip array.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Each clip entry contains:
|
|
|
|
|
|
* filePath – absolute OS path to source media (for MAM asset ID lookup)
|
|
|
|
|
|
* fileName – project panel display name
|
|
|
|
|
|
* trackIndex – 0-based video track index (0 = V1, 1 = V2, …)
|
|
|
|
|
|
* sourceInFrames – in-point within source media, in frames
|
|
|
|
|
|
* sourceOutFrames – out-point within source media, in frames
|
|
|
|
|
|
* timelineInFrames – clip start position on timeline, in frames
|
|
|
|
|
|
* timelineOutFrames – clip end position on timeline, in frames
|
|
|
|
|
|
*
|
|
|
|
|
|
* sequence.timebase is ticks-per-frame. Premiere uses 254,016,000,000
|
|
|
|
|
|
* ticks/second, so fps = 254016000000 / timebase.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function exportTimelineData() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: "",
|
|
|
|
|
|
sequenceName: "",
|
|
|
|
|
|
frameRate: 59.94,
|
|
|
|
|
|
width: 1920,
|
|
|
|
|
|
height: 1080,
|
|
|
|
|
|
clips: []
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.sequenceName = sequence.name;
|
|
|
|
|
|
|
|
|
|
|
|
var TICKS_PER_SECOND = 254016000000;
|
|
|
|
|
|
var timebaseTicks = parseFloat(sequence.timebase) || 4240384;
|
|
|
|
|
|
result.frameRate = parseFloat((TICKS_PER_SECOND / timebaseTicks).toFixed(4));
|
|
|
|
|
|
|
|
|
|
|
|
try { result.width = sequence.frameSizeHorizontal || 1920; } catch (e) {}
|
|
|
|
|
|
try { result.height = sequence.frameSizeVertical || 1080; } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
var videoTracks = sequence.videoTracks;
|
|
|
|
|
|
for (var t = 0; t < videoTracks.numTracks; t++) {
|
|
|
|
|
|
var track = videoTracks[t];
|
|
|
|
|
|
if (!track || !track.clips) continue;
|
|
|
|
|
|
var numClips = track.clips.numItems;
|
|
|
|
|
|
for (var c = 0; c < numClips; c++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var clip = track.clips[c];
|
|
|
|
|
|
if (!clip || !clip.projectItem) continue;
|
|
|
|
|
|
|
|
|
|
|
|
var filePath = "";
|
|
|
|
|
|
try { filePath = clip.projectItem.getMediaPath(); } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
var srcIn = Math.round(parseFloat(clip.inPoint.ticks) / timebaseTicks);
|
|
|
|
|
|
var srcOut = Math.round(parseFloat(clip.outPoint.ticks) / timebaseTicks);
|
|
|
|
|
|
var recIn = Math.round(parseFloat(clip.start.ticks) / timebaseTicks);
|
|
|
|
|
|
var recOut = Math.round(parseFloat(clip.end.ticks) / timebaseTicks);
|
|
|
|
|
|
|
|
|
|
|
|
// Skip degenerate clips (zero or negative duration)
|
|
|
|
|
|
if (srcOut <= srcIn || recOut <= recIn) continue;
|
|
|
|
|
|
|
|
|
|
|
|
result.clips.push({
|
|
|
|
|
|
trackIndex: t,
|
|
|
|
|
|
filePath: filePath,
|
|
|
|
|
|
fileName: clip.projectItem.name || "",
|
|
|
|
|
|
sourceInFrames: srcIn,
|
|
|
|
|
|
sourceOutFrames: srcOut,
|
|
|
|
|
|
timelineInFrames: recIn,
|
|
|
|
|
|
timelineOutFrames: recOut
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) { /* skip malformed clip */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = result.clips.length + " clip(s) across " + videoTracks.numTracks + " track(s)";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "Error reading timeline: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Returns all media ProjectItems in the current project with name + file path.
|
|
|
|
|
|
* Useful for rebuilding the asset-path lookup map after a Premiere restart
|
|
|
|
|
|
* when temp-file paths may have changed.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getProjectItems() {
|
|
|
|
|
|
var result = { items: [] };
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) return JSON.stringify(result);
|
|
|
|
|
|
_collectProjectItems(app.project.rootItem, result.items);
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _collectProjectItems(bin, out) {
|
|
|
|
|
|
if (!bin || !bin.children) return;
|
|
|
|
|
|
for (var i = 0; i < bin.children.numItems; i++) {
|
|
|
|
|
|
var item = bin.children[i];
|
|
|
|
|
|
if (!item) continue;
|
|
|
|
|
|
if (item.type === ProjectItemType.BIN) {
|
|
|
|
|
|
_collectProjectItems(item, out);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
var path = "";
|
|
|
|
|
|
try { path = item.getMediaPath(); } catch (e) {}
|
|
|
|
|
|
out.push({ name: item.name, path: path });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
|
// Advanced Features — FCP XML Export & Hi-Res Relink
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Enhanced timeline export that includes a unique clipInstanceId per clip.
|
|
|
|
|
|
* Each instance ID is derived from the project item's nodeId + track index +
|
|
|
|
|
|
* in-point frame so it is stable across repeated reads of the same timeline.
|
|
|
|
|
|
* @returns {string} JSON string with clip array including clipInstanceId fields
|
|
|
|
|
|
*/
|
|
|
|
|
|
function exportTimelineDataWithIds() {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: "",
|
|
|
|
|
|
sequenceName: "",
|
|
|
|
|
|
frameRate: 59.94,
|
|
|
|
|
|
width: 1920,
|
|
|
|
|
|
height: 1080,
|
|
|
|
|
|
clips: []
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.sequenceName = sequence.name;
|
|
|
|
|
|
|
|
|
|
|
|
var TICKS_PER_SECOND = 254016000000;
|
|
|
|
|
|
var timebaseTicks = parseFloat(sequence.timebase) || 4240384;
|
|
|
|
|
|
result.frameRate = parseFloat((TICKS_PER_SECOND / timebaseTicks).toFixed(4));
|
|
|
|
|
|
|
|
|
|
|
|
try { result.width = sequence.frameSizeHorizontal || 1920; } catch (e) {}
|
|
|
|
|
|
try { result.height = sequence.frameSizeVertical || 1080; } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
var videoTracks = sequence.videoTracks;
|
|
|
|
|
|
for (var t = 0; t < videoTracks.numTracks; t++) {
|
|
|
|
|
|
var track = videoTracks[t];
|
|
|
|
|
|
if (!track || !track.clips) continue;
|
|
|
|
|
|
var numClips = track.clips.numItems;
|
|
|
|
|
|
for (var c = 0; c < numClips; c++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var clip = track.clips[c];
|
|
|
|
|
|
if (!clip || !clip.projectItem) continue;
|
|
|
|
|
|
|
|
|
|
|
|
var filePath = "";
|
|
|
|
|
|
try { filePath = clip.projectItem.getMediaPath(); } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
var srcIn = Math.round(parseFloat(clip.inPoint.ticks) / timebaseTicks);
|
|
|
|
|
|
var srcOut = Math.round(parseFloat(clip.outPoint.ticks) / timebaseTicks);
|
|
|
|
|
|
var recIn = Math.round(parseFloat(clip.start.ticks) / timebaseTicks);
|
|
|
|
|
|
var recOut = Math.round(parseFloat(clip.end.ticks) / timebaseTicks);
|
|
|
|
|
|
|
|
|
|
|
|
if (srcOut <= srcIn || recOut <= recIn) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Build a stable unique ID from the project item nodeId,
|
|
|
|
|
|
// track index, and timeline in-point.
|
|
|
|
|
|
var nodeId = "";
|
|
|
|
|
|
try { nodeId = clip.projectItem.nodeId; } catch (e) {}
|
|
|
|
|
|
if (!nodeId) nodeId = clip.projectItem.name + "_" + t;
|
|
|
|
|
|
var clipInstanceId = nodeId + "_" + t + "_" + recIn;
|
|
|
|
|
|
|
|
|
|
|
|
result.clips.push({
|
|
|
|
|
|
clipInstanceId: clipInstanceId,
|
|
|
|
|
|
assetId: null,
|
|
|
|
|
|
trackIndex: t,
|
|
|
|
|
|
filePath: filePath,
|
|
|
|
|
|
fileName: clip.projectItem.name || "",
|
|
|
|
|
|
sourceInFrames: srcIn,
|
|
|
|
|
|
sourceOutFrames: srcOut,
|
|
|
|
|
|
timelineInFrames: recIn,
|
|
|
|
|
|
timelineOutFrames:recOut
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) { /* skip malformed clip */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = result.clips.length + " clip(s) with instance IDs";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "Error reading timeline with IDs: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Relinks a specific clip in the Premiere project by finding it via
|
|
|
|
|
|
* clipInstanceId (from timeline export) and swapping its media path.
|
|
|
|
|
|
*
|
|
|
|
|
|
* ExtendScript cannot receive complex objects, so clipInstanceId and newPath
|
|
|
|
|
|
* are passed as separate string arguments.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} clipInstanceId - The clip instance ID from exportTimelineDataWithIds()
|
|
|
|
|
|
* @param {string} newMediaPath - Absolute path to the replacement media file
|
|
|
|
|
|
* @returns {string} JSON with success flag, message, and relinked count
|
|
|
|
|
|
*/
|
|
|
|
|
|
function relinkClipToNewMedia(clipInstanceId, newMediaPath) {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: "",
|
|
|
|
|
|
relinked: 0
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse clipInstanceId: nodeId_track_frame
|
|
|
|
|
|
var parts = clipInstanceId.split("_");
|
|
|
|
|
|
if (parts.length < 3) {
|
|
|
|
|
|
result.message = "Invalid clipInstanceId format";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
var targetTrack = parseInt(parts[parts.length - 2], 10);
|
|
|
|
|
|
var targetInFrame = parseInt(parts[parts.length - 1], 10);
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var TICKS_PER_SECOND = 254016000000;
|
|
|
|
|
|
var timebaseTicks = parseFloat(sequence.timebase) || 4240384;
|
|
|
|
|
|
|
|
|
|
|
|
// Walk the target track and find matching clip
|
|
|
|
|
|
var videoTracks = sequence.videoTracks;
|
|
|
|
|
|
if (targetTrack < 0 || targetTrack >= videoTracks.numTracks) {
|
|
|
|
|
|
result.message = "Track " + targetTrack + " out of range";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var track = videoTracks[targetTrack];
|
|
|
|
|
|
if (!track || !track.clips) {
|
|
|
|
|
|
result.message = "No clips on track " + targetTrack;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var matched = false;
|
|
|
|
|
|
for (var c = 0; c < track.clips.numItems; c++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var clip = track.clips[c];
|
|
|
|
|
|
if (!clip) continue;
|
|
|
|
|
|
|
|
|
|
|
|
var recIn = Math.round(parseFloat(clip.start.ticks) / timebaseTicks);
|
|
|
|
|
|
if (recIn === targetInFrame) {
|
|
|
|
|
|
var newFile = new File(newMediaPath);
|
|
|
|
|
|
if (!newFile.exists) {
|
|
|
|
|
|
result.message = "New media not found: " + newMediaPath;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Import the new media file into the project
|
|
|
|
|
|
app.project.importFiles([newMediaPath]);
|
|
|
|
|
|
|
|
|
|
|
|
// Replace the clip source with the new project item
|
|
|
|
|
|
var fileName = newFile.displayName;
|
|
|
|
|
|
var rootBin = app.project.rootItem;
|
|
|
|
|
|
var newItem = findProjectItemByName(rootBin, fileName);
|
|
|
|
|
|
|
|
|
|
|
|
if (newItem) {
|
|
|
|
|
|
clip.projectItem = newItem;
|
|
|
|
|
|
matched = true;
|
|
|
|
|
|
result.relinked = 1;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { /* continue searching */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (matched) {
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "Clip relinked on track " + (targetTrack + 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.message = "No matching clip found at specified position";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "Relink error: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* FCP XML Export — exports the active sequence as Final Cut Pro XML
|
|
|
|
|
|
* using Premiere Pro's native export function.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Note: Premiere Pro ExtendScript does not expose a direct
|
|
|
|
|
|
* exportFCPXML() call in all versions. If the native call is
|
|
|
|
|
|
* unavailable, this function falls back to constructing the XML
|
|
|
|
|
|
* from the timeline data, which the panel JS can then use directly.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} exportPath - Absolute path to write the .xml file
|
|
|
|
|
|
* @returns {string} JSON with success flag and message
|
|
|
|
|
|
*/
|
|
|
|
|
|
function exportSequenceAsFCPXML(exportPath) {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: "",
|
|
|
|
|
|
xmlContent: ""
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sequence = app.project.activeSequence;
|
|
|
|
|
|
if (!sequence) {
|
|
|
|
|
|
result.message = "No active sequence";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try Premiere's built-in FCP XML export first
|
|
|
|
|
|
// Some versions expose exportFCPXML on the project object.
|
|
|
|
|
|
var exported = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Method 1: app.project.exportFCPXML (available in some versions)
|
|
|
|
|
|
if (typeof app.project.exportFCPXML === "function") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
app.project.exportFCPXML(sequence, exportPath);
|
|
|
|
|
|
exported = true;
|
|
|
|
|
|
} catch (e) { /* fall through */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Method 2: app.encoder with FCP XML preset
|
|
|
|
|
|
if (!exported && typeof app.encoder !== "undefined" && typeof app.encoder.launchEncoder === "function") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
app.encoder.launchEncoder();
|
|
|
|
|
|
// There is no standard FCP XML AME preset — fall through to manual
|
|
|
|
|
|
} catch (e) { /* fall through */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!exported) {
|
|
|
|
|
|
// Fall back to constructing XML from timeline data
|
|
|
|
|
|
var timelineData = JSON.parse(exportTimelineData());
|
|
|
|
|
|
|
|
|
|
|
|
if (!timelineData.success) {
|
|
|
|
|
|
result.message = "Failed to read timeline data";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var fps = parseFloat(timelineData.frameRate) || 59.94;
|
|
|
|
|
|
var width = timelineData.width || 1920;
|
|
|
|
|
|
var height = timelineData.height || 1080;
|
|
|
|
|
|
|
|
|
|
|
|
var xml = [];
|
|
|
|
|
|
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
|
|
|
|
xml.push('<!DOCTYPE xmeml>');
|
|
|
|
|
|
xml.push('<xmeml version="5">');
|
|
|
|
|
|
xml.push(' <sequence>');
|
|
|
|
|
|
xml.push(' <name>' + escapeXmlStr(timelineData.sequenceName || "Untitled") + '</name>');
|
|
|
|
|
|
xml.push(' <duration>' + getSequenceDuration(timelineData.clips, fps) + '</duration>');
|
|
|
|
|
|
xml.push(' <rate>');
|
|
|
|
|
|
xml.push(' <timebase>' + Math.round(fps) + '</timebase>');
|
|
|
|
|
|
xml.push(' <ntsc>' + (Math.abs(fps - 29.97) < 0.02 || Math.abs(fps - 59.94) < 0.02 ? "TRUE" : "FALSE") + '</ntsc>');
|
|
|
|
|
|
xml.push(' </rate>');
|
|
|
|
|
|
xml.push(' <media>');
|
|
|
|
|
|
xml.push(' <video>');
|
|
|
|
|
|
xml.push(' <format>');
|
|
|
|
|
|
xml.push(' <samplecharacteristics>');
|
|
|
|
|
|
xml.push(' <width>' + width + '</width>');
|
|
|
|
|
|
xml.push(' <height>' + height + '</height>');
|
|
|
|
|
|
xml.push(' </samplecharacteristics>');
|
|
|
|
|
|
xml.push(' </format>');
|
|
|
|
|
|
xml.push(' <track>');
|
|
|
|
|
|
|
|
|
|
|
|
// Group clips by track
|
|
|
|
|
|
var tracks = {};
|
|
|
|
|
|
for (var i = 0; i < timelineData.clips.length; i++) {
|
|
|
|
|
|
var cl = timelineData.clips[i];
|
|
|
|
|
|
if (!tracks[cl.trackIndex]) tracks[cl.trackIndex] = [];
|
|
|
|
|
|
tracks[cl.trackIndex].push(cl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var trackKeys = Object.keys(tracks).sort();
|
|
|
|
|
|
for (var tk = 0; tk < trackKeys.length; tk++) {
|
|
|
|
|
|
var tIdx = trackKeys[tk];
|
|
|
|
|
|
var trackClips = tracks[tIdx];
|
|
|
|
|
|
xml.push(' <clipitem id="clip-item-' + tIdx + '">');
|
|
|
|
|
|
xml.push(' <name>Track ' + (parseInt(tIdx) + 1) + '</name>');
|
|
|
|
|
|
|
|
|
|
|
|
for (var ci = 0; ci < trackClips.length; ci++) {
|
|
|
|
|
|
var clipData = trackClips[ci];
|
|
|
|
|
|
xml.push(' <clipitem id="clip-' + tIdx + '-' + ci + '">');
|
|
|
|
|
|
xml.push(' <name>' + escapeXmlStr(clipData.fileName || "Clip " + (ci + 1)) + '</name>');
|
|
|
|
|
|
xml.push(' <duration>' + (clipData.sourceOutFrames - clipData.sourceInFrames) + '</duration>');
|
|
|
|
|
|
xml.push(' <rate>');
|
|
|
|
|
|
xml.push(' <timebase>' + Math.round(fps) + '</timebase>');
|
|
|
|
|
|
xml.push(' </rate>');
|
|
|
|
|
|
xml.push(' <in>' + clipData.sourceInFrames + '</in>');
|
|
|
|
|
|
xml.push(' <out>' + clipData.sourceOutFrames + '</out>');
|
|
|
|
|
|
xml.push(' <start>' + clipData.timelineInFrames + '</start>');
|
|
|
|
|
|
xml.push(' <end>' + clipData.timelineOutFrames + '</end>');
|
|
|
|
|
|
xml.push(' <file>');
|
|
|
|
|
|
xml.push(' <name>' + escapeXmlStr(clipData.filePath || clipData.fileName || "Unknown") + '</name>');
|
|
|
|
|
|
xml.push(' <pathurl>' + escapeXmlStr(clipData.filePath || "") + '</pathurl>');
|
|
|
|
|
|
xml.push(' </file>');
|
|
|
|
|
|
xml.push(' </clipitem>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
xml.push(' </clipitem>');
|
|
|
|
|
|
xml.push(' </track>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
xml.push(' </video>');
|
|
|
|
|
|
xml.push(' </media>');
|
|
|
|
|
|
xml.push(' </sequence>');
|
|
|
|
|
|
xml.push('</xmeml>');
|
|
|
|
|
|
|
|
|
|
|
|
result.xmlContent = xml.join("\n");
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "FCP XML constructed from timeline data";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Read the exported file
|
|
|
|
|
|
try {
|
|
|
|
|
|
var file = new File(exportPath);
|
|
|
|
|
|
if (file.exists) {
|
|
|
|
|
|
file.open("r");
|
|
|
|
|
|
result.xmlContent = file.read();
|
|
|
|
|
|
file.close();
|
|
|
|
|
|
result.success = true;
|
|
|
|
|
|
result.message = "FCP XML exported via Premiere native function";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.message = "Export file not found at " + exportPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
result.message = "Failed to read exported file: " + e.message;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "FCP XML export error: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Walks every project item and swaps clip media paths matching `oldPath`
|
|
|
|
|
|
* onto `newPath`. Returns the number of clips relinked.
|
|
|
|
|
|
* This is used by the hi-res auto-relink workflow after downloading
|
|
|
|
|
|
* trimmed segments.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} oldPath - Current media path to replace
|
|
|
|
|
|
* @param {string} newPath - New media path to use
|
|
|
|
|
|
* @returns {string} JSON with success, relinked count, and message
|
|
|
|
|
|
*/
|
|
|
|
|
|
function replaceMediaPath(oldPath, newPath) {
|
|
|
|
|
|
var result = {
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
relinked: 0,
|
|
|
|
|
|
message: ""
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!app.project) {
|
|
|
|
|
|
result.message = "No active project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var oldFile = new File(newPath);
|
|
|
|
|
|
if (!oldFile.exists) {
|
|
|
|
|
|
result.message = "New media file not found: " + newPath;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.project.importFiles([newPath]);
|
|
|
|
|
|
|
|
|
|
|
|
var newName = oldFile.displayName;
|
|
|
|
|
|
var rootBin = app.project.rootItem;
|
|
|
|
|
|
var newItem = findProjectItemByName(rootBin, newName);
|
|
|
|
|
|
|
|
|
|
|
|
if (!newItem) {
|
|
|
|
|
|
result.message = "Could not find imported media in project";
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count = 0;
|
|
|
|
|
|
function walkAndReplace(item) {
|
|
|
|
|
|
if (!item || !item.children) return;
|
|
|
|
|
|
for (var i = 0; i < item.children.numItems; i++) {
|
|
|
|
|
|
var child = item.children[i];
|
|
|
|
|
|
if (!child) continue;
|
|
|
|
|
|
if (child.type === ProjectItemType.BIN) {
|
|
|
|
|
|
walkAndReplace(child);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var mediaPath = child.getMediaPath();
|
|
|
|
|
|
if (mediaPath === oldPath) {
|
|
|
|
|
|
child.changeMediaPath(newPath);
|
|
|
|
|
|
count++;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { /* skip inaccessible items */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
walkAndReplace(rootBin);
|
|
|
|
|
|
|
|
|
|
|
|
result.success = count > 0;
|
|
|
|
|
|
result.relinked = count;
|
|
|
|
|
|
result.message = count + " clip(s) relinked";
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
result.message = "replaceMediaPath error: " + error.message;
|
|
|
|
|
|
return JSON.stringify(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// Internal Helpers (Advanced Features)
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Calculates total sequence duration in frames from clip array.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getSequenceDuration(clips, fps) {
|
|
|
|
|
|
var maxEnd = 0;
|
|
|
|
|
|
for (var i = 0; i < clips.length; i++) {
|
|
|
|
|
|
if (clips[i].timelineOutFrames > maxEnd) {
|
|
|
|
|
|
maxEnd = clips[i].timelineOutFrames;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return maxEnd || Math.round(fps * 30); // default to 30 sec
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Escapes XML special characters in a string.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function escapeXmlStr(str) {
|
|
|
|
|
|
if (typeof str !== "string") return "";
|
|
|
|
|
|
return str
|
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
|
.replace(/"/g, """)
|
|
|
|
|
|
.replace(/'/g, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-05-15 21:36:15 -04:00
|
|
|
|
// Helper Functions
|
2026-04-07 22:05:47 -04:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-05-15 21:36:15 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Recursively searches for a project item by display name.
|
|
|
|
|
|
* @param {ProjectItem} bin - The bin/root to search
|
|
|
|
|
|
* @param {string} name - Name to match
|
|
|
|
|
|
* @returns {ProjectItem|null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function findProjectItemByName(bin, name) {
|
|
|
|
|
|
if (!bin || !bin.children) return null;
|
|
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < bin.children.numItems; i++) {
|
|
|
|
|
|
var item = bin.children[i];
|
|
|
|
|
|
if (item.name === name) return item;
|
|
|
|
|
|
if (item.type === ProjectItemType.BIN) {
|
|
|
|
|
|
var found = findProjectItemByName(item, name);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Pads a number with leading zeros.
|
|
|
|
|
|
* @param {number} num - Number to pad
|
|
|
|
|
|
* @param {number} digits - Minimum digit count
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function pad(num, digits) {
|
|
|
|
|
|
var str = "" + Math.floor(num);
|
|
|
|
|
|
while (str.length < digits) str = "0" + str;
|
|
|
|
|
|
return str;
|
2026-04-07 22:05:47 -04:00
|
|
|
|
}
|