UXP v2.1.0: timeline.js — new module: sequence read, FCP XML, export, conform, batch relink via UXP premierepro API
This commit is contained in:
parent
60d0b09c63
commit
066718c968
1 changed files with 286 additions and 0 deletions
286
services/premiere-plugin-uxp/src/timeline.js
Normal file
286
services/premiere-plugin-uxp/src/timeline.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// 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.
|
||||
|
||||
(function () {
|
||||
const Timeline = {};
|
||||
|
||||
// ── premierepro lazy require ────────────────────────────────────
|
||||
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[] }
|
||||
// clips: { fileName, filePath, trackIndex, timelineInFrames, timelineOutFrames,
|
||||
// sourceInFrames, sourceOutFrames }
|
||||
Timeline.readActiveSequence = async function () {
|
||||
const P = ppro();
|
||||
const project = await P.Project.getActiveProject();
|
||||
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 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;
|
||||
|
||||
// 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++) {
|
||||
try {
|
||||
const clip = await track.getClip(ci);
|
||||
const projItem = await clip.getProjectItem().catch(() => null);
|
||||
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); }
|
||||
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),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return { sequenceName: name, frameRate, width, height, clips };
|
||||
};
|
||||
|
||||
// ── Active sequence info bar ─────────────────────────────────────
|
||||
Timeline.refreshSeqBar = async function () {
|
||||
try {
|
||||
const P = ppro();
|
||||
const project = await P.Project.getActiveProject().catch(() => null);
|
||||
if (!project) { UI.setHidden('#seq-info-bar', true); return; }
|
||||
const seq = await project.getActiveSequence().catch(() => null);
|
||||
if (!seq) { UI.setHidden('#seq-info-bar', true); return; }
|
||||
const name = await seq.getName().catch(() => '');
|
||||
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;
|
||||
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);
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<!DOCTYPE fcpxml>\n';
|
||||
xml += '<fcpxml version="1.10">\n';
|
||||
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(
|
||||
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 || '') + '"'
|
||||
+ ' duration="' + srcDur + '" start="0s" format="r0"/>\n';
|
||||
rid++;
|
||||
}
|
||||
});
|
||||
xml += ' </resources>\n';
|
||||
xml += ' <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 srcIn = UI.timecodeFromFrames(clip.sourceInFrames || 0, frameRate);
|
||||
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 += ' </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);
|
||||
if (matched.length === 0) 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);
|
||||
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 ──────────────────────────────────────────────────────
|
||||
// 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,
|
||||
audio: opts.audio,
|
||||
fcp_xml: Timeline.generateFcpXml(timelineData),
|
||||
});
|
||||
return job.jobId || job.id;
|
||||
};
|
||||
|
||||
Timeline.pollConform = function (jobId, onProgress, onDone) {
|
||||
let timer = setInterval(async () => {
|
||||
try {
|
||||
const job = await API.getJob(jobId);
|
||||
const pct = job.progress || 0;
|
||||
onProgress(pct, job.status);
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
clearInterval(timer);
|
||||
onDone(job);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 2000);
|
||||
return timer;
|
||||
};
|
||||
|
||||
// ── Batch Hi-Res Relink ──────────────────────────────────────────
|
||||
// Takes selectedClips (array of resolved clip objects with asset_id),
|
||||
// downloads hi-res for each, relinks in Premiere.
|
||||
Timeline.batchRelink = async function (selectedClips) {
|
||||
const P = ppro();
|
||||
const project = await P.Project.getActiveProject();
|
||||
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);
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Relink: walk project items, find by old path, changeMediaPath
|
||||
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;
|
||||
};
|
||||
|
||||
// Walk all project items and changeMediaPath for items matching oldPath.
|
||||
Timeline._relinkInProject = async function (project, oldPath, newPath) {
|
||||
const root = await project.getRootItem();
|
||||
let count = 0;
|
||||
async function walk(item) {
|
||||
try {
|
||||
const children = await item.getItems();
|
||||
for (const child of children) {
|
||||
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);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
await walk(root);
|
||||
if (count === 0) throw new Error('No matching clips found for ' + (oldPath || 'unknown path'));
|
||||
return count;
|
||||
};
|
||||
|
||||
window.Timeline = Timeline;
|
||||
})();
|
||||
Loading…
Reference in a new issue