/** * Wild Dragon MAM - Premiere Pro Panel * Main JavaScript file for the CEP panel * Features: #30 FCP XML Export & Conform, #31 Hi-Res Auto-Relink, #32 GUI Redesign */ // ============================================================================ // State Management // ============================================================================ const state = { serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434', apiToken: localStorage.getItem('mam_api_token') || '', isConnected: false, isConnecting: false, selectedAsset: null, assets: [], projects: [], selectedProject: 'all', searchQuery: '', currentPage: 0, pageSize: 50, totalAssets: 0, downloadProgress: 0, isDownloading: false, thumbCache: {}, importedAssets: JSON.parse(localStorage.getItem('mam_imported_assets') || '{}'), exportPanelVisible: false, currentSequenceName: '', // Advanced features state conformPanelVisible: false, relinkPanelVisible: false, selectedPreset: 'broadcast', timelineData: null, relinkClips: [], conformJobId: null, conformPollTimer: null, // Tabs state currentTab: 'library', growingAssets: [], growingPollInterval: null, }; // ============================================================================ // DOM Elements // ============================================================================ let elements = {}; function initDOMElements() { elements = { serverUrlInput: document.getElementById('server-url'), apiTokenInput: document.getElementById('api-token'), 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'), 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'), seqInfoBar: document.getElementById('seq-info-bar'), seqInfoName: document.getElementById('seq-info-name'), seqRefreshBtn: document.getElementById('seq-refresh-btn'), // Tabs tabLibrary: document.getElementById('tab-library'), tabGrowing: document.getElementById('tab-growing'), growingCount: document.getElementById('growing-count'), libraryContainer: document.getElementById('library-container'), growingContainer: document.getElementById('growing-container'), growingGrid: document.getElementById('growing-grid'), growingEmptyState: document.getElementById('growing-empty-state'), // Advanced: Conform panel exportConformBtn: document.getElementById('export-conform-btn'), exportConformOverlay: document.getElementById('export-conform-overlay'), exportConformPanel: document.getElementById('export-conform-panel'), exportConformCloseBtn: document.getElementById('export-conform-close-btn'), exportConformCancelBtn:document.getElementById('export-conform-cancel-btn'), exportConformStartBtn: document.getElementById('export-conform-start-btn'), presetCards: document.getElementById('preset-cards'), conformCodec: document.getElementById('conform-codec'), conformQuality: document.getElementById('conform-quality'), conformResolution: document.getElementById('conform-resolution'), conformAudio: document.getElementById('conform-audio'), conformClipInfo: document.getElementById('conform-clip-info'), // Advanced: Relink panel fetchRelinkBtn: document.getElementById('fetch-relink-btn'), relinkOverlay: document.getElementById('relink-overlay'), relinkPanel: document.getElementById('relink-panel'), relinkCloseBtn: document.getElementById('relink-close-btn'), relinkCancelBtn: document.getElementById('relink-cancel-btn'), relinkStartBtn: document.getElementById('relink-start-btn'), clipList: document.getElementById('clip-list'), relinkSummary: document.getElementById('relink-summary'), relinkSummaryText: document.getElementById('relink-summary-text'), relinkSummaryDetail: document.getElementById('relink-summary-detail'), // Connection UI toggle connectionForm: document.getElementById('connection-form'), connectedBar: document.getElementById('connected-bar'), disconnectBtn: document.getElementById('disconnect-btn'), connectedHost: document.getElementById('connected-host'), }; } // ============================================================================ // Thumbnail Lazy Loading // ============================================================================ 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 (_) {} } // ============================================================================ // Initialization // ============================================================================ document.addEventListener('DOMContentLoaded', () => { initDOMElements(); setupEventListeners(); restoreSettings(); logMessage('Wild Dragon MAM panel initialized'); }); function setupEventListeners() { elements.serverUrlInput.addEventListener('input', (e) => { state.serverUrl = e.target.value.trim().replace(/\/$/, ''); localStorage.setItem('mam_server_url', state.serverUrl); state.thumbCache = {}; }); elements.apiTokenInput.addEventListener('input', (e) => { state.apiToken = e.target.value.trim(); localStorage.setItem('mam_api_token', state.apiToken); }); elements.connectBtn.addEventListener('click', connectToServer); elements.disconnectBtn.addEventListener('click', disconnectFromServer); 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); // Tabs elements.tabLibrary.addEventListener('click', () => switchTab('library')); elements.tabGrowing.addEventListener('click', () => switchTab('growing')); elements.growingGrid.addEventListener('click', handleAssetClick); // Advanced: Conform panel elements.exportConformBtn.addEventListener('click', showAdvancedExportPanel); elements.exportConformCloseBtn.addEventListener('click', hideAdvancedExportPanel); elements.exportConformCancelBtn.addEventListener('click', hideAdvancedExportPanel); elements.exportConformStartBtn.addEventListener('click', startConformFromPanel); elements.presetCards.addEventListener('click', handlePresetSelection); // Advanced: Relink panel elements.fetchRelinkBtn.addEventListener('click', fetchAndRelinkAll); elements.relinkCloseBtn.addEventListener('click', hideRelinkPanel); elements.relinkCancelBtn.addEventListener('click', hideRelinkPanel); elements.relinkStartBtn.addEventListener('click', startBatchRelink); } function restoreSettings() { state.serverUrl = state.serverUrl.replace(/\/+$/, ''); localStorage.setItem('mam_server_url', state.serverUrl); elements.serverUrlInput.value = state.serverUrl; elements.apiTokenInput.value = state.apiToken; } // ============================================================================ // Auth: inject Bearer token // ============================================================================ const _originalFetch = window.fetch.bind(window); window.fetch = function (input, init) { init = init || {}; const url = typeof input === 'string' ? input : (input && input.url) || ''; if (state.apiToken && state.serverUrl && url.startsWith(state.serverUrl)) { const headers = new Headers(init.headers || {}); if (!headers.has('Authorization')) { headers.set('Authorization', 'Bearer ' + state.apiToken); } init.headers = headers; } return _originalFetch(input, init); }; // ============================================================================ // 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'); try { elements.connectedHost.textContent = new URL(state.serverUrl).hostname; } catch (_) { elements.connectedHost.textContent = state.serverUrl; } elements.connectionForm.classList.add('hidden'); elements.connectedBar.classList.remove('hidden'); logMessage('Connected to Wild Dragon MAM'); const projectData = await response.json(); await fetchProjects(projectData); await fetchAssets(); refreshCurrentSequenceInfo(); startGrowingPoll(); } 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}`); stopGrowingPoll(); } 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'); } function disconnectFromServer() { state.isConnected = false; state.assets = []; state.growingAssets = []; state.selectedAsset = null; stopGrowingPoll(); updateConnectionStatus('disconnected'); // Clear grids back to empty state elements.assetGrid.innerHTML = ''; elements.growingGrid.innerHTML = ''; elements.assetGrid.appendChild(elements.emptyState); elements.growingGrid.appendChild(elements.growingEmptyState); // Hide secondary panels elements.detailsPanel.classList.add('hidden'); elements.seqInfoBar.classList.add('hidden'); // Disable action buttons [ elements.importBtn, elements.importHiresBtn, elements.mountLiveBtn, elements.relinkBtn, elements.exportConformBtn, elements.fetchRelinkBtn, ].forEach(btn => { btn.disabled = true; }); // Swap connection UI elements.connectedBar.classList.add('hidden'); elements.connectionForm.classList.remove('hidden'); } // ============================================================================ // 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; 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; } } 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; } 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; 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); 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); let durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null; if (asset.status === 'live' && asset.created_at) { durationSec = Math.floor((Date.now() - Date.parse(asset.created_at)) / 1000); } const codec = asset.codec || asset.media_type || 'video'; const meta = document.createElement('div'); meta.className = 'asset-meta'; meta.innerHTML = [ '' + (durationSec ? formatDuration(durationSec) : 'LIVE') + '', '' + 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; 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; } // ============================================================================ // Tabs and Growing Assets Polling // ============================================================================ function switchTab(tabName) { if (!state.isConnected) return; state.currentTab = tabName; elements.tabLibrary.classList.toggle('active', tabName === 'library'); elements.tabGrowing.classList.toggle('active', tabName === 'growing'); elements.libraryContainer.classList.toggle('hidden', tabName !== 'library'); elements.growingContainer.classList.toggle('hidden', tabName !== 'growing'); hideAssetDetails(); if (tabName === 'library') { fetchAssets(); } else { pollGrowingAssets(); } } function startGrowingPoll() { stopGrowingPoll(); pollGrowingAssets(); state.growingPollInterval = setInterval(pollGrowingAssets, 5000); } function stopGrowingPoll() { if (state.growingPollInterval) { clearInterval(state.growingPollInterval); state.growingPollInterval = null; } } async function pollGrowingAssets() { if (!state.isConnected) return; try { const params = new URLSearchParams({ limit: 100 }); if (state.selectedProject !== 'all') { params.append('project_id', state.selectedProject); } if (state.searchQuery) { params.append('search', state.searchQuery); } const response = await fetch(`${state.serverUrl}/api/v1/assets?${params.toString()}`, { headers: { Accept: 'application/json' }, credentials: 'include' }); if (!response.ok) return; const data = await response.json(); const allAssets = data.assets || []; state.growingAssets = allAssets.filter(a => a.status === 'live' || a.status === 'ingesting' || a.status === 'processing' || (a.status === 'ready' && state.importedAssets['live:' + a.id]) ); const count = state.growingAssets.filter(a => a.status === 'live' || a.status === 'ingesting' || a.status === 'processing').length; elements.growingCount.textContent = count; elements.growingCount.style.display = count > 0 ? 'inline-block' : 'none'; if (state.currentTab === 'growing') { renderGrowingAssets(); if (state.selectedAsset) { const updatedSelected = state.growingAssets.find(a => a.id === state.selectedAsset.id); if (updatedSelected) { showAssetDetails(updatedSelected); } else { const latest = await fetchAssetDetails(state.selectedAsset.id); if (latest) { showAssetDetails(latest); } } } } } catch (error) { console.error('Error polling growing assets:', error); } } function renderGrowingAssets() { elements.growingGrid.innerHTML = ''; if (state.growingAssets.length === 0) { elements.growingEmptyState.style.display = 'flex'; return; } elements.growingEmptyState.style.display = 'none'; state.growingAssets.forEach((asset) => { elements.growingGrid.appendChild(createAssetCard(asset)); }); } // ============================================================================ // Mount Live // ============================================================================ 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(); var isMac = navigator.platform.indexOf('Mac') !== -1; var hostPath = isMac ? info.posix_path : info.win_path; showProgress('Importing live file…', 60); await importFileToPremiereProject(hostPath); 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'); } } 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 (_) {} }, 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; } } 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) {', ' 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; if (state.currentTab === 'library') { fetchAssets(); } else { pollGrowingAssets(); } } function handleProjectFilter(e) { state.selectedProject = e.target.value; state.currentPage = 0; if (state.currentTab === 'library') { fetchAssets(); } else { pollGrowingAssets(); } } // ============================================================================ // 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; showProgress('Getting download link...', 5); const url = await getSignedDownloadUrl(asset.id); const safeName = sanitizeFilename( (asset.display_name || asset.filename || asset.id) + '.mp4' ); showProgress('Downloading ' + safeName + '...', 10); const filePath = await downloadFile(url, safeName); showProgress('Importing into Premiere Pro...', 85); await importFileToPremiereProject(filePath); 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; 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' ); showProgress('Downloading hi-res' + sizeNote + '...', 10); const filePath = await downloadFile(hiresInfo.url, safeName); showProgress('Importing hi-res into Premiere Pro...', 85); await importFileToPremiereProject(filePath); 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; } } 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 (Push Timeline to MAM) // ============================================================================ 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(--status-red)' : 'var(--text-secondary)'; 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 // ============================================================================ async function startExportTimeline() { if (!state.isConnected) { showErrorMessage('Connect to MAM first'); return; } 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); } 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(); } 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 }); }); } 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; } // ============================================================================ // #30 — FCP XML Export & Conform // ============================================================================ async function showAdvancedExportPanel() { if (!state.isConnected) { showErrorMessage('Connect to MAM first'); return; } if (state.conformPanelVisible) { hideAdvancedExportPanel(); 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; } state.timelineData = timelineData; // Update clip info in panel var totalClips = timelineData.clips.length; var matchedClips = resolveClipsToAssets(timelineData.clips).filter(function (c) { return c.asset_id; }).length; elements.conformClipInfo.textContent = matchedClips + ' of ' + totalClips + ' clip(s) matched'; // Enable start button only if some clips are matched elements.exportConformStartBtn.disabled = matchedClips === 0; // Apply preset defaults applyPreset(state.selectedPreset); openSlidePanel( elements.exportConformOverlay, elements.exportConformPanel ); state.conformPanelVisible = true; } function hideAdvancedExportPanel() { closeSlidePanel( elements.exportConformOverlay, elements.exportConformPanel ); state.conformPanelVisible = false; if (state.conformPollTimer) { clearInterval(state.conformPollTimer); state.conformPollTimer = null; } } function handlePresetSelection(e) { var card = e.target.closest('.preset-card'); if (!card) return; document.querySelectorAll('.preset-card').forEach(function (el) { el.classList.remove('selected'); }); card.classList.add('selected'); var preset = card.dataset.preset; state.selectedPreset = preset; applyPreset(preset); } function applyPreset(preset) { switch (preset) { case 'broadcast': elements.conformCodec.value = 'prores_hq'; elements.conformQuality.value = 'high'; elements.conformResolution.value = '1080p'; elements.conformAudio.value = 'broadcast'; break; case 'web': elements.conformCodec.value = 'h264'; elements.conformQuality.value = 'medium'; elements.conformResolution.value = '1080p'; elements.conformAudio.value = 'web'; break; case 'archive': elements.conformCodec.value = 'prores_4444'; elements.conformQuality.value = 'high'; elements.conformResolution.value = 'uhd'; elements.conformAudio.value = 'archive'; break; case 'custom': // Leave current selections as-is break; } } async function startConformFromPanel() { if (!state.timelineData) { showErrorMessage('No timeline data — re-open the panel'); return; } elements.exportConformStartBtn.disabled = true; showProgress('Generating FCP XML...', 10); try { var fcpXml = generateFcpXml(state.timelineData); var codec = elements.conformCodec.value; var quality = elements.conformQuality.value; var resolution = elements.conformResolution.value; var audio = elements.conformAudio.value; showProgress('Starting conform job...', 30); var job = await startConformJob(fcpXml, codec, quality, resolution, audio); hideAdvancedExportPanel(); showProgress('Conform job started — polling...', 40); state.conformJobId = job.jobId; pollConformProgress(job.jobId); } catch (err) { hideProgress(); showErrorMessage('Conform failed: ' + err.message); elements.exportConformStartBtn.disabled = false; } } function generateFcpXml(timelineData) { var seqName = escapeXml(timelineData.sequenceName || 'Sequence 1'); var frameRate = timelineData.frameRate || 29.97; var width = timelineData.width || 1920; var height = timelineData.height || 1080; var clips = timelineData.clips || []; // Calculate total duration from clips var totalFrames = 0; clips.forEach(function (c) { var end = c.timelineOutFrames || 0; if (end > totalFrames) totalFrames = end; }); if (totalFrames < 1) totalFrames = 100; var duration = timecodeFromFrames(totalFrames, frameRate); var frameRateStr = formatFrameRate(frameRate); var xml = '\n'; xml += '\n'; xml += '\n'; xml += ' \n'; // Build a resource for each unique source var seen = {}; var resourceId = 1; clips.forEach(function (clip) { var key = clip.filePath || clip.fileName || 'clip_' + resourceId; if (!seen[key]) { seen[key] = true; var name = escapeXml(clip.fileName || 'Clip ' + resourceId); var path = escapeXml(clip.filePath || ''); var srcDur = timecodeFromFrames( (clip.sourceOutFrames || 100) - (clip.sourceInFrames || 0), frameRate ); xml += ' \n'; resourceId++; } }); xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; // Resolve clip paths to asset IDs and build track layout var resolvedClips = resolveClipsToAssets(clips); var trackGroups = {}; resolvedClips.forEach(function (clip) { var track = clip.trackIndex !== undefined ? clip.trackIndex : 0; if (!trackGroups[track]) trackGroups[track] = []; trackGroups[track].push(clip); }); var trackKeys = Object.keys(trackGroups).sort(); if (trackKeys.length === 0) trackKeys = ['0']; trackKeys.forEach(function (trackKey) { var trackClips = trackGroups[trackKey]; trackClips.forEach(function (clip) { var name = escapeXml(clip.fileName || 'Clip'); var tcStart = timecodeFromFrames(clip.timelineInFrames || 0, frameRate); var dur = timecodeFromFrames( (clip.timelineOutFrames || clip.sourceOutFrames || 100) - (clip.timelineInFrames || 0), frameRate ); var srcStart = timecodeFromFrames(clip.sourceInFrames || 0, frameRate); // Find the resource ID var rid = 1; var seen2 = {}; var count = 0; resolvedClips.forEach(function (c) { var key = c.filePath || c.fileName || 'clip_'; if (!seen2[key]) { seen2[key] = true; count++; if (key === (clip.filePath || clip.fileName || '')) { rid = count; } } }); xml += ' \n'; }); }); xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += ' \n'; xml += '\n'; return xml; } function escapeXml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatFrameRate(fps) { // FCP XML uses rational time scale: e.g. 100/2997 for 29.97fps var tolerance = 0.001; if (Math.abs(fps - 23.976) < tolerance || Math.abs(fps - 23.98) < tolerance) return '1001/24000'; if (Math.abs(fps - 24) < tolerance) return '1/24'; if (Math.abs(fps - 25) < tolerance) return '1/25'; if (Math.abs(fps - 29.97) < tolerance) return '1001/30000'; if (Math.abs(fps - 30) < tolerance) return '1/30'; if (Math.abs(fps - 50) < tolerance) return '1/50'; if (Math.abs(fps - 59.94) < tolerance) return '1001/60000'; if (Math.abs(fps - 60) < tolerance) return '1/60'; return '1001/30000'; // default to 29.97 } function timecodeFromFrames(frames, fps) { if (fps <= 0) fps = 29.97; var totalSeconds = frames / fps; var h = Math.floor(totalSeconds / 3600); var m = Math.floor((totalSeconds % 3600) / 60); var s = Math.floor(totalSeconds % 60); var f = Math.round((totalSeconds - Math.floor(totalSeconds)) * fps); return pad2(h) + ':' + pad2(m) + ':' + pad2(s) + ':' + pad2(f); } function pad2(num) { return (num < 10 ? '0' : '') + num; } async function startConformJob(fcpXml, codec, quality, resolution, audio) { // Normalize codec value for the worker var workerCodec = codec; if (codec === 'prores_hq' || codec === 'prores_4444') workerCodec = 'prores'; // Normalize resolution value for the worker var workerResolution = resolution; if (resolution === 'uhd') workerResolution = '3840x2160'; else if (resolution === '1080p') workerResolution = '1920x1080'; else if (resolution === '720p') workerResolution = '1280x720'; // Create or find the sequence in MAM (same flow as export timeline) var timelineData = state.timelineData; var seqName = (timelineData && timelineData.sequenceName) || 'Conformed Sequence'; var frameRate = (timelineData && timelineData.frameRate) || 29.97; var width = (timelineData && timelineData.width) || 1920; var height = (timelineData && timelineData.height) || 1080; var seqId = await upsertSequence( state.selectedProject, seqName, frameRate, width, height ); var response = await fetch(state.serverUrl + '/api/v1/sequences/' + seqId + '/conform', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'include', body: JSON.stringify({ fcp_xml: fcpXml, codec: workerCodec, quality: quality, resolution: workerResolution, audio: audio, }), }); if (!response.ok) { var errBody = {}; try { errBody = await response.json(); } catch (_) {} throw new Error(errBody.error || ('HTTP ' + response.status)); } return await response.json(); } function pollConformProgress(jobId) { // jobId is in format "conform:123" — matches GET /api/v1/jobs/:id var pollInterval = setInterval(async function () { try { var response = await fetch( state.serverUrl + '/api/v1/jobs/' + encodeURIComponent(jobId), { headers: { Accept: 'application/json' }, credentials: 'include' } ); if (!response.ok) { clearInterval(pollInterval); hideProgress(); showErrorMessage('Conform job status check failed'); return; } var status = await response.json(); var apiStatus = status.status || 'waiting'; if (status.progress !== undefined) { showProgress('Conforming... ' + status.progress + '%', status.progress); } if (apiStatus === 'completed') { clearInterval(pollInterval); hideProgress(); showSuccessMessage('Conform complete! Asset ready in MAM.'); state.conformPollTimer = null; // Refresh assets to show the new conformed asset fetchAssets(); } else if (apiStatus === 'failed') { clearInterval(pollInterval); hideProgress(); showErrorMessage('Conform failed: ' + (status.error || 'Unknown error')); state.conformPollTimer = null; } } catch (err) { // Transient error — keep polling } }, 2000); state.conformPollTimer = pollInterval; } // ============================================================================ // #31 — Hi-Res Auto-Relink // ============================================================================ async function fetchAndRelinkAll() { if (!state.isConnected) { showErrorMessage('Connect to MAM first'); return; } if (state.relinkPanelVisible) { hideRelinkPanel(); return; } showProgress('Reading Premiere timeline...', 20); const timelineData = await new Promise(function (resolve) { // Use the enhanced function that includes clipInstanceId csInterface.evalScript('exportTimelineDataWithIds()', 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; } // Resolve clips to MAM assets var resolved = resolveClipsToAssets(timelineData.clips); var matched = resolved.filter(function (c) { return c.asset_id; }); if (matched.length === 0) { showErrorMessage('No timeline clips matched MAM assets — import them first'); return; } state.timelineData = timelineData; state.relinkClips = resolved; showClipSelection(resolved); } function showClipSelection(clips) { elements.clipList.innerHTML = ''; clips.forEach(function (clip, index) { var item = document.createElement('div'); item.className = 'clip-list-item'; item.dataset.index = index; var checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'clip-list-item-checkbox'; checkbox.checked = !!clip.asset_id; checkbox.disabled = !clip.asset_id; var info = document.createElement('div'); info.className = 'clip-list-item-info'; var name = document.createElement('div'); name.className = 'clip-list-item-name'; name.textContent = clip.fileName || 'Unknown clip'; var meta = document.createElement('div'); meta.className = 'clip-list-item-meta'; meta.textContent = clip.asset_id ? 'Track ' + (clip.trackIndex + 1) + ' | MAM asset: ' + clip.asset_id : 'Track ' + (clip.trackIndex + 1) + ' | Not matched'; info.appendChild(name); info.appendChild(meta); var status = document.createElement('div'); status.className = 'clip-list-item-status ' + (clip.asset_id ? 'matched' : 'unmatched'); status.textContent = clip.asset_id ? 'Matched' : 'Unmatched'; item.appendChild(checkbox); item.appendChild(info); item.appendChild(status); elements.clipList.appendChild(item); }); elements.relinkSummary.classList.add('hidden'); elements.relinkStartBtn.disabled = false; openSlidePanel( elements.relinkOverlay, elements.relinkPanel ); state.relinkPanelVisible = true; } function hideRelinkPanel() { closeSlidePanel( elements.relinkOverlay, elements.relinkPanel ); state.relinkPanelVisible = false; state.relinkClips = []; } async function startBatchRelink() { var checkboxes = elements.clipList.querySelectorAll('.clip-list-item-checkbox'); var selectedClips = []; checkboxes.forEach(function (cb, index) { if (cb.checked && !cb.disabled) { var clip = state.relinkClips[index]; if (clip && clip.asset_id) { selectedClips.push(clip); } } }); if (selectedClips.length === 0) { showErrorMessage('No clips selected for relink'); return; } elements.relinkStartBtn.disabled = true; showProgress('Requesting batch trim for ' + selectedClips.length + ' clip(s)...', 10); try { var segments = await requestBatchTrim(selectedClips); showProgress('Downloading ' + segments.length + ' segment(s)...', 30); var results = await downloadAndRelink(segments); hideProgress(); showRelinkSummary(results); } catch (err) { hideProgress(); showErrorMessage('Batch relink failed: ' + err.message); elements.relinkStartBtn.disabled = false; } } async function requestBatchTrim(clips) { var payload = clips.map(function (clip) { return { assetId: clip.asset_id, filename: clip.fileName || 'clip.mxf', trackIndex: clip.trackIndex || 0, sourceInFrames: clip.sourceInFrames || 0, sourceOutFrames: clip.sourceOutFrames || 0, timelineInFrames: clip.timelineInFrames || 0, timelineOutFrames: clip.timelineOutFrames || 0, }; }); var response = await fetch(state.serverUrl + '/api/v1/assets/batch-trim', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'include', body: JSON.stringify({ clips: payload }), }); if (!response.ok) { var errBody = {}; try { errBody = await response.json(); } catch (_) {} throw new Error(errBody.error || ('HTTP ' + response.status)); } var data = await response.json(); return data.clips || []; } async function downloadAndRelink(segments) { var results = []; for (var i = 0; i < segments.length; i++) { var seg = segments[i]; try { showProgress( 'Getting signed URL for segment ' + (i + 1) + ' of ' + segments.length + '...', 25 + ((i / segments.length) * 50) ); // Fetch signed URL for the temp segment var urlRes = await fetch( state.serverUrl + '/api/v1/assets/temp-segment-url/' + encodeURIComponent(seg.clipInstanceId), { headers: { Accept: 'application/json' }, credentials: 'include' } ); if (!urlRes.ok) throw new Error('Segment not ready: HTTP ' + urlRes.status); var urlData = await urlRes.json(); var safeName = sanitizeFilename('segment_' + seg.clipInstanceId + '.mov'); var localPath = await downloadFile(urlData.url, safeName); showProgress('Relinking segment ' + (i + 1) + ' in Premiere...', 85); var relinkResult = await relinkClipToNewMedia(seg.clipInstanceId, localPath); results.push({ clipName: seg.clipInstanceId || safeName, success: true, localPath: localPath, message: relinkResult.message || 'Relinked', }); } catch (err) { results.push({ clipName: seg.clipInstanceId || 'Unknown', success: false, localPath: null, message: err.message, }); } } return results; } function relinkClipToNewMedia(clipId, filePath) { var safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); var script = [ '(function () {', ' var out = { success: false, message: "" };', ' try {', ' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }', ' var newPath = "' + safePath + '";', ' function walk(item) {', ' for (var i = 0; i < item.children.numItems; i++) {', ' var c = item.children[i];', ' if (c.type === 1 || c.type === 2) {', ' try {', ' c.changeMediaPath(newPath);', ' out.success = true;', ' out.message = "Relinked: " + c.name;', ' return;', ' } catch (e) {}', ' }', ' if (c.children && c.children.numItems > 0) walk(c);', ' }', ' }', ' walk(app.project.rootItem);', ' if (!out.success) out.message = "No matching clip found for relink";', ' } 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); resolve(parsed); } catch (e) { reject(new Error('ExtendScript error: ' + resultStr)); } }); }); } function showRelinkSummary(results) { var succeeded = results.filter(function (r) { return r.success; }).length; var failed = results.filter(function (r) { return !r.success; }).length; elements.relinkSummary.classList.remove('hidden'); elements.relinkSummaryText.textContent = succeeded + ' of ' + results.length + ' clip(s) relinked to hi-res'; var detailParts = []; if (succeeded > 0) detailParts.push(succeeded + ' succeeded'); if (failed > 0) detailParts.push(failed + ' failed'); elements.relinkSummaryDetail.textContent = detailParts.join(', ') || 'No clips processed'; elements.relinkStartBtn.disabled = false; _showFlash(results.length + ' clip(s) processed — ' + succeeded + ' relinked, ' + failed + ' failed', 'info-message'); } // ============================================================================ // #32 — Slide Panel Management // ============================================================================ function openSlidePanel(overlay, panel) { overlay.classList.add('open'); panel.classList.add('open'); } function closeSlidePanel(overlay, panel) { overlay.classList.remove('open'); panel.classList.remove('open'); } // ============================================================================ // 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; }) || state.growingAssets.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 // ============================================================================ 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; // Native http.get bypasses window.fetch, so the Bearer // interceptor never fires. Inject Authorization manually for // same-server URLs (proxy /video etc.); off-host presigned URLs // don't need it. var reqOpts = url; if (state.apiToken && state.serverUrl && url.startsWith(state.serverUrl)) { try { var p = new URL(url); reqOpts = { hostname: p.hostname, port: p.port || (p.protocol === 'https:' ? 443 : 80), path: p.pathname + (p.search || ''), headers: { 'Authorization': 'Bearer ' + state.apiToken }, }; } catch (_) { /* fall back to url string */ } } var req = protocol.get(reqOpts, 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); }); }); // Without a timeout an unreachable host or a CEP-Node TLS hiccup // makes this Promise sit forever. 15s covers slow LTE connect + // handshake; once bytes flow there's no cap. req.setTimeout(15000, function () { var host = (typeof reqOpts === 'string') ? reqOpts : reqOpts.hostname; req.destroy(new Error('Download timed out after 15s (no response from ' + host + ')')); }); req.on('error', function (err) { fs.unlink(tempPath, function () {}); reject(err); }); } catch (err) { reject(new Error('Node.js unavailable for download: ' + err.message)); } }); } function importFileToPremiereProject(filePath) { return new Promise(function (resolve, reject) { var safePath = filePath.replace(/\\/g, '\\\\'); // Premiere can deadlock on importFiles() if a hidden prompt appears // (off-screen modal, etc.). The evalScript callback then never fires. // Race the import against a 60s timeout so the user sees an error // instead of a frozen spinner. var done = false; var timer = setTimeout(function () { if (done) return; done = true; reject(new Error('ExtendScript importFiles() did not return within 60s — Premiere may have a hidden dialog')); }, 60000); var script = [ '(function() {', ' var result = { success: false, message: "" };', ' try {', ' var lf = new File("C:/Users/Administrator/Documents/df-import-log.txt");', ' var opened = lf.open("a");', ' result.message = "opened=" + opened + " fsName=" + lf.fsName + " err=" + (lf.error || "none");', ' if (opened) { lf.writeln((new Date()).toString() + " v1.2.5 noop probe"); lf.close(); }', ' /* keep success=false so the message surfaces in the panel popup */', ' } catch (e) { result.message = "caught: " + e.message; }', ' return JSON.stringify(result);', '})();', ].join('\n'); csInterface.evalScript(script, function (resultStr) { if (done) return; done = true; clearTimeout(timer); 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]; }