diff --git a/services/premiere-plugin/js/main.js b/services/premiere-plugin/js/main.js index e69de29..45125e6 100644 --- a/services/premiere-plugin/js/main.js +++ b/services/premiere-plugin/js/main.js @@ -0,0 +1,2016 @@ +/** + * 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; + + 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)); + } + }); +} + +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]; +}