dragonflight/services/premiere-plugin-uxp/src/main.js
Claude 540d333758 feat(uxp): v2.2.1 — Export Timeline is now one-click push + render → asset
Contract: clicking Export Timeline does the whole pipeline with no
prompts. Behavior matches what the user actually expected from the
button label:

  1. readActiveSequence — pulls the Premiere timeline + clip map
  2. resolveExportProject — picks the target MAM project. First run
     uses the first project on the server and caches its id in
     localStorage (df.uxp.exportProjectId). Subsequent runs reuse
     the cache. If the cached project was deleted server-side we
     transparently re-pick.
  3. Timeline.startConform with sensible defaults:
       codec=prores_hq, quality=high, resolution=source, audio=broadcast
     This both pushes the sequence + clip rows AND queues a real
     conform job (the prior Push-to-MAM button never queued a job,
     which is why "no jobs spin up" happened earlier).
  4. pollConform every 2s, mapping job progress 20→95% on the
     panel progress bar.
  5. On completion, toast + Library.refresh() so the rendered hi-res
     asset shows up in the grid without needing to click around.

The Conform slide panel stays wired for Advanced → Export & Conform
so power users can still override the codec/preset for one-off jobs.
The Push-only slide panel that this replaces is now orphaned chrome
and will be removed in a later cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:28:51 -04:00

477 lines
22 KiB
JavaScript

// Dragonflight UXP Panel — main.js v2.1.5
(function () {
if (window.__df_uxp_started) return;
window.__df_uxp_started = true;
const $ = id => document.getElementById(id);
function syncConnectBtn() {
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
}
async function tryConnect(serverUrl, apiToken) {
UI.setStatus('#connect-status', 'Connecting…', 'muted');
try {
const me = await API.connect(serverUrl, apiToken);
const who = (me && (me.display_name || me.username)) || serverUrl;
$('connected-host').textContent = who === serverUrl ? who : who + ' @ ' + serverUrl;
UI.setStatus('#connect-status', '', 'muted');
UI.showPane('library');
await Library.loadProjects();
await Library.refresh('');
Library.startGrowingPoll();
Timeline.refreshSeqBar().catch(() => {});
} catch (e) {
API.state.connected = false;
UI.setStatus('#connect-status', 'Connect failed: ' + e.message, 'error');
UI.showPane('connect');
}
}
function wireConnectPane() {
$('server-url').value = API.state.serverUrl;
$('api-token').value = API.state.apiToken;
syncConnectBtn();
['input','change'].forEach(ev => {
$('server-url').addEventListener(ev, syncConnectBtn);
$('api-token').addEventListener(ev, syncConnectBtn);
});
$('connect-btn').addEventListener('click', async () => {
await tryConnect($('server-url').value.trim(), $('api-token').value.trim());
});
}
function wireLibraryPane() {
// Overflow menu toggle (v2.1.8). Click ⋯ to open; click elsewhere closes.
const menuBtn = $('menu-btn');
const menu = $('status-menu');
if (menuBtn && menu) {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menu.classList.contains('hidden')
&& !menu.contains(e.target)
&& e.target !== menuBtn) {
menu.classList.add('hidden');
}
});
}
$('disconnect-btn').addEventListener('click', () => {
if (menu) menu.classList.add('hidden');
API.disconnect();
Library.stopGrowingPoll();
$('server-url').value = '';
$('api-token').value = '';
syncConnectBtn();
UI.showPane('connect');
UI.setStatus('#connect-status', 'Disconnected.', 'muted');
});
$('tab-library').addEventListener('click', () => Library.switchTab('library'));
$('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
let searchTimer;
$('search-input').addEventListener('input', e => {
clearTimeout(searchTimer);
const q = e.target.value;
searchTimer = setTimeout(() => {
if (Library.state.currentTab === 'library') Library.refresh(q);
else { Library.state.searchQuery = q; Library._pollGrowing(); }
}, 280);
});
$('project-filter').addEventListener('change', e => {
Library.state.selectedProject = e.target.value;
if (Library.state.currentTab === 'library') Library.refresh(Library.state.searchQuery);
else Library._pollGrowing();
});
$('refresh-btn').addEventListener('click', () => Library.refresh($('search-input').value));
$('import-proxy-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
_disableImportBtns(true);
try {
const { localPath, safeName } = await Import.proxy(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
} catch (e) { UI.hideProgress(); UI.toast('Proxy import failed: ' + e.message, 'error'); }
finally { _disableImportBtns(false); Library._syncActions(); }
});
$('import-hires-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
_disableImportBtns(true);
try {
const { localPath, safeName } = await Import.hires(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
} catch (e) { UI.hideProgress(); UI.toast('Hi-res import failed: ' + e.message, 'error'); }
finally { _disableImportBtns(false); Library._syncActions(); }
});
$('import-all-btn').addEventListener('click', async () => {
const assets = Library.state.assets;
if (!assets.length) { UI.toast('No assets', 'error'); return; }
_disableImportBtns(true);
let ok = 0, fail = 0;
for (const a of assets) {
try {
const { localPath, safeName } = await Import.proxy(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
ok++;
} catch (_) { fail++; }
}
_disableImportBtns(false);
UI.hideProgress();
UI.toast('Import all: ' + ok + ' ok' + (fail ? ', ' + fail + ' failed' : ''), fail ? 'error' : 'ok');
Library._syncActions();
});
$('mount-live-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
$('mount-live-btn').disabled = true;
UI.showProgress('Resolving live path…', 10);
try {
const info = await API.getLivePath(a.id);
const hostPath = info.win_path || info.posix_path;
UI.showProgress('Importing live file…', 50);
await Import.importIntoProject(hostPath);
Library.recordImport('live:' + a.id, { assetId: a.id, displayName: info.display_name, livePath: hostPath });
Library.startLiveStatusPoll(a.id);
UI.hideProgress();
UI.toast('Mounted live: ' + (info.display_name || a.id), 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Mount live failed: ' + e.message, 'error'); }
finally { Library._syncActions(); }
});
// ── Relink single asset (proxy → hi-res) ──
// ALL premierepro calls must be awaited — runtime returns Promises
$('relink-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
const entry = Library.getImport('live:' + a.id);
if (!entry) { UI.toast('No live mount recorded', 'error'); return; }
$('relink-btn').disabled = true;
UI.showProgress('Fetching hi-res…', 10);
try {
const info = await API.getHiresInfo(a.id);
const safeName = UI.sanitizeFilename(info.filename || (a.display_name || a.id) + '.' + (info.ext || 'mxf'));
const dest = await Import._tempPath(safeName);
UI.showProgress('Downloading ' + safeName + '…', 20);
// S3 presigned URL — no auth, auto-follow redirects (UXP handles)
const r = await API.requestExternal(info.url);
if (!r.ok) throw new Error('Download HTTP ' + r.status);
UI.showProgress('Writing to disk…', 65);
const buf = await r.arrayBuffer();
await Import._writeBuffer(dest, buf);
UI.showProgress('Relinking…', 85);
const P = require('premierepro');
const proj = await P.Project.getActiveProject(); // ← must await
if (!proj) throw new Error('No active project');
await Timeline._relinkInProject(proj, entry.livePath, dest);
Library.recordImport(dest, { assetId: a.id, displayName: a.display_name || a.filename });
UI.hideProgress();
UI.toast('Relinked: ' + safeName, 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Relink failed: ' + e.message, 'error'); }
finally { Library._syncActions(); }
});
// v2.2.1: Export Timeline is now a single-click pipeline —
// push to MAM → start conform → poll → new asset lands in Library.
// The Conform slide panel is still wired for Advanced → Export & Conform.
$('export-timeline-btn').addEventListener('click', oneClickExport);
$('export-conform-btn').addEventListener('click', openConformPanel);
$('fetch-relink-btn').addEventListener('click', openRelinkPanel);
// Advanced collapsible toggle (v2.2.0).
const advToggle = $('advanced-toggle');
const advBody = $('advanced-body');
if (advToggle && advBody) {
advToggle.addEventListener('click', () => {
const open = advBody.classList.toggle('hidden') === false;
advToggle.setAttribute('aria-expanded', String(open));
});
}
}
function _disableImportBtns(dis) {
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
}
let _seqCache = null;
// ── One-click Export Timeline ────────────────────────────────────
//
// Contract: clicking Export Timeline reads the active Premiere
// sequence, pushes it to MAM, kicks off a hi-res conform render, and
// lets the result land as an asset in the Library. No prompts.
//
// Defaults are baked in (ProRes 422 HQ / source res / broadcast
// audio). Advanced → Export & Conform is still wired for fine-grained
// control.
const LS_EXPORT_PROJECT = 'df.uxp.exportProjectId';
const DEFAULT_EXPORT_OPTS = {
codec: 'prores_hq',
quality: 'high',
resolution: 'source',
audio: 'broadcast',
};
// Resolve a target MAM project for the export. Cached in localStorage
// after first run so subsequent exports require zero clicks. Falls
// back to the first project on the server. If the cache references a
// project that no longer exists, we transparently re-pick.
async function resolveExportProject() {
const cached = localStorage.getItem(LS_EXPORT_PROJECT) || '';
let projects;
try { projects = await API.listProjects(); }
catch (e) { throw new Error('Project lookup failed: ' + e.message); }
if (!Array.isArray(projects) || !projects.length) {
throw new Error('No projects in MAM — create one in the web UI first');
}
if (cached && projects.some(p => p.id === cached)) return cached;
const id = projects[0].id;
localStorage.setItem(LS_EXPORT_PROJECT, id);
return id;
}
async function oneClickExport() {
UI.$('#export-timeline-btn').disabled = true;
try {
UI.showProgress('Reading Premiere sequence…', 5);
let td;
try { td = await Timeline.readActiveSequence(); }
catch (e) { throw new Error('Timeline read failed: ' + e.message); }
if (!td || !td.clips || !td.clips.length) {
throw new Error('No clips in active sequence');
}
UI.showProgress('Resolving target project…', 10);
const projectId = await resolveExportProject();
const seqName = td.sequenceName || 'Premiere Export';
UI.showProgress('Pushing timeline + queueing render…', 15);
let jobId;
try {
jobId = await Timeline.startConform(projectId, seqName, td, DEFAULT_EXPORT_OPTS);
} catch (e) { throw new Error('Export failed: ' + e.message); }
if (!jobId) throw new Error('Export started but no job id was returned');
// Poll the conform job to completion. Map progress 20→95% so the
// user sees the bar move during the long render step.
UI.showProgress('Rendering hi-res…', 20);
await new Promise((resolve) => {
Timeline.pollConform(jobId,
(progress, status) => {
UI.showProgress('Rendering hi-res (' + (status || 'queued') + ')…', 20 + 0.75 * (Number(progress) || 0));
},
(job) => {
UI.hideProgress();
if (job && job.status === 'completed') {
UI.toast('Rendered: ' + seqName + ' — now in Library', 'ok');
const q = ($('search-input') && $('search-input').value) || '';
Library.refresh(q).catch(() => {});
} else {
const why = (job && (job.error || job.message)) || 'unknown reason';
UI.toast('Render failed: ' + why, 'error');
}
resolve();
}
);
});
} catch (e) {
UI.hideProgress();
UI.toast(e.message || 'Export failed', 'error');
} finally {
UI.$('#export-timeline-btn').disabled = false;
}
}
async function openExportPanel() {
UI.showProgress('Reading Premiere sequence…', 20);
try { _seqCache = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const matched = resolved.filter(c => c.asset_id).length;
$('export-seq-name').value = _seqCache.sequenceName || 'Sequence 1';
$('export-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched to MAM assets';
UI.openSlide('export-overlay', 'export-panel');
}
function wireExportPanel() {
$('export-close-btn').addEventListener('click', () => UI.closeSlide('export-overlay', 'export-panel'));
$('export-cancel-btn').addEventListener('click', () => UI.closeSlide('export-overlay', 'export-panel'));
$('export-overlay').addEventListener('click', () => UI.closeSlide('export-overlay', 'export-panel'));
$('export-confirm-btn').addEventListener('click', async () => {
if (!_seqCache) return;
const seqName = ($('export-seq-name').value || '').trim() || 'Sequence 1';
const projectId = $('export-proj-select').value;
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
UI.closeSlide('export-overlay', 'export-panel');
UI.showProgress('Pushing timeline…', 20);
try {
const { matched, skipped } = await Timeline.pushToMAM(seqName, projectId, _seqCache);
UI.hideProgress();
UI.toast('Pushed ' + matched + ' clip(s)' + (skipped ? ' (' + skipped + ' skipped)' : ''), 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Export failed: ' + e.message, 'error'); }
});
}
async function openConformPanel() {
UI.showProgress('Reading Premiere sequence…', 20);
try { _seqCache = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const matched = resolved.filter(c => c.asset_id).length;
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched';
$('conform-start-btn').disabled = matched === 0;
const conformProj = $('conform-proj-select');
if (conformProj) {
conformProj.innerHTML = '<option value="">— Select project —</option>';
Library.state.projects.forEach(p => {
const o = document.createElement('option'); o.value = p.id; o.textContent = p.name;
conformProj.appendChild(o);
});
}
UI.openSlide('conform-overlay', 'conform-panel');
}
function wireConformPanel() {
$('conform-close-btn').addEventListener('click', () => UI.closeSlide('conform-overlay', 'conform-panel'));
$('conform-cancel-btn').addEventListener('click', () => UI.closeSlide('conform-overlay', 'conform-panel'));
$('conform-overlay').addEventListener('click', () => UI.closeSlide('conform-overlay', 'conform-panel'));
$('preset-cards').addEventListener('click', e => {
const card = e.target.closest('.preset-card'); if (!card) return;
document.querySelectorAll('.preset-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
const presets = {
broadcast: { codec:'prores_hq', quality:'high', resolution:'1080p', audio:'broadcast' },
web: { codec:'h264', quality:'medium', resolution:'1080p', audio:'web' },
archive: { codec:'prores_4444', quality:'high', resolution:'uhd', audio:'archive' },
};
const p = presets[card.dataset.preset];
if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; }
});
$('conform-start-btn').addEventListener('click', async () => {
if (!_seqCache) return;
const conformProj = $('conform-proj-select');
const projectId = conformProj ? conformProj.value : '';
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
UI.closeSlide('conform-overlay', 'conform-panel');
UI.showProgress('Starting conform job…', 15);
try {
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
codec:$('conform-codec').value, quality:$('conform-quality').value,
resolution:$('conform-resolution').value, audio:$('conform-audio').value,
});
Timeline.pollConform(jobId,
(pct, status) => UI.showProgress('Conform: ' + status + ' (' + pct + '%)…', 15 + pct * 0.8),
job => {
UI.hideProgress();
if (job.status==='completed') { UI.toast('Conform complete','ok'); Library.refresh(Library.state.searchQuery); }
else { UI.toast('Conform failed: '+(job.error||'unknown'),'error'); }
}
);
} catch (e) { UI.hideProgress(); UI.toast('Conform failed: ' + e.message, 'error'); }
});
}
let _relinkClips = [];
async function openRelinkPanel() {
UI.showProgress('Reading Premiere sequence…', 20);
try { _seqCache = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
_relinkClips = resolved;
const list = $('clip-list');
list.innerHTML = '';
$('relink-summary').classList.add('hidden');
$('relink-start-btn').disabled = true;
if (!resolved.length) {
list.innerHTML = '<div class="empty muted">No clips in active sequence</div>';
} else {
resolved.forEach((clip, i) => {
const matched = !!clip.asset_id;
const row = document.createElement('div');
row.className = 'clip-item' + (matched ? ' matched' : ' unmatched');
const cb = document.createElement('input');
cb.type='checkbox'; cb.id='rclip-'+i; cb.disabled=!matched; cb.checked=matched;
cb.addEventListener('change', _syncRelinkStart);
const lbl = document.createElement('label');
lbl.htmlFor='rclip-'+i; lbl.className='clip-item-name'; lbl.textContent=clip.fileName||'clip';
const st = document.createElement('span');
st.className='clip-item-status'; st.textContent=matched?'matched':'not in MAM';
row.append(cb, lbl, st); list.appendChild(row);
});
}
UI.openSlide('relink-overlay', 'relink-panel');
_syncRelinkStart();
}
function _syncRelinkStart() {
$('relink-start-btn').disabled = !document.querySelector('#clip-list input[type="checkbox"]:checked');
}
function wireRelinkPanel() {
$('relink-close-btn').addEventListener('click', () => UI.closeSlide('relink-overlay', 'relink-panel'));
$('relink-cancel-btn').addEventListener('click', () => UI.closeSlide('relink-overlay', 'relink-panel'));
$('relink-overlay').addEventListener('click', () => UI.closeSlide('relink-overlay', 'relink-panel'));
$('relink-start-btn').addEventListener('click', async () => {
const checked = document.querySelectorAll('#clip-list input[type="checkbox"]:checked');
const selected = [];
checked.forEach(cb => {
const i = parseInt(cb.id.replace('rclip-',''), 10);
if (_relinkClips[i] && _relinkClips[i].asset_id) selected.push(_relinkClips[i]);
});
if (!selected.length) return;
UI.closeSlide('relink-overlay', 'relink-panel');
UI.showProgress('Starting batch relink…', 5);
try {
const res = await Timeline.batchRelink(selected);
UI.hideProgress();
UI.toast(res.succeeded + ' relinked' + (res.failed ? ', ' + res.failed + ' failed' : ''), res.failed ? 'error' : 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Batch relink failed: ' + e.message, 'error'); }
});
}
// Read the plugin version from manifest.json at runtime — the canonical
// source of truth. Display it on both the connect-pane brand and the
// library-pane status strip so we can verify which build is actually
// running (UPIA stacks every install in its own _<version> dir, so the
// "highest in folder" picker has historically loaded stale copies).
async function showVersion() {
let v = '';
try {
const lfs = require('uxp').storage.localFileSystem;
const folder = await lfs.getPluginFolder();
const entry = await folder.getEntry('manifest.json');
const text = await entry.read();
const m = JSON.parse(text);
v = m && m.version ? ('v' + m.version) : '';
} catch (_) { /* leave blank — the absence itself signals a bad load */ }
const a = $('panel-version'); if (a) a.textContent = v;
const b = $('brand-version'); if (b) b.textContent = v;
}
function init() {
wireConnectPane(); wireLibraryPane();
wireExportPanel(); wireConformPanel(); wireRelinkPanel();
showVersion();
if (API.state.serverUrl && API.state.apiToken) tryConnect(API.state.serverUrl, API.state.apiToken);
else UI.showPane('connect');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();