diff --git a/services/premiere-plugin/js/main.js b/services/premiere-plugin/js/main.js index 421aaef..e337906 100644 --- a/services/premiere-plugin/js/main.js +++ b/services/premiere-plugin/js/main.js @@ -25,6 +25,11 @@ const state = { downloadProgress: 0, isDownloading: false, thumbCache: {}, // assetId -> signed URL + // Maps tempFilePath → { assetId, displayName } for timeline export resolution. + // Also stores 'name:filename.mp4' → same as a fuzzy fallback. + importedAssets: JSON.parse(localStorage.getItem('mam_imported_assets') || '{}'), + exportPanelVisible: false, + currentSequenceName: '', }; // ============================================================================ @@ -51,10 +56,23 @@ function initDOMElements() { detailsSize: document.getElementById('details-size'), detailsTags: document.getElementById('details-tags'), importBtn: document.getElementById('import-btn'), + importHiresBtn: document.getElementById('import-hires-btn'), importAllBtn: document.getElementById('import-all-btn'), + exportTimelineBtn: document.getElementById('export-timeline-btn'), progressContainer: document.getElementById('progress-container'), progressLabel: document.getElementById('progress-label'), progressFill: document.getElementById('progress-fill'), + // Export panel + exportPanel: document.getElementById('export-panel'), + exportSeqName: document.getElementById('export-seq-name'), + exportProjSelect: document.getElementById('export-proj-select'), + exportClipInfo: document.getElementById('export-clip-info'), + exportConfirmBtn: document.getElementById('export-confirm-btn'), + exportCancelBtn: document.getElementById('export-cancel-btn'), + // Sequence info bar + seqInfoBar: document.getElementById('seq-info-bar'), + seqInfoName: document.getElementById('seq-info-name'), + seqRefreshBtn: document.getElementById('seq-refresh-btn'), }; } @@ -116,7 +134,12 @@ function setupEventListeners() { elements.projectFilter.addEventListener('change', handleProjectFilter); elements.assetGrid.addEventListener('click', handleAssetClick); elements.importBtn.addEventListener('click', importSelectedAsset); + elements.importHiresBtn.addEventListener('click', importSelectedAssetHires); elements.importAllBtn.addEventListener('click', importAllAssets); + elements.exportTimelineBtn.addEventListener('click', startExportTimeline); + elements.exportConfirmBtn.addEventListener('click', confirmExportTimeline); + elements.exportCancelBtn.addEventListener('click', cancelExportTimeline); + elements.seqRefreshBtn.addEventListener('click', refreshCurrentSequenceInfo); } function restoreSettings() { @@ -135,7 +158,6 @@ async function connectToServer() { elements.connectBtn.disabled = true; try { - // Use the projects endpoint as a connectivity check const response = await fetch(`${state.serverUrl}/api/v1/projects`, { method: 'GET', headers: { Accept: 'application/json' }, @@ -151,6 +173,7 @@ async function connectToServer() { const projectData = await response.json(); await fetchProjects(projectData); await fetchAssets(); + refreshCurrentSequenceInfo(); } else { throw new Error(`HTTP ${response.status}`); } @@ -204,6 +227,9 @@ async function fetchProjects(preloadedData) { }); elements.projectFilter.value = savedProject; + // Also populate the export panel project selector + populateExportProjectSelect(); + logMessage(`Loaded ${state.projects.length} projects`); } catch (error) { console.error('Error fetching projects:', error); @@ -255,8 +281,9 @@ async function fetchAssetDetails(assetId) { } /** - * Returns a short-lived presigned URL for the H.264 proxy of the given asset. - * GET /api/v1/assets/:id/stream -> { url: '...' } + * Returns the download URL for the proxy. /stream returns a server-relative + * path (/api/v1/assets/:id/video) designed for browser playback; we prepend + * serverUrl so Node.js http.get() receives a valid absolute URL. */ async function getSignedDownloadUrl(assetId) { const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/stream`, { @@ -266,7 +293,22 @@ async function getSignedDownloadUrl(assetId) { if (!response.ok) throw new Error(`HTTP ${response.status} from /stream`); const { url } = await response.json(); if (!url) throw new Error('Stream endpoint returned no URL'); - return url; + return url.startsWith('/') ? state.serverUrl + url : url; +} + +/** + * Returns presigned S3 URL + filename/ext/file_size for the hi-res original. + * GET /api/v1/assets/:id/hires + */ +async function getHiresDownloadInfo(assetId) { + const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/hires`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }); + if (!response.ok) throw new Error(`HTTP ${response.status} from /hires`); + const data = await response.json(); + if (!data.url) throw new Error('Hi-res endpoint returned no URL'); + return data; } // ============================================================================ @@ -320,7 +362,6 @@ function createAssetCard(asset) { info.appendChild(filenameEl); const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null; - // codec and media_type are top-level columns on the asset record const codec = asset.codec || asset.media_type || 'video'; const meta = document.createElement('div'); meta.className = 'asset-meta'; @@ -344,7 +385,6 @@ function showAssetDetails(asset) { state.selectedAsset = asset; elements.detailsPanel.classList.remove('hidden'); - // All of these are top-level columns in the assets table elements.detailsFilename.textContent = asset.display_name || asset.filename; elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown'; elements.detailsResolution.textContent = asset.resolution || 'N/A'; @@ -370,13 +410,15 @@ function showAssetDetails(asset) { 'No tags'; } - elements.importBtn.disabled = false; + elements.importBtn.disabled = false; + elements.importHiresBtn.disabled = false; } function hideAssetDetails() { state.selectedAsset = null; elements.detailsPanel.classList.add('hidden'); - elements.importBtn.disabled = true; + elements.importBtn.disabled = true; + elements.importHiresBtn.disabled = true; } // ============================================================================ @@ -396,7 +438,7 @@ function handleProjectFilter(e) { } // ============================================================================ -// Import Functionality +// Import — Proxy // ============================================================================ async function importSelectedAsset() { @@ -422,7 +464,7 @@ async function importAsset(asset) { try { elements.importBtn.disabled = true; - // 1. Get a presigned proxy URL + // 1. Get proxy URL (absolute — /stream returns a relative path) showProgress('Getting download link...', 5); const url = await getSignedDownloadUrl(asset.id); @@ -437,6 +479,9 @@ async function importAsset(asset) { showProgress('Importing into Premiere Pro...', 85); await importFileToPremiereProject(filePath); + // 4. Store path → assetId so timeline export can resolve this clip + saveImportMapping(filePath, safeName, asset); + hideProgress(); showSuccessMessage('Imported: ' + safeName); } catch (error) { @@ -448,6 +493,376 @@ async function importAsset(asset) { } } +// ============================================================================ +// Import — Hi-Res Original +// ============================================================================ + +async function importSelectedAssetHires() { + if (!state.selectedAsset) { + showErrorMessage('No asset selected'); + return; + } + await importAssetHires(state.selectedAsset); +} + +async function importAssetHires(asset) { + try { + elements.importHiresBtn.disabled = true; + + // 1. Get hi-res presigned URL and metadata + showProgress('Getting hi-res link...', 5); + let hiresInfo; + try { + hiresInfo = await getHiresDownloadInfo(asset.id); + } catch (err) { + throw new Error('No hi-res source: ' + err.message); + } + + const sizeNote = hiresInfo.file_size ? ' (' + formatFileSize(hiresInfo.file_size) + ')' : ''; + const safeName = sanitizeFilename( + hiresInfo.filename || (asset.display_name || asset.id) + '.mxf' + ); + + // 2. Download hi-res file to OS temp dir + showProgress('Downloading hi-res' + sizeNote + '...', 10); + const filePath = await downloadFile(hiresInfo.url, safeName); + + // 3. Import into Premiere Pro + showProgress('Importing hi-res into Premiere Pro...', 85); + await importFileToPremiereProject(filePath); + + // 4. Map this path to the same asset ID (hi-res round-trips to same MAM record) + saveImportMapping(filePath, safeName, asset); + + hideProgress(); + showSuccessMessage('Hi-res imported: ' + safeName); + } catch (error) { + console.error('Hi-res import error:', error); + hideProgress(); + showErrorMessage('Hi-res import failed: ' + error.message); + } finally { + elements.importHiresBtn.disabled = !state.selectedAsset; + } +} + +/** + * Persists two keys per import: + * - exact temp path → so Premiere getMediaPath() matches on the same machine + * - 'name:filename' → fuzzy fallback across sessions / machines + */ +function saveImportMapping(filePath, safeName, asset) { + const entry = { + assetId: asset.id, + displayName: asset.display_name || asset.filename || '', + }; + state.importedAssets[filePath] = entry; + state.importedAssets['name:' + safeName] = entry; + try { + localStorage.setItem('mam_imported_assets', JSON.stringify(state.importedAssets)); + } catch (_) {} +} + +// ============================================================================ +// Active Sequence Info Bar +// ============================================================================ + +function refreshCurrentSequenceInfo() { + csInterface.evalScript('getActiveSequence()', function (resultStr) { + try { + var parsed = JSON.parse(resultStr); + state.currentSequenceName = parsed.sequenceName || ''; + if (state.currentSequenceName) { + elements.seqInfoName.textContent = state.currentSequenceName; + elements.seqInfoBar.classList.remove('hidden'); + } else { + elements.seqInfoBar.classList.add('hidden'); + } + } catch (e) { + elements.seqInfoBar.classList.add('hidden'); + } + }); +} + +// ============================================================================ +// Export Panel UI +// ============================================================================ + +function populateExportProjectSelect() { + elements.exportProjSelect.innerHTML = ''; + state.projects.forEach(function (p) { + var opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + if (p.id === state.selectedProject) opt.selected = true; + elements.exportProjSelect.appendChild(opt); + }); +} + +function showExportPanel(timelineData) { + elements.exportSeqName.value = timelineData.sequenceName || 'Sequence 1'; + populateExportProjectSelect(); + + var totalClips = (timelineData.clips || []).length; + var matchedClips = resolveClipsToAssets(timelineData.clips || []).filter(function (c) { + return c.asset_id; + }).length; + + elements.exportClipInfo.textContent = + matchedClips + ' of ' + totalClips + ' clip(s) matched to MAM assets'; + elements.exportClipInfo.style.color = + matchedClips === 0 ? 'var(--error)' : 'var(--text-secondary)'; + + // Stash the full timeline data for confirmExportTimeline + elements.exportPanel.dataset.timelineJson = JSON.stringify(timelineData); + elements.exportPanel.classList.remove('hidden'); + state.exportPanelVisible = true; + elements.exportSeqName.focus(); +} + +function hideExportPanel() { + elements.exportPanel.classList.add('hidden'); + state.exportPanelVisible = false; +} + +// ============================================================================ +// Timeline Export — Premiere → MAM +// ============================================================================ + +/** + * Step 1 of 2: read the Premiere timeline then show the export confirmation + * panel with pre-filled sequence name and matched clip count. + */ +async function startExportTimeline() { + if (!state.isConnected) { + showErrorMessage('Connect to MAM first'); + return; + } + + // Toggle: clicking again closes the panel + if (state.exportPanelVisible) { + hideExportPanel(); + return; + } + + showProgress('Reading Premiere timeline...', 20); + + const timelineData = await new Promise(function (resolve) { + csInterface.evalScript('exportTimelineData()', function (resultStr) { + try { resolve(JSON.parse(resultStr)); } + catch (e) { resolve({ success: false, message: 'Parse error: ' + e.message, clips: [] }); } + }); + }); + + hideProgress(); + + if (!timelineData.success) { + showErrorMessage('Timeline read failed: ' + (timelineData.message || 'unknown error')); + return; + } + + if (!timelineData.clips || timelineData.clips.length === 0) { + showErrorMessage('No clips found in the active sequence'); + return; + } + + showExportPanel(timelineData); +} + +/** + * Step 2 of 2: resolve clip paths to MAM asset IDs, upsert the sequence, + * then PUT the clip array to /api/v1/sequences/:id/clips. + */ +async function confirmExportTimeline() { + var timelineData; + try { + timelineData = JSON.parse(elements.exportPanel.dataset.timelineJson || '{}'); + } catch (e) { + showErrorMessage('Invalid timeline data — try reading again'); + return; + } + + var seqName = (elements.exportSeqName.value || '').trim() || 'Sequence 1'; + var projectId = elements.exportProjSelect.value; + if (!projectId) { + showErrorMessage('Select a target project'); + return; + } + + var resolved = resolveClipsToAssets(timelineData.clips || []); + var matched = resolved.filter(function (c) { return c.asset_id; }); + + if (matched.length === 0) { + hideExportPanel(); + showErrorMessage('No clips matched MAM assets — import proxies or hi-res first'); + return; + } + + hideExportPanel(); + showProgress('Creating sequence in MAM...', 20); + + try { + var seqId = await upsertSequence( + projectId, + seqName, + timelineData.frameRate || 59.94, + timelineData.width || 1920, + timelineData.height || 1080 + ); + + showProgress('Writing ' + matched.length + ' clip(s)...', 60); + + var clipPayload = matched.map(function (c) { + return { + 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, + }; + }); + + var clipsRes = await fetch( + state.serverUrl + '/api/v1/sequences/' + seqId + '/clips', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'include', + body: JSON.stringify(clipPayload), + } + ); + if (!clipsRes.ok) throw new Error('Clip push failed: HTTP ' + clipsRes.status); + + hideProgress(); + showSuccessMessage('Timeline pushed: ' + matched.length + ' clip(s) → "' + seqName + '"'); + + var skipped = resolved.length - matched.length; + if (skipped > 0) { + _showFlash(skipped + ' clip(s) skipped — not in MAM (import them first)', 'info-message'); + } + } catch (err) { + hideProgress(); + showErrorMessage('Export failed: ' + err.message); + } +} + +function cancelExportTimeline() { + hideExportPanel(); +} + +/** + * Maps Premiere clip file paths back to MAM asset IDs. + * Lookup order: exact temp path → name-based fallback. + */ +function resolveClipsToAssets(clips) { + return clips.map(function (clip) { + var entry = state.importedAssets[clip.filePath]; + if (!entry) entry = state.importedAssets['name:' + clip.fileName]; + return Object.assign({}, clip, { asset_id: entry ? entry.assetId : null }); + }); +} + +/** + * Finds an existing sequence by name in the target project; creates it if + * absent. Updates frame_rate/width/height on the existing record. + * Returns the sequence ID. + */ +async function upsertSequence(projectId, name, frameRate, width, height) { + var listRes = await fetch( + state.serverUrl + '/api/v1/sequences?project_id=' + encodeURIComponent(projectId), + { headers: { Accept: 'application/json' }, credentials: 'include' } + ); + if (listRes.ok) { + var seqs = await listRes.json(); + var existing = seqs.find(function (s) { return s.name === name; }); + if (existing) { + await fetch(state.serverUrl + '/api/v1/sequences/' + existing.id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'include', + body: JSON.stringify({ frame_rate: frameRate, width: width, height: height }), + }); + return existing.id; + } + } + + var createRes = await fetch(state.serverUrl + '/api/v1/sequences', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + project_id: projectId, + name: name, + frame_rate: frameRate, + width: width, + height: height, + }), + }); + if (!createRes.ok) throw new Error('Failed to create sequence: HTTP ' + createRes.status); + var seq = await createRes.json(); + return seq.id; +} + +// ============================================================================ +// UI Helpers +// ============================================================================ + +function handleAssetClick(e) { + var card = e.target.closest('.asset-card'); + if (!card) return; + + document.querySelectorAll('.asset-card.selected').forEach(function (el) { + el.classList.remove('selected'); + }); + card.classList.add('selected'); + + var assetId = card.dataset.assetId; + var asset = state.assets.find(function (a) { return a.id === assetId; }); + if (asset) showAssetDetails(asset); +} + +function showProgress(label, percent) { + elements.progressContainer.classList.add('visible'); + elements.progressLabel.textContent = label; + state.downloadProgress = percent; + updateProgressUI(); +} + +function hideProgress() { + elements.progressContainer.classList.remove('visible'); + state.downloadProgress = 0; + updateProgressUI(); +} + +function updateProgressUI() { + elements.progressFill.style.width = state.downloadProgress + '%'; +} + +function showErrorMessage(message) { + _showFlash(message, 'error-message'); +} + +function showSuccessMessage(message) { + _showFlash(message, 'success-message'); +} + +function _showFlash(message, className) { + var el = document.createElement('div'); + el.className = className; + el.textContent = message; + var anchor = document.querySelector('.search-filter-area'); + anchor.insertBefore(el, anchor.firstChild); + setTimeout(function () { el.remove(); }, 5000); +} + +function logMessage(message) { + console.log('[MAM Panel] ' + message); +} + +// ============================================================================ +// File Download and Premiere Import (Node.js / ExtendScript) +// ============================================================================ + /** * Downloads a remote URL to a local temp file using Node.js http/https. * Requires --enable-nodejs in the CEP manifest. @@ -546,62 +961,6 @@ function importFileToPremiereProject(filePath) { }); } -// ============================================================================ -// UI Helpers -// ============================================================================ - -function handleAssetClick(e) { - var card = e.target.closest('.asset-card'); - if (!card) return; - - document.querySelectorAll('.asset-card.selected').forEach(function (el) { - el.classList.remove('selected'); - }); - card.classList.add('selected'); - - var assetId = card.dataset.assetId; - var asset = state.assets.find(function (a) { return a.id === assetId; }); - if (asset) showAssetDetails(asset); -} - -function showProgress(label, percent) { - elements.progressContainer.classList.add('visible'); - elements.progressLabel.textContent = label; - state.downloadProgress = percent; - updateProgressUI(); -} - -function hideProgress() { - elements.progressContainer.classList.remove('visible'); - state.downloadProgress = 0; - updateProgressUI(); -} - -function updateProgressUI() { - elements.progressFill.style.width = state.downloadProgress + '%'; -} - -function showErrorMessage(message) { - _showFlash(message, 'error-message'); -} - -function showSuccessMessage(message) { - _showFlash(message, 'success-message'); -} - -function _showFlash(message, className) { - var el = document.createElement('div'); - el.className = className; - el.textContent = message; - var anchor = document.querySelector('.search-filter-area'); - anchor.insertBefore(el, anchor.firstChild); - setTimeout(function () { el.remove(); }, 5000); -} - -function logMessage(message) { - console.log('[MAM Panel] ' + message); -} - // ============================================================================ // Utility Functions // ============================================================================