2026-05-28 07:48:57 -04:00
|
|
|
// 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
|
2026-05-28 01:00:19 -04:00
|
|
|
|
|
|
|
|
(function () {
|
|
|
|
|
const Timeline = {};
|
|
|
|
|
|
|
|
|
|
function ppro() {
|
|
|
|
|
if (Timeline._ppro) return Timeline._ppro;
|
|
|
|
|
try { Timeline._ppro = require('premierepro'); }
|
|
|
|
|
catch (e) { throw new Error('UXP premierepro unavailable: ' + e.message); }
|
|
|
|
|
return Timeline._ppro;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Read active sequence ─────────────────────────────────────────
|
|
|
|
|
// Returns { sequenceName, frameRate, width, height, clips[] }
|
2026-05-28 07:48:57 -04:00
|
|
|
// clips: { fileName, filePath, trackIndex,
|
|
|
|
|
// timelineInSec, timelineOutSec, sourceInSec, sourceOutSec }
|
2026-05-28 01:00:19 -04:00
|
|
|
Timeline.readActiveSequence = async function () {
|
|
|
|
|
const P = ppro();
|
2026-05-28 07:48:57 -04:00
|
|
|
const project = P.Project.getActiveProject(); // sync
|
2026-05-28 01:00:19 -04:00
|
|
|
if (!project) throw new Error('No active Premiere project');
|
|
|
|
|
|
2026-05-28 07:48:57 -04:00
|
|
|
const seq = project.getActiveSequence(); // sync
|
|
|
|
|
if (!seq) throw new Error('No active sequence');
|
2026-05-28 01:00:19 -04:00
|
|
|
|
2026-05-28 07:48:57 -04:00
|
|
|
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
|
2026-05-28 01:00:19 -04:00
|
|
|
|
|
|
|
|
for (let ti = 0; ti < trackCount; ti++) {
|
2026-05-28 07:48:57 -04:00
|
|
|
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) {
|
2026-05-28 01:00:19 -04:00
|
|
|
try {
|
2026-05-28 07:48:57 -04:00
|
|
|
const projItem = clip.getProjectItem(); // sync
|
2026-05-28 01:00:19 -04:00
|
|
|
if (!projItem) continue;
|
2026-05-28 07:48:57 -04:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-05-28 01:00:19 -04:00
|
|
|
clips.push({
|
2026-05-28 07:48:57 -04:00
|
|
|
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),
|
2026-05-28 01:00:19 -04:00
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { sequenceName: name, frameRate, width, height, clips };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Active sequence info bar ─────────────────────────────────────
|
|
|
|
|
Timeline.refreshSeqBar = async function () {
|
|
|
|
|
try {
|
|
|
|
|
const P = ppro();
|
2026-05-28 07:48:57 -04:00
|
|
|
const project = P.Project.getActiveProject();
|
2026-05-28 01:00:19 -04:00
|
|
|
if (!project) { UI.setHidden('#seq-info-bar', true); return; }
|
2026-05-28 07:48:57 -04:00
|
|
|
const seq = project.getActiveSequence();
|
2026-05-28 01:00:19 -04:00
|
|
|
if (!seq) { UI.setHidden('#seq-info-bar', true); return; }
|
2026-05-28 07:48:57 -04:00
|
|
|
const name = seq.name || '';
|
2026-05-28 01:00:19 -04:00
|
|
|
if (name) {
|
|
|
|
|
document.getElementById('seq-info-name').textContent = name;
|
|
|
|
|
UI.setHidden('#seq-info-bar', false);
|
|
|
|
|
} else {
|
|
|
|
|
UI.setHidden('#seq-info-bar', true);
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
UI.setHidden('#seq-info-bar', true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── FCP XML generation ───────────────────────────────────────────
|
|
|
|
|
Timeline.generateFcpXml = function (timelineData) {
|
|
|
|
|
const seqName = UI.escapeXml(timelineData.sequenceName || 'Sequence 1');
|
|
|
|
|
const frameRate = timelineData.frameRate || 29.97;
|
|
|
|
|
const width = timelineData.width || 1920;
|
|
|
|
|
const height = timelineData.height || 1080;
|
|
|
|
|
const clips = timelineData.clips || [];
|
|
|
|
|
|
|
|
|
|
let totalFrames = 0;
|
2026-05-28 07:48:57 -04:00
|
|
|
clips.forEach(c => { if ((c.timelineOutFrames||0) > totalFrames) totalFrames = c.timelineOutFrames; });
|
2026-05-28 01:00:19 -04:00
|
|
|
if (totalFrames < 1) totalFrames = 100;
|
|
|
|
|
|
2026-05-28 07:48:57 -04:00
|
|
|
const duration = UI.timecodeFromFrames(totalFrames, frameRate);
|
|
|
|
|
const frateStr = UI.formatFrameRate(frameRate);
|
2026-05-28 01:00:19 -04:00
|
|
|
|
2026-05-28 07:48:57 -04:00
|
|
|
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE fcpxml>\n<fcpxml version="1.10">\n';
|
2026-05-28 01:00:19 -04:00
|
|
|
xml += ' <resources>\n';
|
|
|
|
|
xml += ' <format id="r0" frameDuration="' + frateStr + '" width="' + width + '" height="' + height + '"/>\n';
|
|
|
|
|
|
|
|
|
|
const seen = {}; let rid = 1;
|
|
|
|
|
clips.forEach(clip => {
|
|
|
|
|
const key = clip.filePath || clip.fileName || 'c' + rid;
|
|
|
|
|
if (!seen[key]) {
|
|
|
|
|
seen[key] = 'r' + rid;
|
|
|
|
|
const srcDur = UI.timecodeFromFrames(
|
2026-05-28 07:48:57 -04:00
|
|
|
Math.max(1, (clip.sourceOutFrames||100) - (clip.sourceInFrames||0)), frameRate
|
2026-05-28 01:00:19 -04:00
|
|
|
);
|
2026-05-28 07:48:57 -04:00
|
|
|
xml += ' <asset id="r' + rid + '" name="' + UI.escapeXml(clip.fileName||'Clip') + '"'
|
|
|
|
|
+ ' src="' + UI.escapeXml(clip.filePath||'') + '"'
|
2026-05-28 01:00:19 -04:00
|
|
|
+ ' duration="' + srcDur + '" start="0s" format="r0"/>\n';
|
|
|
|
|
rid++;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-28 07:48:57 -04:00
|
|
|
xml += ' </resources>\n <library>\n <event name="Conform Export">\n <project name="' + seqName + '">\n';
|
2026-05-28 01:00:19 -04:00
|
|
|
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);
|
2026-05-28 07:48:57 -04:00
|
|
|
const dur = UI.timecodeFromFrames(Math.max(1, (clip.timelineOutFrames||1) - (clip.timelineInFrames||0)), frameRate);
|
2026-05-28 01:00:19 -04:00
|
|
|
const srcIn = UI.timecodeFromFrames(clip.sourceInFrames || 0, frameRate);
|
2026-05-28 07:48:57 -04:00
|
|
|
xml += ' <clip name="' + UI.escapeXml(clip.fileName||'Clip') + '"'
|
2026-05-28 01:00:19 -04:00
|
|
|
+ ' offset="' + off + '" duration="' + dur + '" start="' + srcIn + '">\n';
|
2026-05-28 07:48:57 -04:00
|
|
|
xml += ' <asset-clip ref="' + resId + '"/>\n </clip>\n';
|
2026-05-28 01:00:19 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
xml += ' </spine>\n </sequence>\n </project>\n </event>\n </library>\n</fcpxml>';
|
|
|
|
|
return xml;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Push Timeline to MAM ─────────────────────────────────────────
|
|
|
|
|
Timeline.pushToMAM = async function (seqName, projectId, timelineData) {
|
|
|
|
|
const resolved = Library.resolveClipsToAssets(timelineData.clips || []);
|
|
|
|
|
const matched = resolved.filter(c => c.asset_id);
|
2026-05-28 07:48:57 -04:00
|
|
|
if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first');
|
2026-05-28 01:00:19 -04:00
|
|
|
|
|
|
|
|
const seqs = await API.listSequences(projectId);
|
|
|
|
|
let seqId;
|
|
|
|
|
const existing = seqs.find(s => s.name === seqName);
|
|
|
|
|
if (existing) {
|
|
|
|
|
await API.updateSequence(existing.id, {
|
|
|
|
|
frame_rate: timelineData.frameRate, width: timelineData.width, height: timelineData.height,
|
|
|
|
|
});
|
|
|
|
|
seqId = existing.id;
|
|
|
|
|
} else {
|
|
|
|
|
const created = await API.createSequence(
|
|
|
|
|
projectId, seqName, timelineData.frameRate, timelineData.width, timelineData.height
|
|
|
|
|
);
|
|
|
|
|
seqId = created.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = matched.map(c => ({
|
|
|
|
|
asset_id: c.asset_id,
|
|
|
|
|
track: c.trackIndex,
|
|
|
|
|
timeline_in_frames: c.timelineInFrames,
|
|
|
|
|
timeline_out_frames: c.timelineOutFrames,
|
|
|
|
|
source_in_frames: c.sourceInFrames,
|
|
|
|
|
source_out_frames: c.sourceOutFrames,
|
|
|
|
|
}));
|
|
|
|
|
await API.pushClips(seqId, payload);
|
|
|
|
|
return { seqId, matched: matched.length, skipped: resolved.length - matched.length };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Conform ──────────────────────────────────────────────────────
|
|
|
|
|
Timeline.startConform = async function (projectId, seqName, timelineData, opts) {
|
|
|
|
|
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,
|
|
|
|
|
audio: opts.audio,
|
|
|
|
|
fcp_xml: Timeline.generateFcpXml(timelineData),
|
|
|
|
|
});
|
|
|
|
|
return job.jobId || job.id;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Timeline.pollConform = function (jobId, onProgress, onDone) {
|
2026-05-28 07:48:57 -04:00
|
|
|
const timer = setInterval(async () => {
|
2026-05-28 01:00:19 -04:00
|
|
|
try {
|
|
|
|
|
const job = await API.getJob(jobId);
|
2026-05-28 07:48:57 -04:00
|
|
|
onProgress(job.progress || 0, job.status);
|
2026-05-28 01:00:19 -04:00
|
|
|
if (job.status === 'completed' || job.status === 'failed') {
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
onDone(job);
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}, 2000);
|
|
|
|
|
return timer;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Batch Hi-Res Relink ──────────────────────────────────────────
|
2026-05-28 07:48:57 -04:00
|
|
|
// selectedClips: resolved clip objects with asset_id + filePath
|
2026-05-28 01:00:19 -04:00
|
|
|
Timeline.batchRelink = async function (selectedClips) {
|
|
|
|
|
const P = ppro();
|
2026-05-28 07:48:57 -04:00
|
|
|
const project = P.Project.getActiveProject(); // sync
|
2026-05-28 01:00:19 -04:00
|
|
|
if (!project) throw new Error('No active Premiere project');
|
|
|
|
|
|
|
|
|
|
const results = { succeeded: 0, failed: 0, errors: [] };
|
|
|
|
|
|
|
|
|
|
for (const clip of selectedClips) {
|
|
|
|
|
if (!clip.asset_id) continue;
|
|
|
|
|
try {
|
|
|
|
|
UI.showProgress('Fetching hi-res for ' + clip.fileName + '…', 20);
|
2026-05-28 07:48:57 -04:00
|
|
|
const info = await API.getHiresInfo(clip.asset_id);
|
2026-05-28 01:00:19 -04:00
|
|
|
const safeName = UI.sanitizeFilename(info.filename || clip.fileName + '.' + (info.ext || 'mxf'));
|
2026-05-28 07:48:57 -04:00
|
|
|
const dest = await Import._tempPath(safeName);
|
2026-05-28 01:00:19 -04:00
|
|
|
|
|
|
|
|
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;
|
2026-05-28 07:48:57 -04:00
|
|
|
UI.showProgress(UI.formatBytes(received) + '…', pct);
|
2026-05-28 01:00:19 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
UI.showProgress('Relinking ' + clip.fileName + '…', 85);
|
|
|
|
|
await Timeline._relinkInProject(project, clip.filePath, dest);
|
|
|
|
|
results.succeeded++;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
results.failed++;
|
|
|
|
|
results.errors.push(clip.fileName + ': ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-28 07:48:57 -04:00
|
|
|
// Walk project items and changeMediaFilePath for items matching oldPath.
|
|
|
|
|
// Uses ClipProjectItem.cast() + changeMediaFilePath() per official docs.
|
2026-05-28 01:00:19 -04:00
|
|
|
Timeline._relinkInProject = async function (project, oldPath, newPath) {
|
2026-05-28 07:48:57 -04:00
|
|
|
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 = [];
|
2026-05-28 01:00:19 -04:00
|
|
|
try {
|
2026-05-28 07:48:57 -04:00
|
|
|
// 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 || [])) {
|
2026-05-28 01:00:19 -04:00
|
|
|
try {
|
2026-05-28 07:48:57 -04:00
|
|
|
const clipItem = P.ClipProjectItem.cast(item);
|
|
|
|
|
clipItem.changeMediaFilePath(newPath, false);
|
|
|
|
|
count++;
|
2026-05-28 01:00:19 -04:00
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
2026-05-28 07:48:57 -04:00
|
|
|
|
|
|
|
|
if (count === 0) throw new Error('No matching clips found for path: ' + (oldPath || 'unknown'));
|
2026-05-28 01:00:19 -04:00
|
|
|
return count;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.Timeline = Timeline;
|
|
|
|
|
})();
|