UXP v2.1.3: timeline.js — correct Premiere DOM API calls per official docs

This commit is contained in:
Zac Gaetano 2026-05-28 07:48:57 -04:00
parent 046d99f57a
commit a25e4b6071

View file

@ -1,12 +1,18 @@
// timeline.js — v2.1.0
// Reads active Premiere sequence via UXP premierepro API.
// Replaces all CSInterface.evalScript() calls that died in PPro 26.
// Also: FCP XML generation, push to MAM, conform, batch hi-res relink.
// timeline.js — v2.1.3
// Reads active Premiere sequence via the real UXP premierepro DOM API.
//
// Key corrections from official docs (developer.adobe.com/premiere-pro/uxp/ppro-reference/):
// • Most Premiere API calls are SYNCHRONOUS — no await needed
// • Track items via VideoTrack.getTrackItems(TrackItemType.Clip, false) → VideoClipTrackItem[]
// (no getClipCount / getClip — those don't exist)
// • Media file path via ClipProjectItem.cast(projItem).getMediaFilePath() (not getMediaPath)
// • Relink via ClipProjectItem.cast(projItem).changeMediaFilePath(newPath, false)
// • TickTime.seconds is the float-seconds property (use directly, no ticks math needed)
// • project.getRootItem() and getActiveProject() are synchronous
(function () {
const Timeline = {};
// ── premierepro lazy require ────────────────────────────────────
function ppro() {
if (Timeline._ppro) return Timeline._ppro;
try { Timeline._ppro = require('premierepro'); }
@ -16,53 +22,78 @@
// ── Read active sequence ─────────────────────────────────────────
// Returns { sequenceName, frameRate, width, height, clips[] }
// clips: { fileName, filePath, trackIndex, timelineInFrames, timelineOutFrames,
// sourceInFrames, sourceOutFrames }
// clips: { fileName, filePath, trackIndex,
// timelineInSec, timelineOutSec, sourceInSec, sourceOutSec }
Timeline.readActiveSequence = async function () {
const P = ppro();
const project = await P.Project.getActiveProject();
const project = P.Project.getActiveProject(); // sync
if (!project) throw new Error('No active Premiere project');
const seq = await project.getActiveSequence();
if (!seq) throw new Error('No active sequence in the project');
const seq = project.getActiveSequence(); // sync
if (!seq) throw new Error('No active sequence');
const name = await seq.getName().catch(() => 'Sequence 1');
const settings = await seq.getSettings().catch(() => ({}));
const frameRate = settings.videoFrameRate ? (1 / settings.videoFrameRate) : 29.97;
const width = settings.videoFrameWidth || 1920;
const height = settings.videoFrameHeight || 1080;
const name = seq.name || 'Sequence 1';
const settings = seq.getSettings(); // sync
// SequenceSettings.videoFrameRate is a FrameRate object with .seconds property
// .seconds on FrameRate = 1/fps (duration of one frame in seconds)
let frameRate = 29.97;
try {
const fr = settings.videoFrameRate;
if (fr && typeof fr.seconds === 'number' && fr.seconds > 0) {
frameRate = 1 / fr.seconds;
}
} catch (_) {}
const width = settings.videoFrameWidth || 1920;
const height = settings.videoFrameHeight || 1080;
const clips = [];
const trackCount = seq.getVideoTrackCount(); // sync
// Walk video tracks
const clips = [];
let trackCount = 0;
try { trackCount = await seq.getVideoTrackCount(); } catch (_) {}
for (let ti = 0; ti < trackCount; ti++) {
let track;
try { track = await seq.getVideoTrack(ti); } catch (_) { continue; }
let clipCount = 0;
try { clipCount = await track.getClipCount(); } catch (_) {}
for (let ci = 0; ci < clipCount; ci++) {
const track = seq.getVideoTrack(ti); // sync
if (!track) continue;
// getTrackItems(TrackItemType, includeEmptyItems) → VideoClipTrackItem[]
let items = [];
try {
// Constants.TrackItemType.Clip = 1
items = track.getTrackItems(1, false);
} catch (_) { continue; }
if (!items || !items.length) continue;
for (const clip of items) {
try {
const clip = await track.getClip(ci);
const projItem = await clip.getProjectItem().catch(() => null);
const projItem = clip.getProjectItem(); // sync
if (!projItem) continue;
const mediaPath = await projItem.getMediaPath().catch(() => '');
const itemName = await projItem.getName().catch(() => '');
const inPoint = await clip.getInPoint().catch(() => ({ ticks: 0 }));
const outPoint = await clip.getOutPoint().catch(() => ({ ticks: 0 }));
const srcIn = await clip.getSourceInPoint().catch(() => ({ ticks: 0 }));
const srcOut = await clip.getSourceOutPoint().catch(() => ({ ticks: 0 }));
// Premiere ticks = 1/254016000000 s. Convert to frames.
const TICK = 254016000000;
function ticksToFrames(t) { return Math.round((t.ticks / TICK) * frameRate); }
// Cast to ClipProjectItem to get file path
let filePath = '';
try {
const clipItem = P.ClipProjectItem.cast(projItem);
filePath = clipItem.getMediaFilePath() || ''; // sync
} catch (_) {}
const fileName = clip.getName() || path.basename(filePath) || 'clip';
// TickTime.seconds is directly available as a number property
const tlIn = clip.getStartTime().seconds; // timeline start
const tlOut = clip.getEndTime().seconds; // timeline end
const srcIn = clip.getInPoint().seconds; // source in
const srcOut= clip.getOutPoint().seconds; // source out
clips.push({
fileName: itemName || (mediaPath ? mediaPath.split(/[\\/]/).pop() : 'clip'),
filePath: mediaPath || '',
trackIndex: ti,
timelineInFrames: ticksToFrames(inPoint),
timelineOutFrames: ticksToFrames(outPoint),
sourceInFrames: ticksToFrames(srcIn),
sourceOutFrames: ticksToFrames(srcOut),
fileName,
filePath,
trackIndex: ti,
timelineInSec: tlIn,
timelineOutSec: tlOut,
sourceInSec: srcIn,
sourceOutSec: srcOut,
// frame equivalents for FCP XML / MAM clip push
timelineInFrames: Math.round(tlIn * frameRate),
timelineOutFrames: Math.round(tlOut * frameRate),
sourceInFrames: Math.round(srcIn * frameRate),
sourceOutFrames: Math.round(srcOut * frameRate),
});
} catch (_) {}
}
@ -75,11 +106,11 @@
Timeline.refreshSeqBar = async function () {
try {
const P = ppro();
const project = await P.Project.getActiveProject().catch(() => null);
const project = P.Project.getActiveProject();
if (!project) { UI.setHidden('#seq-info-bar', true); return; }
const seq = await project.getActiveSequence().catch(() => null);
const seq = project.getActiveSequence();
if (!seq) { UI.setHidden('#seq-info-bar', true); return; }
const name = await seq.getName().catch(() => '');
const name = seq.name || '';
if (name) {
document.getElementById('seq-info-name').textContent = name;
UI.setHidden('#seq-info-bar', false);
@ -100,15 +131,13 @@
const clips = timelineData.clips || [];
let totalFrames = 0;
clips.forEach(c => { if ((c.timelineOutFrames || 0) > totalFrames) totalFrames = c.timelineOutFrames; });
clips.forEach(c => { if ((c.timelineOutFrames||0) > totalFrames) totalFrames = c.timelineOutFrames; });
if (totalFrames < 1) totalFrames = 100;
const duration = UI.timecodeFromFrames(totalFrames, frameRate);
const frateStr = UI.formatFrameRate(frameRate);
const duration = UI.timecodeFromFrames(totalFrames, frameRate);
const frateStr = UI.formatFrameRate(frameRate);
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE fcpxml>\n';
xml += '<fcpxml version="1.10">\n';
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE fcpxml>\n<fcpxml version="1.10">\n';
xml += ' <resources>\n';
xml += ' <format id="r0" frameDuration="' + frateStr + '" width="' + width + '" height="' + height + '"/>\n';
@ -118,29 +147,25 @@
if (!seen[key]) {
seen[key] = 'r' + rid;
const srcDur = UI.timecodeFromFrames(
Math.max(1, (clip.sourceOutFrames || 100) - (clip.sourceInFrames || 0)), frameRate
Math.max(1, (clip.sourceOutFrames||100) - (clip.sourceInFrames||0)), frameRate
);
xml += ' <asset id="r' + rid + '" name="' + UI.escapeXml(clip.fileName || 'Clip') + '"'
+ ' src="' + UI.escapeXml(clip.filePath || '') + '"'
xml += ' <asset id="r' + rid + '" name="' + UI.escapeXml(clip.fileName||'Clip') + '"'
+ ' src="' + UI.escapeXml(clip.filePath||'') + '"'
+ ' duration="' + srcDur + '" start="0s" format="r0"/>\n';
rid++;
}
});
xml += ' </resources>\n';
xml += ' <library>\n <event name="Conform Export">\n <project name="' + seqName + '">\n';
xml += ' </resources>\n <library>\n <event name="Conform Export">\n <project name="' + seqName + '">\n';
xml += ' <sequence duration="' + duration + '" format="r0">\n <spine>\n';
clips.forEach(clip => {
const resId = seen[clip.filePath || clip.fileName || 'x'] || 'r1';
const off = UI.timecodeFromFrames(clip.timelineInFrames || 0, frameRate);
const dur = UI.timecodeFromFrames(
Math.max(1, (clip.timelineOutFrames || 1) - (clip.timelineInFrames || 0)), frameRate
);
const dur = UI.timecodeFromFrames(Math.max(1, (clip.timelineOutFrames||1) - (clip.timelineInFrames||0)), frameRate);
const srcIn = UI.timecodeFromFrames(clip.sourceInFrames || 0, frameRate);
xml += ' <clip name="' + UI.escapeXml(clip.fileName || 'Clip') + '"'
xml += ' <clip name="' + UI.escapeXml(clip.fileName||'Clip') + '"'
+ ' offset="' + off + '" duration="' + dur + '" start="' + srcIn + '">\n';
xml += ' <asset-clip ref="' + resId + '"/>\n';
xml += ' </clip>\n';
xml += ' <asset-clip ref="' + resId + '"/>\n </clip>\n';
});
xml += ' </spine>\n </sequence>\n </project>\n </event>\n </library>\n</fcpxml>';
@ -151,9 +176,8 @@
Timeline.pushToMAM = async function (seqName, projectId, timelineData) {
const resolved = Library.resolveClipsToAssets(timelineData.clips || []);
const matched = resolved.filter(c => c.asset_id);
if (matched.length === 0) throw new Error('No clips matched MAM assets — import proxies first');
if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first');
// Upsert sequence
const seqs = await API.listSequences(projectId);
let seqId;
const existing = seqs.find(s => s.name === seqName);
@ -178,20 +202,14 @@
source_out_frames: c.sourceOutFrames,
}));
await API.pushClips(seqId, payload);
return { seqId, matched: matched.length, skipped: resolved.length - matched.length };
};
// ── Conform ──────────────────────────────────────────────────────
// opts: { codec, quality, resolution, audio }
// Returns jobId for polling.
Timeline.startConform = async function (projectId, seqName, timelineData, opts) {
// Upsert sequence to get seqId
const { seqId } = await Timeline.pushToMAM(seqName, projectId, timelineData);
const resMap = { '1080p': [1920,1080], '720p': [1280,720], 'uhd': [3840,2160], 'source': [0,0] };
const [w, h] = resMap[opts.resolution] || [1920,1080];
const job = await API.startConform(seqId, {
codec: opts.codec, quality: opts.quality,
width: w || undefined, height: h || undefined,
@ -202,11 +220,10 @@
};
Timeline.pollConform = function (jobId, onProgress, onDone) {
let timer = setInterval(async () => {
const timer = setInterval(async () => {
try {
const job = await API.getJob(jobId);
const pct = job.progress || 0;
onProgress(pct, job.status);
onProgress(job.progress || 0, job.status);
if (job.status === 'completed' || job.status === 'failed') {
clearInterval(timer);
onDone(job);
@ -217,11 +234,10 @@
};
// ── Batch Hi-Res Relink ──────────────────────────────────────────
// Takes selectedClips (array of resolved clip objects with asset_id),
// downloads hi-res for each, relinks in Premiere.
// selectedClips: resolved clip objects with asset_id + filePath
Timeline.batchRelink = async function (selectedClips) {
const P = ppro();
const project = await P.Project.getActiveProject();
const project = P.Project.getActiveProject(); // sync
if (!project) throw new Error('No active Premiere project');
const results = { succeeded: 0, failed: 0, errors: [] };
@ -230,19 +246,18 @@
if (!clip.asset_id) continue;
try {
UI.showProgress('Fetching hi-res for ' + clip.fileName + '…', 20);
const info = await API.getHiresInfo(clip.asset_id);
const info = await API.getHiresInfo(clip.asset_id);
const safeName = UI.sanitizeFilename(info.filename || clip.fileName + '.' + (info.ext || 'mxf'));
const dest = await Import._tempPath(safeName);
const dest = await Import._tempPath(safeName);
UI.showProgress('Downloading ' + safeName + '…', 30);
const r = await API.requestFollow(info.url, {});
if (!r.ok) throw new Error('Download HTTP ' + r.status);
await Import._streamToFile(r, dest, ({ received, total }) => {
const pct = total ? 30 + (received / total) * 50 : 30;
UI.showProgress('Downloading ' + UI.formatBytes(received) + '…', pct);
UI.showProgress(UI.formatBytes(received) + '…', pct);
});
// Relink: walk project items, find by old path, changeMediaPath
UI.showProgress('Relinking ' + clip.fileName + '…', 85);
await Timeline._relinkInProject(project, clip.filePath, dest);
results.succeeded++;
@ -254,31 +269,58 @@
return results;
};
// Walk all project items and changeMediaPath for items matching oldPath.
// Walk project items and changeMediaFilePath for items matching oldPath.
// Uses ClipProjectItem.cast() + changeMediaFilePath() per official docs.
Timeline._relinkInProject = async function (project, oldPath, newPath) {
const root = await project.getRootItem();
let count = 0;
async function walk(item) {
const P = ppro();
const root = project.getRootItem(); // sync (FolderItem)
let count = 0;
function walkSync(folderItem) {
// FolderItem has no getItems() in docs — use project.getSequences() / rootItem children
// Actually FolderItem IS a ProjectItem; children accessible via getItems on the project
// Try both known APIs gracefully
let children = [];
try {
const children = await item.getItems();
for (const child of children) {
// Some UXP builds expose children on FolderItem directly
if (typeof folderItem.getItems === 'function') {
children = folderItem.getItems() || [];
}
} catch (_) {}
for (const item of children) {
try {
const clipItem = P.ClipProjectItem.cast(item);
const mp = clipItem.getMediaFilePath();
if (mp && mp === oldPath) {
clipItem.changeMediaFilePath(newPath, false);
count++;
}
} catch (_) {
// cast fails for FolderItems — try to recurse
try { walkSync(item); } catch (__) {}
}
}
}
walkSync(root);
// If walkSync found nothing (getItems not on FolderItem in this build),
// fall back to findItemsMatchingMediaPath on root
if (count === 0) {
try {
const matches = P.ClipProjectItem.cast(root).findItemsMatchingMediaPath(oldPath, true);
for (const item of (matches || [])) {
try {
const mp = await child.getMediaPath().catch(() => null);
if (mp && mp === oldPath) {
await child.changeMediaPath(newPath, true);
count++;
}
} catch (_) {}
// Recurse into bins
try {
const sub = await child.getItems();
if (sub && sub.length) await walk(child);
const clipItem = P.ClipProjectItem.cast(item);
clipItem.changeMediaFilePath(newPath, false);
count++;
} catch (_) {}
}
} catch (_) {}
}
await walk(root);
if (count === 0) throw new Error('No matching clips found for ' + (oldPath || 'unknown path'));
if (count === 0) throw new Error('No matching clips found for path: ' + (oldPath || 'unknown'));
return count;
};