The redesigned UXP panel (left icon rail, compact list-view toggle, hover tooltips, single Export menu) was committed only to redesign/panel-icon-rail and never merged, so main + the website kept serving the old blocky-button build under the same version number (2.2.2). That branch had diverged off an old main and is missing recent worker/HLS/NVENC/import work, so it can't be merged wholesale — cherry-pick just the plugin instead. - services/premiere-plugin-uxp: replace source with the redesigned panel (adds src/tooltip.js; reworks index.html + styles.css + src/*). Verified byte-identical to the build installed on BMG-PC-Edit. - web-ui/public/downloads/dragonflight-mam-2.2.2.ccx: swap the served artifact to the redesigned 34708-byte build (download link unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
17 KiB
JavaScript
379 lines
17 KiB
JavaScript
// timeline.js — v2.1.6
|
|
// premierepro API: docs say sync, runtime returns Promises. Await everything.
|
|
|
|
(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;
|
|
}
|
|
|
|
// Safe helper — returns the numeric seconds value from a Time object,
|
|
// or 0 if the object is null/undefined/malformed.
|
|
function _secs(t) {
|
|
if (!t) return 0;
|
|
if (typeof t.seconds === 'number') return t.seconds;
|
|
return 0;
|
|
}
|
|
|
|
// ── Read active sequence ─────────────────────────────────────────
|
|
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 — open a sequence in the timeline first');
|
|
|
|
const name = seq.name || (seq.getName ? await seq.getName() : '') || 'Sequence 1';
|
|
const settings = await seq.getSettings();
|
|
let frameRate = 29.97;
|
|
try {
|
|
const fr = settings && settings.videoFrameRate;
|
|
if (fr && typeof fr.seconds === 'number' && fr.seconds > 0) frameRate = 1 / fr.seconds;
|
|
} catch (_) {}
|
|
const width = (settings && settings.videoFrameWidth) || 1920;
|
|
const height = (settings && settings.videoFrameHeight) || 1080;
|
|
|
|
const clips = [];
|
|
const trackCount = await seq.getVideoTrackCount();
|
|
|
|
for (let ti = 0; ti < trackCount; ti++) {
|
|
const track = await seq.getVideoTrack(ti);
|
|
if (!track) continue;
|
|
let items = [];
|
|
try { items = await track.getTrackItems(1, false); } catch (_) { continue; }
|
|
if (!items || !items.length) continue;
|
|
|
|
for (const clip of items) {
|
|
try {
|
|
const projItem = await clip.getProjectItem();
|
|
if (!projItem) continue;
|
|
|
|
let filePath = '';
|
|
try {
|
|
const clipItem = await P.ClipProjectItem.cast(projItem);
|
|
filePath = await clipItem.getMediaFilePath() || '';
|
|
} catch (_) {}
|
|
|
|
const clipName = await clip.getName().catch(() => '');
|
|
const fileName = clipName || (filePath ? path.basename(filePath) : 'clip');
|
|
|
|
// Null-safe time access — non-clip items can return null Time objects
|
|
const startT = await clip.getStartTime().catch(() => null);
|
|
const endT = await clip.getEndTime().catch(() => null);
|
|
const inT = await clip.getInPoint().catch(() => null);
|
|
const outT = await clip.getOutPoint().catch(() => null);
|
|
|
|
const tlIn = _secs(startT);
|
|
const tlOut = _secs(endT);
|
|
const srcIn = _secs(inT);
|
|
const srcOut = _secs(outT);
|
|
|
|
// Skip filler/gap items where all times are zero
|
|
if (tlIn === 0 && tlOut === 0 && srcIn === 0 && srcOut === 0 && !filePath) continue;
|
|
|
|
clips.push({
|
|
fileName, filePath, trackIndex: ti,
|
|
timelineInSec: tlIn, timelineOutSec: tlOut,
|
|
sourceInSec: srcIn, sourceOutSec: srcOut,
|
|
timelineInFrames: Math.round(tlIn * frameRate),
|
|
timelineOutFrames: Math.round(tlOut * frameRate),
|
|
sourceInFrames: Math.round(srcIn * frameRate),
|
|
sourceOutFrames: Math.round(srcOut * frameRate),
|
|
});
|
|
} catch (clipErr) {
|
|
console.warn('[df] readActiveSequence: skipped clip on track', ti, '—', clipErr && clipErr.message);
|
|
}
|
|
}
|
|
}
|
|
return { sequenceName: name, frameRate, width, height, clips };
|
|
};
|
|
|
|
// ── Sequence info bar ────────────────────────────────────────────
|
|
Timeline.refreshSeqBar = async function () {
|
|
try {
|
|
const P = ppro();
|
|
const project = await P.Project.getActiveProject();
|
|
if (!project) { UI.setHidden('#seq-info-bar', true); return; }
|
|
const seq = await project.getActiveSequence();
|
|
if (!seq) { UI.setHidden('#seq-info-bar', true); return; }
|
|
const name = seq.name || '';
|
|
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 (xmeml / FCP 7) ──────────────────────────────────────
|
|
// The worker's parseFcpXml expects the legacy xmeml schema
|
|
// (root <xmeml><sequence>), NOT the modern fcpxml schema
|
|
// (root <fcpxml><resources><library>...). We previously emitted
|
|
// fcpxml and every conform job failed with "Invalid FCP XML: no
|
|
// sequence element". Now we emit xmeml v5 with:
|
|
// xmeml/sequence/{name,duration,rate,media/video/{format,track*}}
|
|
// track[@currentExplodedTrackIndex] / clipitem / file{name,pathurl}
|
|
// clipitem in/out are SOURCE frames; start/end are TIMELINE frames.
|
|
Timeline.generateFcpXml = function (td) {
|
|
const seqName = UI.escapeXml(td.sequenceName || 'Sequence 1');
|
|
const fps = Math.round(td.frameRate || 29.97);
|
|
const w = td.width || 1920;
|
|
const h = td.height || 1080;
|
|
const clips = td.clips || [];
|
|
|
|
let totalF = 0;
|
|
clips.forEach(c => { if ((c.timelineOutFrames || 0) > totalF) totalF = c.timelineOutFrames; });
|
|
if (totalF < 1) totalF = 100;
|
|
|
|
// Group clips by their video track so each <track> contains its
|
|
// own clipitems in timeline order. The worker iterates tracks and
|
|
// collects clipitems off each.
|
|
const byTrack = {};
|
|
clips.forEach(c => {
|
|
const ti = Number(c.trackIndex || 0);
|
|
(byTrack[ti] = byTrack[ti] || []).push(c);
|
|
});
|
|
|
|
const out = [];
|
|
out.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
out.push('<!DOCTYPE xmeml>');
|
|
out.push('<xmeml version="5">');
|
|
out.push(' <sequence>');
|
|
out.push(' <name>' + seqName + '</name>');
|
|
out.push(' <duration>' + totalF + '</duration>');
|
|
out.push(' <rate><timebase>' + fps + '</timebase><ntsc>FALSE</ntsc></rate>');
|
|
out.push(' <media>');
|
|
out.push(' <video>');
|
|
out.push(' <format>');
|
|
out.push(' <samplecharacteristics>');
|
|
out.push(' <width>' + w + '</width>');
|
|
out.push(' <height>' + h + '</height>');
|
|
out.push(' </samplecharacteristics>');
|
|
out.push(' </format>');
|
|
|
|
let fileId = 1;
|
|
Object.keys(byTrack)
|
|
.sort((a, b) => Number(a) - Number(b))
|
|
.forEach((ti) => {
|
|
out.push(' <track currentExplodedTrackIndex="' + ti + '">');
|
|
byTrack[ti].forEach((c, idx) => {
|
|
const name = UI.escapeXml(c.fileName || 'Clip');
|
|
// file:// URI normalises path separators and gives the worker
|
|
// a parseable pathurl in case it ever resolves locally.
|
|
const rawPath = c.filePath || '';
|
|
const pathUri = rawPath
|
|
? 'file://localhost/' + UI.escapeXml(String(rawPath).replace(/\\/g, '/').replace(/^\/+/, ''))
|
|
: '';
|
|
const dur = Math.max(1, (c.sourceOutFrames || 0) - (c.sourceInFrames || 0));
|
|
out.push(' <clipitem id="clipitem-' + ti + '-' + idx + '">');
|
|
out.push(' <name>' + name + '</name>');
|
|
out.push(' <duration>' + dur + '</duration>');
|
|
out.push(' <rate><timebase>' + fps + '</timebase></rate>');
|
|
out.push(' <in>' + (c.sourceInFrames || 0) + '</in>');
|
|
out.push(' <out>' + (c.sourceOutFrames || 0) + '</out>');
|
|
out.push(' <start>' + (c.timelineInFrames || 0) + '</start>');
|
|
out.push(' <end>' + (c.timelineOutFrames || 0) + '</end>');
|
|
out.push(' <file id="file-' + fileId + '">');
|
|
out.push(' <name>' + name + '</name>');
|
|
if (pathUri) out.push(' <pathurl>' + pathUri + '</pathurl>');
|
|
out.push(' </file>');
|
|
out.push(' </clipitem>');
|
|
fileId++;
|
|
});
|
|
out.push(' </track>');
|
|
});
|
|
|
|
out.push(' </video>');
|
|
out.push(' </media>');
|
|
out.push(' </sequence>');
|
|
out.push('</xmeml>');
|
|
return out.join('\n');
|
|
};
|
|
|
|
// ── Push Timeline to MAM ─────────────────────────────────────────
|
|
Timeline.pushToMAM = async function (seqName, projectId, td) {
|
|
const resolved = Library.resolveClipsToAssets(td.clips || []);
|
|
const matched = resolved.filter(c => c.asset_id);
|
|
if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first so the plugin can map file paths to asset IDs');
|
|
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: td.frameRate, width: td.width, height: td.height });
|
|
seqId = existing.id;
|
|
} else {
|
|
const created = await API.createSequence(projectId, seqName, td.frameRate, td.width, td.height);
|
|
seqId = created.id;
|
|
}
|
|
await API.pushClips(seqId, 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,
|
|
})));
|
|
return { seqId, matched: matched.length, skipped: resolved.length - matched.length };
|
|
};
|
|
|
|
// ── Conform ──────────────────────────────────────────────────────
|
|
Timeline.startConform = async function (projectId, seqName, td, opts) {
|
|
const { seqId } = await Timeline.pushToMAM(seqName, projectId, td);
|
|
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(td) });
|
|
return job.jobId || job.id;
|
|
};
|
|
|
|
Timeline.pollConform = function (jobId, onProgress, onDone) {
|
|
const t = setInterval(async () => {
|
|
try {
|
|
const job = await API.getJob(jobId);
|
|
onProgress(job.progress||0, job.status);
|
|
if (job.status==='completed'||job.status==='failed') { clearInterval(t); onDone(job); }
|
|
} catch (_) {}
|
|
}, 2000);
|
|
return t;
|
|
};
|
|
|
|
// ── Batch Hi-Res Relink ──────────────────────────────────────────
|
|
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.requestExternal(info.url);
|
|
if (!r.ok) throw new Error('Download HTTP ' + r.status);
|
|
const buf = await r.arrayBuffer();
|
|
await Import._writeBuffer(dest, buf);
|
|
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;
|
|
};
|
|
|
|
// Relink via ClipProjectItem.changeMediaFilePath — await all calls
|
|
Timeline._relinkInProject = async function (project, oldPath, newPath) {
|
|
const P = ppro();
|
|
const root = await project.getRootItem();
|
|
let count = 0;
|
|
|
|
async function walk(item) {
|
|
let children = [];
|
|
try { children = await item.getItems(); } catch (_) {}
|
|
for (const child of children) {
|
|
try {
|
|
const ci = await P.ClipProjectItem.cast(child);
|
|
const mp = await ci.getMediaFilePath();
|
|
if (mp && mp === oldPath) { await ci.changeMediaFilePath(newPath, false); count++; }
|
|
} catch (_) {
|
|
try { await walk(child); } catch (__) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(root);
|
|
|
|
// Fallback: findItemsMatchingMediaPath
|
|
if (count === 0) {
|
|
try {
|
|
const rootCi = await P.ClipProjectItem.cast(root);
|
|
const matches = await rootCi.findItemsMatchingMediaPath(oldPath, true);
|
|
for (const item of (matches||[])) {
|
|
try { const ci = await P.ClipProjectItem.cast(item); await ci.changeMediaFilePath(newPath, false); count++; } catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (count === 0) throw new Error('No matching clips found for: ' + (oldPath||'unknown'));
|
|
return count;
|
|
};
|
|
|
|
// ── Local Export ─────────────────────────────────────────────────
|
|
// Server trims each timeline clip's hi-res via FFMPEG, then we download
|
|
// the trimmed segments and relink the project items to them.
|
|
// CAVEAT: relink keys on the source media path, so a source used by
|
|
// multiple timeline clips with different in/out points will relink to a
|
|
// single segment (last one wins). Common single-use case is exact.
|
|
Timeline.localExport = async function (resolvedClips, onProgress) {
|
|
const P = ppro();
|
|
const project = await P.Project.getActiveProject();
|
|
if (!project) throw new Error('No active Premiere project');
|
|
const matched = (resolvedClips || []).filter(c => c.asset_id);
|
|
if (!matched.length) throw new Error('No clips matched MAM assets to export');
|
|
|
|
const payload = matched.map(c => ({
|
|
assetId: c.asset_id,
|
|
filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'),
|
|
sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames,
|
|
timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames,
|
|
trackIndex: c.trackIndex,
|
|
}));
|
|
|
|
onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10);
|
|
const job = await API.batchTrim(payload);
|
|
const jobId = job.jobId;
|
|
const clipByInstance = {};
|
|
(job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; });
|
|
|
|
// Poll until every segment is ready (s3Key set) or the job fails.
|
|
const ready = {};
|
|
await new Promise((resolve, reject) => {
|
|
const t = setInterval(async () => {
|
|
try {
|
|
const st = await API.getTrimStatus(jobId);
|
|
const clips = st.clips || [];
|
|
const completed = clips.filter(c => c.status === 'completed' && c.s3Key);
|
|
onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')',
|
|
15 + (completed.length / Math.max(1, clips.length)) * 45);
|
|
if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; }
|
|
if (clips.length && completed.length === clips.length) {
|
|
clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve();
|
|
}
|
|
} catch (_) { /* transient — keep polling */ }
|
|
}, 2000);
|
|
});
|
|
|
|
// Download each segment and relink the source media path to it.
|
|
const results = { succeeded: 0, failed: 0, errors: [] };
|
|
const ids = Object.keys(ready);
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const cid = ids[i];
|
|
const clip = clipByInstance[cid];
|
|
if (!clip) continue;
|
|
try {
|
|
onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35);
|
|
const seg = await API.getTempSegmentUrl(cid);
|
|
const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov';
|
|
const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext);
|
|
const dest = await Import._tempPath(base);
|
|
const r = await API.requestExternal(seg.url);
|
|
if (!r.ok) throw new Error('Segment download HTTP ' + r.status);
|
|
await Import._writeBuffer(dest, await r.arrayBuffer());
|
|
if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest);
|
|
results.succeeded++;
|
|
} catch (e) {
|
|
results.failed++;
|
|
results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message);
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
|
|
window.Timeline = Timeline;
|
|
})();
|