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