/** * Wild Dragon MAM - Premiere Pro ExtendScript * * This file runs in the Premiere Pro host context (not the browser panel). * It is registered via 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]); 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]); 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]); // 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.push(''); xml.push(''); xml.push(' '); xml.push(' ' + escapeXmlStr(timelineData.sequenceName || "Untitled") + ''); xml.push(' ' + getSequenceDuration(timelineData.clips, fps) + ''); xml.push(' '); xml.push(' ' + Math.round(fps) + ''); xml.push(' ' + (Math.abs(fps - 29.97) < 0.02 || Math.abs(fps - 59.94) < 0.02 ? "TRUE" : "FALSE") + ''); xml.push(' '); xml.push(' '); xml.push(' '); xml.push(' '); xml.push(' '); xml.push(''); 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, "'"); } // ============================================================================ // 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; }