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];
+}