/** * Wild Dragon MAM - Premiere Pro Panel * Main JavaScript file for the CEP panel */ // Adobe CEP interface — must be instantiated before any host (ExtendScript) calls const csInterface = new CSInterface(); // ============================================================================ // State Management // ============================================================================ const state = { serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434', isConnected: false, isConnecting: false, selectedAsset: null, assets: [], projects: [], selectedProject: 'all', searchQuery: '', currentPage: 0, pageSize: 50, totalAssets: 0, 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: '', }; // ============================================================================ // DOM Elements // ============================================================================ let elements = {}; function initDOMElements() { elements = { serverUrlInput: document.getElementById('server-url'), connectBtn: document.getElementById('connect-btn'), statusIndicator: document.getElementById('status-indicator'), searchInput: document.getElementById('search-input'), projectFilter: document.getElementById('project-filter'), assetGrid: document.getElementById('asset-grid'), emptyState: document.getElementById('empty-state'), detailsPanel: document.getElementById('details-panel'), detailsFilename: document.getElementById('details-filename'), detailsCodec: document.getElementById('details-codec'), detailsResolution: document.getElementById('details-resolution'), detailsFps: document.getElementById('details-fps'), detailsDuration: document.getElementById('details-duration'), 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'), mountLiveBtn: document.getElementById('mount-live-btn'), relinkBtn: document.getElementById('relink-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'), }; } // ============================================================================ // Thumbnail Lazy Loading (IntersectionObserver) // ============================================================================ const thumbObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; const assetId = img.dataset.assetId; if (assetId && !img.dataset.loaded) loadThumbnail(img, assetId); thumbObserver.unobserve(img); } }); }, { rootMargin: '100px' }); async function loadThumbnail(img, assetId) { if (state.thumbCache[assetId]) { img.src = state.thumbCache[assetId]; img.dataset.loaded = '1'; return; } try { const r = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/thumbnail`, { headers: { Accept: 'application/json' }, }); if (!r.ok) return; const { url } = await r.json(); if (!url) return; state.thumbCache[assetId] = url; img.src = url; img.dataset.loaded = '1'; } catch (_) { // thumbnail unavailable — leave placeholder } } // ============================================================================ // Initialization // ============================================================================ document.addEventListener('DOMContentLoaded', () => { initDOMElements(); setupEventListeners(); restoreSettings(); logMessage('Wild Dragon MAM panel initialized'); }); function setupEventListeners() { elements.serverUrlInput.addEventListener('change', (e) => { state.serverUrl = e.target.value.trim().replace(/\/$/, ''); localStorage.setItem('mam_server_url', state.serverUrl); state.thumbCache = {}; }); elements.connectBtn.addEventListener('click', connectToServer); elements.searchInput.addEventListener('input', debounce(handleSearch, 300)); 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.mountLiveBtn.addEventListener('click', mountLiveAsset); elements.relinkBtn.addEventListener('click', relinkSelectedAsset); elements.exportTimelineBtn.addEventListener('click', startExportTimeline); elements.exportConfirmBtn.addEventListener('click', confirmExportTimeline); elements.exportCancelBtn.addEventListener('click', cancelExportTimeline); elements.seqRefreshBtn.addEventListener('click', refreshCurrentSequenceInfo); } function restoreSettings() { elements.serverUrlInput.value = state.serverUrl; } // ============================================================================ // Server Connection // ============================================================================ async function connectToServer() { if (state.isConnecting) return; state.isConnecting = true; updateConnectionStatus('connecting'); elements.connectBtn.disabled = true; try { const response = await fetch(`${state.serverUrl}/api/v1/projects`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include', }); if (response.ok) { state.isConnected = true; updateConnectionStatus('connected'); elements.connectBtn.textContent = 'Reconnect'; logMessage('Connected to Wild Dragon MAM'); const projectData = await response.json(); await fetchProjects(projectData); await fetchAssets(); refreshCurrentSequenceInfo(); } else { throw new Error(`HTTP ${response.status}`); } } catch (error) { console.error('Connection error:', error); state.isConnected = false; updateConnectionStatus('disconnected'); elements.connectBtn.textContent = 'Connect'; showErrorMessage(`Failed to connect: ${error.message}`); } finally { state.isConnecting = false; elements.connectBtn.disabled = false; } } function updateConnectionStatus(status) { const indicator = elements.statusIndicator; indicator.classList.remove('connected', 'connecting'); if (status === 'connected') indicator.classList.add('connected'); else if (status === 'connecting') indicator.classList.add('connecting'); } // ============================================================================ // API Calls // ============================================================================ async function fetchProjects(preloadedData) { try { let projects; if (preloadedData) { projects = Array.isArray(preloadedData) ? preloadedData : []; } else { const response = await fetch(`${state.serverUrl}/api/v1/projects`, { headers: { Accept: 'application/json' }, credentials: 'include', }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); projects = Array.isArray(data) ? data : []; } state.projects = projects; const savedProject = state.selectedProject; elements.projectFilter.innerHTML = ''; state.projects.forEach((p) => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; elements.projectFilter.appendChild(opt); }); 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); } } async function fetchAssets(page = 0) { if (!state.isConnected) return; try { const params = new URLSearchParams({ offset: page * state.pageSize, limit: state.pageSize, }); if (state.searchQuery) params.append('search', state.searchQuery); if (state.selectedProject !== 'all') params.append('project_id', state.selectedProject); const response = await fetch( `${state.serverUrl}/api/v1/assets?${params.toString()}`, { headers: { Accept: 'application/json' }, credentials: 'include' } ); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); state.assets = data.assets || []; state.totalAssets = data.total || 0; state.currentPage = page; renderAssets(); logMessage(`Loaded ${state.assets.length} assets`); } catch (error) { console.error('Error fetching assets:', error); showErrorMessage('Failed to fetch assets'); } } async function fetchAssetDetails(assetId) { if (!state.isConnected) return null; try { const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}`, { headers: { Accept: 'application/json' }, credentials: 'include', }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error('Error fetching asset details:', error); return null; } } /** * 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`, { headers: { Accept: 'application/json' }, credentials: 'include', }); 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.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; } // ============================================================================ // UI Rendering // ============================================================================ function renderAssets() { elements.assetGrid.innerHTML = ''; if (state.assets.length === 0) { elements.emptyState.style.display = 'flex'; return; } elements.emptyState.style.display = 'none'; state.assets.forEach((asset) => { elements.assetGrid.appendChild(createAssetCard(asset)); }); } function createAssetCard(asset) { const card = document.createElement('div'); card.className = 'asset-card'; if (state.selectedAsset && state.selectedAsset.id === asset.id) { card.classList.add('selected'); } card.dataset.assetId = asset.id; // Thumbnail — lazy loaded via IntersectionObserver const thumbnail = document.createElement('div'); thumbnail.className = 'asset-thumbnail'; const img = document.createElement('img'); img.dataset.assetId = asset.id; img.alt = escapeHtml(asset.display_name || asset.filename || ''); img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; img.onerror = () => { img.style.display = 'none'; }; thumbnail.appendChild(img); thumbObserver.observe(img); card.appendChild(thumbnail); // Info row const info = document.createElement('div'); info.className = 'asset-info'; const name = asset.display_name || asset.filename || 'Untitled'; const filenameEl = document.createElement('div'); filenameEl.className = 'asset-filename'; filenameEl.title = name; filenameEl.textContent = name; info.appendChild(filenameEl); const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null; const codec = asset.codec || asset.media_type || 'video'; const meta = document.createElement('div'); meta.className = 'asset-meta'; meta.innerHTML = [ '' + (durationSec ? formatDuration(durationSec) : 'N/A') + '', '' + escapeHtml(codec.toUpperCase()) + '', ].join(''); info.appendChild(meta); const statusStr = asset.status || 'ready'; const statusBadge = document.createElement('div'); statusBadge.className = 'asset-status-badge status-badge ' + statusStr; statusBadge.textContent = statusStr.toUpperCase(); info.appendChild(statusBadge); card.appendChild(info); return card; } function showAssetDetails(asset) { state.selectedAsset = asset; elements.detailsPanel.classList.remove('hidden'); elements.detailsFilename.textContent = asset.display_name || asset.filename; elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown'; elements.detailsResolution.textContent = asset.resolution || 'N/A'; elements.detailsFps.textContent = asset.fps ? asset.fps + ' fps' : 'N/A'; elements.detailsDuration.textContent = asset.duration_ms ? formatDuration(asset.duration_ms / 1000) : 'N/A'; elements.detailsSize.textContent = asset.file_size ? formatFileSize(asset.file_size) : 'N/A'; elements.detailsTags.innerHTML = ''; const tags = asset.tags || []; if (tags.length > 0) { tags.forEach((tag) => { const el = document.createElement('span'); el.className = 'tag'; el.textContent = tag; elements.detailsTags.appendChild(el); }); } else { elements.detailsTags.innerHTML = 'No tags'; } var isLive = asset.status === 'live'; elements.importBtn.disabled = isLive; elements.importHiresBtn.disabled = isLive; elements.mountLiveBtn.disabled = !isLive; // The relink button only makes sense once the live file has been finalized // AND we have a record of it being mounted in this session. elements.relinkBtn.disabled = !(asset.status === 'ready' && state.importedAssets['live:' + asset.id]); } function hideAssetDetails() { state.selectedAsset = null; elements.detailsPanel.classList.add('hidden'); elements.importBtn.disabled = true; elements.importHiresBtn.disabled = true; elements.mountLiveBtn.disabled = true; elements.relinkBtn.disabled = true; } // ============================================================================ // Mount Live — open the growing file directly from the SMB share // ============================================================================ async function mountLiveAsset() { if (!state.selectedAsset) { showErrorMessage('No asset selected'); return; } var asset = state.selectedAsset; try { elements.mountLiveBtn.disabled = true; showProgress('Resolving SMB path…', 10); var res = await fetch(state.serverUrl + '/api/v1/assets/' + asset.id + '/live-path', { headers: { Accept: 'application/json' }, credentials: 'include', }); if (!res.ok) { var body = await res.json().catch(function () { return {}; }); throw new Error(body.error || ('HTTP ' + res.status)); } var info = await res.json(); // Premiere on Windows wants UNC paths with backslashes; on Mac // POSIX-style smb:// paths route through the Finder mount. var isMac = navigator.platform.indexOf('Mac') !== -1; var hostPath = isMac ? info.posix_path : info.win_path; showProgress('Importing live file…', 60); await importFileToPremiereProject(hostPath); // Remember this asset so the Relink button knows what to swap later. state.importedAssets['live:' + asset.id] = { assetId: asset.id, displayName: info.display_name, livePath: hostPath, }; try { localStorage.setItem('mam_imported_assets', JSON.stringify(state.importedAssets)); } catch (_) {} startLiveStatusPoll(asset.id); hideProgress(); showSuccessMessage('Mounted live: ' + info.display_name); } catch (err) { hideProgress(); showErrorMessage('Mount live failed: ' + err.message); } finally { elements.mountLiveBtn.disabled = !(state.selectedAsset && state.selectedAsset.status === 'live'); } } // Poll the asset until its status flips from 'live' to 'ready' so we can // surface the Relink button. 5s cadence — matches the worker's promotion // scan interval, so we typically catch the transition on the next tick. var _livePolls = {}; function startLiveStatusPoll(assetId) { if (_livePolls[assetId]) return; _livePolls[assetId] = setInterval(async function () { try { var r = await fetch(state.serverUrl + '/api/v1/assets/' + assetId, { headers: { Accept: 'application/json' }, credentials: 'include', }); if (!r.ok) return; var a = await r.json(); if (a.status === 'ready') { clearInterval(_livePolls[assetId]); delete _livePolls[assetId]; logMessage('Live asset ' + assetId + ' finalized — relink available'); if (state.selectedAsset && state.selectedAsset.id === assetId) { state.selectedAsset = a; showAssetDetails(a); } _showFlash('"' + (a.display_name || assetId) + '" finalized — click Relink to swap to hi-res', 'info-message'); } } catch (_) { /* transient — try again next tick */ } }, 5000); } async function relinkSelectedAsset() { if (!state.selectedAsset) return; var asset = state.selectedAsset; var entry = state.importedAssets['live:' + asset.id]; if (!entry) { showErrorMessage('No live mount recorded for this asset'); return; } try { elements.relinkBtn.disabled = true; showProgress('Fetching hi-res link…', 10); var hires = await getHiresDownloadInfo(asset.id); var safeName = sanitizeFilename(hires.filename || (asset.display_name || asset.id) + '.mov'); showProgress('Downloading hi-res' + (hires.file_size ? ' (' + formatFileSize(hires.file_size) + ')' : '') + '…', 20); var localPath = await downloadFile(hires.url, safeName); showProgress('Relinking in Premiere…', 85); await relinkInPremiere(entry.livePath, localPath); saveImportMapping(localPath, safeName, asset); hideProgress(); showSuccessMessage('Relinked to hi-res: ' + safeName); } catch (err) { hideProgress(); showErrorMessage('Relink failed: ' + err.message); } finally { elements.relinkBtn.disabled = false; } } // Walks every project item and swaps clip media paths matching `oldPath` // onto `newPath`. Premiere's ProjectItem.changeMediaPath() does the relink // without touching timeline placement. function relinkInPremiere(oldPath, newPath) { var oldEsc = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); var newEsc = newPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); var script = [ '(function () {', ' var out = { success: false, relinked: 0, message: "" };', ' try {', ' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }', ' var oldPath = "' + oldEsc + '";', ' var newPath = "' + newEsc + '";', ' function walk(item) {', ' for (var i = 0; i < item.children.numItems; i++) {', ' var c = item.children[i];', ' if (c.type === 1 || c.type === 2) {', /* clip or sub-clip */ ' if (c.getMediaPath() === oldPath) {', ' c.changeMediaPath(newPath);', ' out.relinked++;', ' }', ' }', ' if (c.children && c.children.numItems > 0) walk(c);', ' }', ' }', ' walk(app.project.rootItem);', ' out.success = out.relinked > 0;', ' out.message = out.relinked + " clip(s) relinked";', ' } catch (e) { out.message = e.message; }', ' return JSON.stringify(out);', '})();', ].join('\n'); return new Promise(function (resolve, reject) { csInterface.evalScript(script, function (resultStr) { try { var parsed = JSON.parse(resultStr); if (parsed.success) resolve(parsed); else reject(new Error(parsed.message || 'relink found no matching clips')); } catch (e) { reject(new Error('ExtendScript error: ' + resultStr)); } }); }); } // ============================================================================ // Search and Filter // ============================================================================ function handleSearch(e) { state.searchQuery = e.target.value; state.currentPage = 0; fetchAssets(); } function handleProjectFilter(e) { state.selectedProject = e.target.value; state.currentPage = 0; fetchAssets(); } // ============================================================================ // Import — Proxy // ============================================================================ async function importSelectedAsset() { if (!state.selectedAsset) { showErrorMessage('No asset selected'); return; } await importAsset(state.selectedAsset); } async function importAllAssets() { if (state.assets.length === 0) { showErrorMessage('No assets to import'); return; } for (const asset of state.assets) { await importAsset(asset); } showSuccessMessage('All assets imported'); } async function importAsset(asset) { try { elements.importBtn.disabled = true; // 1. Get proxy URL (absolute — /stream returns a relative path) showProgress('Getting download link...', 5); const url = await getSignedDownloadUrl(asset.id); // 2. Download proxy to OS temp dir via Node.js const safeName = sanitizeFilename( (asset.display_name || asset.filename || asset.id) + '.mp4' ); showProgress('Downloading ' + safeName + '...', 10); const filePath = await downloadFile(url, safeName); // 3. Hand the local path to Premiere Pro 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) { console.error('Import error:', error); hideProgress(); showErrorMessage('Import failed: ' + error.message); } finally { elements.importBtn.disabled = !state.selectedAsset; } } // ============================================================================ // 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. */ function downloadFile(url, filename) { return new Promise(function (resolve, reject) { try { var https = require('https'); var http = require('http'); var fs = require('fs'); var path = require('path'); var os = require('os'); var tempPath = path.join(os.tmpdir(), filename); var file = fs.createWriteStream(tempPath); var protocol = url.startsWith('https') ? https : http; protocol.get(url, function (res) { if (res.statusCode !== 200) { file.close(); fs.unlink(tempPath, function () {}); reject(new Error('Download HTTP ' + res.statusCode)); return; } var total = parseInt(res.headers['content-length'] || '0', 10); var received = 0; res.on('data', function (chunk) { received += chunk.length; if (total > 0) { state.downloadProgress = 10 + (received / total) * 75; updateProgressUI(); } }); res.pipe(file); file.on('finish', function () { file.close(function () { resolve(tempPath); }); }); file.on('error', function (err) { fs.unlink(tempPath, function () {}); reject(err); }); }).on('error', function (err) { fs.unlink(tempPath, function () {}); reject(err); }); } catch (err) { reject(new Error('Node.js unavailable for download: ' + err.message)); } }); } /** * Calls csInterface.evalScript to import a local file into the open Premiere project. */ function importFileToPremiereProject(filePath) { return new Promise(function (resolve, reject) { var safePath = filePath.replace(/\\/g, '\\\\'); var script = [ '(function() {', ' var result = { success: false, message: "" };', ' try {', ' if (!app.project) {', ' result.message = "No active Premiere Pro project";', ' return JSON.stringify(result);', ' }', ' var f = new File("' + safePath + '");', ' if (!f.exists) {', ' result.message = "File not found: ' + safePath + '";', ' return JSON.stringify(result);', ' }', ' app.project.importFiles(["' + safePath + '"]);', ' result.success = true;', ' result.message = "Imported successfully";', ' } catch (e) {', ' result.message = e.message;', ' }', ' return JSON.stringify(result);', '})();', ].join('\n'); csInterface.evalScript(script, function (resultStr) { try { var parsed = JSON.parse(resultStr); if (parsed.success) resolve(parsed); else reject(new Error(parsed.message)); } catch (e) { reject(new Error('ExtendScript error: ' + resultStr)); } }); }); } // ============================================================================ // Utility Functions // ============================================================================ function debounce(func, delay) { var timeout; return function () { var args = arguments; clearTimeout(timeout); timeout = setTimeout(function () { func.apply(null, args); }, delay); }; } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function sanitizeFilename(name) { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); } function formatDuration(seconds) { if (!seconds) return 'N/A'; var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0'); } function formatFileSize(bytes) { if (!bytes) return '0 B'; var k = 1024; var sizes = ['B', 'KB', 'MB', 'GB', 'TB']; var i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }