dragonflight/services/premiere-plugin/jsx/premiere.jsx
Claude e3afe38697 fix(premiere-plugin): suppress importFiles UI prompts + 60s timeout guard
app.project.importFiles() can deadlock if a hidden Premiere modal appears (off-screen, behind window, etc) — the evalScript callback never fires and the panel spinner hangs forever.

Two changes:

1) Pass suppressUI=true to all five importFiles call sites (main.js inline IIFE + 4 in premiere.jsx). Premiere proceeds even if it would have prompted (audio sample rate, project link, scale-to-frame, etc).

2) Wrap importFileToPremiereProject in a 60s timeout race so even if importFiles does block, the panel surfaces a real error instead of leaving the spinner stuck.

Bumps to v1.2.2.
2026-05-28 03:19:44 +00:00

923 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Wild Dragon MAM - Premiere Pro ExtendScript
*
* 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().
*
* ExtendScript is ES3-level JavaScript — no arrow functions, no const/let,
* no template literals, no destructuring.
*/
// ============================================================================
// Core Import Functions
// ============================================================================
/**
* Imports a media file into the active Premiere Pro project.
* @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], true);
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);
}
}
/**
* Gets the active sequence in the project.
* @returns {string} JSON string with sequence name and ID, or nulls
*/
function getActiveSequence() {
var result = {
sequenceName: null,
sequenceID: null
};
try {
if (!app.project) return JSON.stringify(result);
var activeSequence = app.project.activeSequence;
if (activeSequence) {
result.sequenceName = activeSequence.name;
result.sequenceID = activeSequence.sequenceID;
}
return JSON.stringify(result);
} catch (error) {
return JSON.stringify(result);
}
}
/**
* Inserts a clip into the active sequence at the playhead position.
* @param {string} filePath - Full path to the media file
* @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], true);
var file = new File(filePath);
var fileName = file.displayName;
var rootBin = app.project.rootItem;
var projectItem = findProjectItemByName(rootBin, fileName);
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];
var playheadTime = sequence.getPlayerPosition();
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);
}
}
/**
* Gets the current project file path.
* @returns {string} JSON string with project path and name
*/
function getProjectPath() {
var result = {
projectPath: null,
projectName: null
};
try {
if (!app.project) return JSON.stringify(result);
result.projectPath = app.project.path;
result.projectName = app.project.name;
return JSON.stringify(result);
} catch (error) {
return JSON.stringify(result);
}
}
/**
* Gets information about all video tracks in the active sequence.
* @returns {string} JSON string with an array of track objects
*/
function getSequenceTracks() {
var result = { tracks: [] };
try {
if (!app.project) return JSON.stringify(result);
var sequence = app.project.activeSequence;
if (!sequence) return JSON.stringify(result);
for (var i = 0; i < sequence.videoTracks.numTracks; i++) {
var track = sequence.videoTracks[i];
result.tracks.push({
index: i + 1,
name: track.name || ("V" + (i + 1)),
type: "video"
});
}
return JSON.stringify(result);
} catch (error) {
return JSON.stringify(result);
}
}
/**
* Gets the current playhead position in the active sequence.
* @returns {string} JSON string with timeInSeconds and SMPTE timeCode
*/
function getPlayheadPosition() {
var result = {
timeInSeconds: 0,
timeCode: ""
};
try {
if (!app.project) return JSON.stringify(result);
var sequence = app.project.activeSequence;
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);
result.timeCode = pad(hours, 2) + ":" + pad(minutes, 2) + ":" +
pad(seconds, 2) + ":" + pad(frames, 2);
return JSON.stringify(result);
} catch (error) {
return JSON.stringify(result);
}
}
/**
* Gets basic project information.
* @returns {string} JSON string with project name, path, and sequence count
*/
function getProjectInfo() {
var result = {
projectName: "",
projectPath: "",
sequenceCount: 0
};
try {
if (!app.project) return JSON.stringify(result);
result.projectName = app.project.name;
result.projectPath = app.project.path;
var rootBin = app.project.rootItem;
if (rootBin && rootBin.children) {
for (var i = 0; i < rootBin.children.numItems; i++) {
if (rootBin.children[i].type === ProjectItemType.SEQUENCE) {
result.sequenceCount++;
}
}
}
return JSON.stringify(result);
} catch (error) {
return JSON.stringify(result);
}
}
/**
* 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
*/
function exportSequence(outputPath, presetPath) {
var result = {
success: false,
message: "",
jobId: null
};
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);
}
// Ensure Adobe Media Encoder is running before queuing
app.encoder.launchEncoder();
// 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
);
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";
}
return JSON.stringify(result);
} catch (error) {
result.message = "Export error: " + error.message;
return JSON.stringify(result);
}
}
// ============================================================================
// 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 });
}
}
}
// ============================================================================
// 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], true);
// 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], true);
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* 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;
}