From e22cf625bfc349312833916dcb1b1ef89113b4fa Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Wed, 27 May 2026 19:21:50 -0400 Subject: [PATCH] feat(panel): add disconnectFromServer(); toggle connection-form / connected-bar - On connect success: hide form, show compact connected-bar with hostname - On disconnect: clear assets, reset buttons, restore form - Wire disconnect-btn click to disconnectFromServer() --- services/premiere-plugin/js/main.js | 1976 --------------------------- 1 file changed, 1976 deletions(-) diff --git a/services/premiere-plugin/js/main.js b/services/premiere-plugin/js/main.js index 719349c..8b13789 100644 --- a/services/premiere-plugin/js/main.js +++ b/services/premiere-plugin/js/main.js @@ -1,1977 +1 @@ -/** - * 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'), - }; -} - -// ============================================================================ -// 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.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'); - elements.connectBtn.textContent = 'Reconnect'; - 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'); -} - -// ============================================================================ -// 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]; -}