/** * 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 }; // ============================================================================ // 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'), importAllBtn: document.getElementById('import-all-btn'), progressContainer: document.getElementById('progress-container'), progressLabel: document.getElementById('progress-label'), progressFill: document.getElementById('progress-fill'), }; } // ============================================================================ // 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.importAllBtn.addEventListener('click', importAllAssets); } 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 { // Use the projects endpoint as a connectivity check 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(); } 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; 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 a short-lived presigned URL for the H.264 proxy of the given asset. * GET /api/v1/assets/:id/stream -> { 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; } // ============================================================================ // 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; // 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'; 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'); // 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'; 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'; } elements.importBtn.disabled = false; } function hideAssetDetails() { state.selectedAsset = null; elements.detailsPanel.classList.add('hidden'); elements.importBtn.disabled = true; } // ============================================================================ // 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 Functionality // ============================================================================ 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 a presigned proxy URL 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); hideProgress(); showSuccessMessage('Imported: ' + safeName); } catch (error) { console.error('Import error:', error); hideProgress(); showErrorMessage('Import failed: ' + error.message); } finally { elements.importBtn.disabled = !state.selectedAsset; } } /** * 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)); } }); }); } // ============================================================================ // 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 // ============================================================================ 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]; }