app.project.importFiles() can deadlock if a hidden Premiere modal appears (off-screen, behind window, etc) — the evalScript callback never fires and the panel spinner hangs forever. Two changes: 1) Pass suppressUI=true to all five importFiles call sites (main.js inline IIFE + 4 in premiere.jsx). Premiere proceeds even if it would have prompted (audio sample rate, project link, scale-to-frame, etc). 2) Wrap importFileToPremiereProject in a 60s timeout race so even if importFiles does block, the panel surfaces a real error instead of leaving the spinner stuck. Bumps to v1.2.2.
2057 lines
74 KiB
JavaScript
2057 lines
74 KiB
JavaScript
/**
|
|
* 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 = '<option value="all">All Projects</option>';
|
|
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 = [
|
|
'<span>' + (durationSec ? formatDuration(durationSec) : 'LIVE') + '</span>',
|
|
'<span>' + escapeHtml(codec.toUpperCase()) + '</span>',
|
|
].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 =
|
|
'<span style="color:var(--text-tertiary)">No tags</span>';
|
|
}
|
|
|
|
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 = '<option value="">— Select project —</option>';
|
|
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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
xml += '<!DOCTYPE fcpxml>\n';
|
|
xml += '<fcpxml version="1.10">\n';
|
|
xml += ' <resources>\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 += ' <asset id="r' + resourceId + '" name="' + name + '" src="' + path + '" duration="' + srcDur + '" start="' + timecodeFromFrames(0, frameRate) + '" format="r0"/>\n';
|
|
resourceId++;
|
|
}
|
|
});
|
|
|
|
xml += ' <format id="r0" frameDuration="' + frameRateStr + '" width="' + width + '" height="' + height + '"/>\n';
|
|
xml += ' </resources>\n';
|
|
xml += ' <library>\n';
|
|
xml += ' <event name="Conform Export">\n';
|
|
xml += ' <project name="' + seqName + '" duration="' + duration + '">\n';
|
|
xml += ' <sequence duration="' + duration + '" format="r0">\n';
|
|
xml += ' <spine>\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 += ' <clip name="' + name + '" offset="' + tcStart + '" duration="' + dur + '" start="' + srcStart + '" ref="r' + rid + '"/>\n';
|
|
});
|
|
});
|
|
|
|
xml += ' </spine>\n';
|
|
xml += ' </sequence>\n';
|
|
xml += ' </project>\n';
|
|
xml += ' </event>\n';
|
|
xml += ' </library>\n';
|
|
xml += '</fcpxml>\n';
|
|
|
|
return xml;
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatFrameRate(fps) {
|
|
// FCP XML uses rational time scale: e.g. 100/2997 for 29.97fps
|
|
var tolerance = 0.001;
|
|
if (Math.abs(fps - 23.976) < tolerance || Math.abs(fps - 23.98) < tolerance) return '1001/24000';
|
|
if (Math.abs(fps - 24) < tolerance) return '1/24';
|
|
if (Math.abs(fps - 25) < tolerance) return '1/25';
|
|
if (Math.abs(fps - 29.97) < tolerance) return '1001/30000';
|
|
if (Math.abs(fps - 30) < tolerance) return '1/30';
|
|
if (Math.abs(fps - 50) < tolerance) return '1/50';
|
|
if (Math.abs(fps - 59.94) < tolerance) return '1001/60000';
|
|
if (Math.abs(fps - 60) < tolerance) return '1/60';
|
|
return '1001/30000'; // default to 29.97
|
|
}
|
|
|
|
function timecodeFromFrames(frames, fps) {
|
|
if (fps <= 0) fps = 29.97;
|
|
var totalSeconds = frames / fps;
|
|
var h = Math.floor(totalSeconds / 3600);
|
|
var m = Math.floor((totalSeconds % 3600) / 60);
|
|
var s = Math.floor(totalSeconds % 60);
|
|
var f = Math.round((totalSeconds - Math.floor(totalSeconds)) * fps);
|
|
return pad2(h) + ':' + pad2(m) + ':' + pad2(s) + ':' + pad2(f);
|
|
}
|
|
|
|
function pad2(num) {
|
|
return (num < 10 ? '0' : '') + num;
|
|
}
|
|
|
|
async function startConformJob(fcpXml, codec, quality, resolution, audio) {
|
|
// Normalize codec value for the worker
|
|
var workerCodec = codec;
|
|
if (codec === 'prores_hq' || codec === 'prores_4444') workerCodec = 'prores';
|
|
|
|
// Normalize resolution value for the worker
|
|
var workerResolution = resolution;
|
|
if (resolution === 'uhd') workerResolution = '3840x2160';
|
|
else if (resolution === '1080p') workerResolution = '1920x1080';
|
|
else if (resolution === '720p') workerResolution = '1280x720';
|
|
|
|
// Create or find the sequence in MAM (same flow as export timeline)
|
|
var timelineData = state.timelineData;
|
|
var seqName = (timelineData && timelineData.sequenceName) || 'Conformed Sequence';
|
|
var frameRate = (timelineData && timelineData.frameRate) || 29.97;
|
|
var width = (timelineData && timelineData.width) || 1920;
|
|
var height = (timelineData && timelineData.height) || 1080;
|
|
|
|
var seqId = await upsertSequence(
|
|
state.selectedProject,
|
|
seqName,
|
|
frameRate,
|
|
width,
|
|
height
|
|
);
|
|
|
|
var response = await fetch(state.serverUrl + '/api/v1/sequences/' + seqId + '/conform', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
fcp_xml: fcpXml,
|
|
codec: workerCodec,
|
|
quality: quality,
|
|
resolution: workerResolution,
|
|
audio: audio,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
var errBody = {};
|
|
try { errBody = await response.json(); } catch (_) {}
|
|
throw new Error(errBody.error || ('HTTP ' + response.status));
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
function pollConformProgress(jobId) {
|
|
// jobId is in format "conform:123" — matches GET /api/v1/jobs/:id
|
|
var pollInterval = setInterval(async function () {
|
|
try {
|
|
var response = await fetch(
|
|
state.serverUrl + '/api/v1/jobs/' + encodeURIComponent(jobId),
|
|
{ headers: { Accept: 'application/json' }, credentials: 'include' }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
clearInterval(pollInterval);
|
|
hideProgress();
|
|
showErrorMessage('Conform job status check failed');
|
|
return;
|
|
}
|
|
|
|
var status = await response.json();
|
|
var apiStatus = status.status || 'waiting';
|
|
|
|
if (status.progress !== undefined) {
|
|
showProgress('Conforming... ' + status.progress + '%', status.progress);
|
|
}
|
|
|
|
if (apiStatus === 'completed') {
|
|
clearInterval(pollInterval);
|
|
hideProgress();
|
|
showSuccessMessage('Conform complete! Asset ready in MAM.');
|
|
state.conformPollTimer = null;
|
|
// Refresh assets to show the new conformed asset
|
|
fetchAssets();
|
|
} else if (apiStatus === 'failed') {
|
|
clearInterval(pollInterval);
|
|
hideProgress();
|
|
showErrorMessage('Conform failed: ' + (status.error || 'Unknown error'));
|
|
state.conformPollTimer = null;
|
|
}
|
|
} catch (err) {
|
|
// Transient error — keep polling
|
|
}
|
|
}, 2000);
|
|
|
|
state.conformPollTimer = pollInterval;
|
|
}
|
|
|
|
// ============================================================================
|
|
// #31 — Hi-Res Auto-Relink
|
|
// ============================================================================
|
|
|
|
async function fetchAndRelinkAll() {
|
|
if (!state.isConnected) {
|
|
showErrorMessage('Connect to MAM first');
|
|
return;
|
|
}
|
|
|
|
if (state.relinkPanelVisible) {
|
|
hideRelinkPanel();
|
|
return;
|
|
}
|
|
|
|
showProgress('Reading Premiere timeline...', 20);
|
|
|
|
const timelineData = await new Promise(function (resolve) {
|
|
// Use the enhanced function that includes clipInstanceId
|
|
csInterface.evalScript('exportTimelineDataWithIds()', function (resultStr) {
|
|
try { resolve(JSON.parse(resultStr)); }
|
|
catch (e) { resolve({ success: false, message: 'Parse error: ' + e.message, clips: [] }); }
|
|
});
|
|
});
|
|
|
|
hideProgress();
|
|
|
|
if (!timelineData.success) {
|
|
showErrorMessage('Timeline read failed: ' + (timelineData.message || 'unknown error'));
|
|
return;
|
|
}
|
|
|
|
if (!timelineData.clips || timelineData.clips.length === 0) {
|
|
showErrorMessage('No clips found in the active sequence');
|
|
return;
|
|
}
|
|
|
|
// Resolve clips to MAM assets
|
|
var resolved = resolveClipsToAssets(timelineData.clips);
|
|
var matched = resolved.filter(function (c) { return c.asset_id; });
|
|
|
|
if (matched.length === 0) {
|
|
showErrorMessage('No timeline clips matched MAM assets — import them first');
|
|
return;
|
|
}
|
|
|
|
state.timelineData = timelineData;
|
|
state.relinkClips = resolved;
|
|
|
|
showClipSelection(resolved);
|
|
}
|
|
|
|
function showClipSelection(clips) {
|
|
elements.clipList.innerHTML = '';
|
|
|
|
clips.forEach(function (clip, index) {
|
|
var item = document.createElement('div');
|
|
item.className = 'clip-list-item';
|
|
item.dataset.index = index;
|
|
|
|
var checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.className = 'clip-list-item-checkbox';
|
|
checkbox.checked = !!clip.asset_id;
|
|
checkbox.disabled = !clip.asset_id;
|
|
|
|
var info = document.createElement('div');
|
|
info.className = 'clip-list-item-info';
|
|
|
|
var name = document.createElement('div');
|
|
name.className = 'clip-list-item-name';
|
|
name.textContent = clip.fileName || 'Unknown clip';
|
|
|
|
var meta = document.createElement('div');
|
|
meta.className = 'clip-list-item-meta';
|
|
meta.textContent = clip.asset_id
|
|
? 'Track ' + (clip.trackIndex + 1) + ' | MAM asset: ' + clip.asset_id
|
|
: 'Track ' + (clip.trackIndex + 1) + ' | Not matched';
|
|
|
|
info.appendChild(name);
|
|
info.appendChild(meta);
|
|
|
|
var status = document.createElement('div');
|
|
status.className = 'clip-list-item-status ' + (clip.asset_id ? 'matched' : 'unmatched');
|
|
status.textContent = clip.asset_id ? 'Matched' : 'Unmatched';
|
|
|
|
item.appendChild(checkbox);
|
|
item.appendChild(info);
|
|
item.appendChild(status);
|
|
|
|
elements.clipList.appendChild(item);
|
|
});
|
|
|
|
elements.relinkSummary.classList.add('hidden');
|
|
elements.relinkStartBtn.disabled = false;
|
|
|
|
openSlidePanel(
|
|
elements.relinkOverlay,
|
|
elements.relinkPanel
|
|
);
|
|
state.relinkPanelVisible = true;
|
|
}
|
|
|
|
function hideRelinkPanel() {
|
|
closeSlidePanel(
|
|
elements.relinkOverlay,
|
|
elements.relinkPanel
|
|
);
|
|
state.relinkPanelVisible = false;
|
|
state.relinkClips = [];
|
|
}
|
|
|
|
async function startBatchRelink() {
|
|
var checkboxes = elements.clipList.querySelectorAll('.clip-list-item-checkbox');
|
|
var selectedClips = [];
|
|
|
|
checkboxes.forEach(function (cb, index) {
|
|
if (cb.checked && !cb.disabled) {
|
|
var clip = state.relinkClips[index];
|
|
if (clip && clip.asset_id) {
|
|
selectedClips.push(clip);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (selectedClips.length === 0) {
|
|
showErrorMessage('No clips selected for relink');
|
|
return;
|
|
}
|
|
|
|
elements.relinkStartBtn.disabled = true;
|
|
showProgress('Requesting batch trim for ' + selectedClips.length + ' clip(s)...', 10);
|
|
|
|
try {
|
|
var segments = await requestBatchTrim(selectedClips);
|
|
showProgress('Downloading ' + segments.length + ' segment(s)...', 30);
|
|
var results = await downloadAndRelink(segments);
|
|
hideProgress();
|
|
showRelinkSummary(results);
|
|
} catch (err) {
|
|
hideProgress();
|
|
showErrorMessage('Batch relink failed: ' + err.message);
|
|
elements.relinkStartBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function requestBatchTrim(clips) {
|
|
var payload = clips.map(function (clip) {
|
|
return {
|
|
assetId: clip.asset_id,
|
|
filename: clip.fileName || 'clip.mxf',
|
|
trackIndex: clip.trackIndex || 0,
|
|
sourceInFrames: clip.sourceInFrames || 0,
|
|
sourceOutFrames: clip.sourceOutFrames || 0,
|
|
timelineInFrames: clip.timelineInFrames || 0,
|
|
timelineOutFrames: clip.timelineOutFrames || 0,
|
|
};
|
|
});
|
|
|
|
var response = await fetch(state.serverUrl + '/api/v1/assets/batch-trim', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ clips: payload }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
var errBody = {};
|
|
try { errBody = await response.json(); } catch (_) {}
|
|
throw new Error(errBody.error || ('HTTP ' + response.status));
|
|
}
|
|
|
|
var data = await response.json();
|
|
return data.clips || [];
|
|
}
|
|
|
|
async function downloadAndRelink(segments) {
|
|
var results = [];
|
|
|
|
for (var i = 0; i < segments.length; i++) {
|
|
var seg = segments[i];
|
|
try {
|
|
showProgress(
|
|
'Getting signed URL for segment ' + (i + 1) + ' of ' + segments.length + '...',
|
|
25 + ((i / segments.length) * 50)
|
|
);
|
|
|
|
// Fetch signed URL for the temp segment
|
|
var urlRes = await fetch(
|
|
state.serverUrl + '/api/v1/assets/temp-segment-url/' + encodeURIComponent(seg.clipInstanceId),
|
|
{ headers: { Accept: 'application/json' }, credentials: 'include' }
|
|
);
|
|
if (!urlRes.ok) throw new Error('Segment not ready: HTTP ' + urlRes.status);
|
|
var urlData = await urlRes.json();
|
|
|
|
var safeName = sanitizeFilename('segment_' + seg.clipInstanceId + '.mov');
|
|
var localPath = await downloadFile(urlData.url, safeName);
|
|
|
|
showProgress('Relinking segment ' + (i + 1) + ' in Premiere...', 85);
|
|
var relinkResult = await relinkClipToNewMedia(seg.clipInstanceId, localPath);
|
|
|
|
results.push({
|
|
clipName: seg.clipInstanceId || safeName,
|
|
success: true,
|
|
localPath: localPath,
|
|
message: relinkResult.message || 'Relinked',
|
|
});
|
|
} catch (err) {
|
|
results.push({
|
|
clipName: seg.clipInstanceId || 'Unknown',
|
|
success: false,
|
|
localPath: null,
|
|
message: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function relinkClipToNewMedia(clipId, filePath) {
|
|
var safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
var script = [
|
|
'(function () {',
|
|
' var out = { success: false, message: "" };',
|
|
' try {',
|
|
' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }',
|
|
' var newPath = "' + safePath + '";',
|
|
' function walk(item) {',
|
|
' for (var i = 0; i < item.children.numItems; i++) {',
|
|
' var c = item.children[i];',
|
|
' if (c.type === 1 || c.type === 2) {',
|
|
' try {',
|
|
' c.changeMediaPath(newPath);',
|
|
' out.success = true;',
|
|
' out.message = "Relinked: " + c.name;',
|
|
' return;',
|
|
' } catch (e) {}',
|
|
' }',
|
|
' if (c.children && c.children.numItems > 0) walk(c);',
|
|
' }',
|
|
' }',
|
|
' walk(app.project.rootItem);',
|
|
' if (!out.success) out.message = "No matching clip found for relink";',
|
|
' } catch (e) { out.message = e.message; }',
|
|
' return JSON.stringify(out);',
|
|
'})();',
|
|
].join('\n');
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
csInterface.evalScript(script, function (resultStr) {
|
|
try {
|
|
var parsed = JSON.parse(resultStr);
|
|
resolve(parsed);
|
|
} catch (e) {
|
|
reject(new Error('ExtendScript error: ' + resultStr));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function showRelinkSummary(results) {
|
|
var succeeded = results.filter(function (r) { return r.success; }).length;
|
|
var failed = results.filter(function (r) { return !r.success; }).length;
|
|
|
|
elements.relinkSummary.classList.remove('hidden');
|
|
elements.relinkSummaryText.textContent = succeeded + ' of ' + results.length + ' clip(s) relinked to hi-res';
|
|
|
|
var detailParts = [];
|
|
if (succeeded > 0) detailParts.push(succeeded + ' succeeded');
|
|
if (failed > 0) detailParts.push(failed + ' failed');
|
|
elements.relinkSummaryDetail.textContent = detailParts.join(', ') || 'No clips processed';
|
|
|
|
elements.relinkStartBtn.disabled = false;
|
|
|
|
_showFlash(results.length + ' clip(s) processed — ' + succeeded + ' relinked, ' + failed + ' failed', 'info-message');
|
|
}
|
|
|
|
// ============================================================================
|
|
// #32 — Slide Panel Management
|
|
// ============================================================================
|
|
|
|
function openSlidePanel(overlay, panel) {
|
|
overlay.classList.add('open');
|
|
panel.classList.add('open');
|
|
}
|
|
|
|
function closeSlidePanel(overlay, panel) {
|
|
overlay.classList.remove('open');
|
|
panel.classList.remove('open');
|
|
}
|
|
|
|
// ============================================================================
|
|
// UI Helpers
|
|
// ============================================================================
|
|
|
|
function handleAssetClick(e) {
|
|
var card = e.target.closest('.asset-card');
|
|
if (!card) return;
|
|
|
|
document.querySelectorAll('.asset-card.selected').forEach(function (el) {
|
|
el.classList.remove('selected');
|
|
});
|
|
card.classList.add('selected');
|
|
|
|
var assetId = card.dataset.assetId;
|
|
var asset = state.assets.find(function (a) { return a.id === assetId; }) ||
|
|
state.growingAssets.find(function (a) { return a.id === assetId; });
|
|
if (asset) showAssetDetails(asset);
|
|
}
|
|
|
|
function showProgress(label, percent) {
|
|
elements.progressContainer.classList.add('visible');
|
|
elements.progressLabel.textContent = label;
|
|
state.downloadProgress = percent;
|
|
updateProgressUI();
|
|
}
|
|
|
|
function hideProgress() {
|
|
elements.progressContainer.classList.remove('visible');
|
|
state.downloadProgress = 0;
|
|
updateProgressUI();
|
|
}
|
|
|
|
function updateProgressUI() {
|
|
elements.progressFill.style.width = state.downloadProgress + '%';
|
|
}
|
|
|
|
function showErrorMessage(message) {
|
|
_showFlash(message, 'error-message');
|
|
}
|
|
|
|
function showSuccessMessage(message) {
|
|
_showFlash(message, 'success-message');
|
|
}
|
|
|
|
function _showFlash(message, className) {
|
|
var el = document.createElement('div');
|
|
el.className = className;
|
|
el.textContent = message;
|
|
var anchor = document.querySelector('.search-filter-area');
|
|
anchor.insertBefore(el, anchor.firstChild);
|
|
setTimeout(function () { el.remove(); }, 5000);
|
|
}
|
|
|
|
function logMessage(message) {
|
|
console.log('[MAM Panel] ' + message);
|
|
}
|
|
|
|
// ============================================================================
|
|
// File Download and Premiere Import
|
|
// ============================================================================
|
|
|
|
function downloadFile(url, filename) {
|
|
return new Promise(function (resolve, reject) {
|
|
try {
|
|
var https = require('https');
|
|
var http = require('http');
|
|
var fs = require('fs');
|
|
var path = require('path');
|
|
var os = require('os');
|
|
|
|
var tempPath = path.join(os.tmpdir(), filename);
|
|
var file = fs.createWriteStream(tempPath);
|
|
var protocol = url.startsWith('https') ? https : http;
|
|
|
|
// Native http.get bypasses window.fetch, so the Bearer
|
|
// interceptor never fires. Inject Authorization manually for
|
|
// same-server URLs (proxy /video etc.); off-host presigned URLs
|
|
// don't need it.
|
|
var reqOpts = url;
|
|
if (state.apiToken && state.serverUrl && url.startsWith(state.serverUrl)) {
|
|
try {
|
|
var p = new URL(url);
|
|
reqOpts = {
|
|
hostname: p.hostname,
|
|
port: p.port || (p.protocol === 'https:' ? 443 : 80),
|
|
path: p.pathname + (p.search || ''),
|
|
headers: { 'Authorization': 'Bearer ' + state.apiToken },
|
|
};
|
|
} catch (_) { /* fall back to url string */ }
|
|
}
|
|
|
|
var req = protocol.get(reqOpts, function (res) {
|
|
if (res.statusCode !== 200) {
|
|
file.close();
|
|
fs.unlink(tempPath, function () {});
|
|
reject(new Error('Download HTTP ' + res.statusCode));
|
|
return;
|
|
}
|
|
|
|
var total = parseInt(res.headers['content-length'] || '0', 10);
|
|
var received = 0;
|
|
|
|
res.on('data', function (chunk) {
|
|
received += chunk.length;
|
|
if (total > 0) {
|
|
state.downloadProgress = 10 + (received / total) * 75;
|
|
updateProgressUI();
|
|
}
|
|
});
|
|
|
|
res.pipe(file);
|
|
|
|
file.on('finish', function () {
|
|
file.close(function () { resolve(tempPath); });
|
|
});
|
|
|
|
file.on('error', function (err) {
|
|
fs.unlink(tempPath, function () {});
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
// Without a timeout an unreachable host or a CEP-Node TLS hiccup
|
|
// makes this Promise sit forever. 15s covers slow LTE connect +
|
|
// handshake; once bytes flow there's no cap.
|
|
req.setTimeout(15000, function () {
|
|
var host = (typeof reqOpts === 'string') ? reqOpts : reqOpts.hostname;
|
|
req.destroy(new Error('Download timed out after 15s (no response from ' + host + ')'));
|
|
});
|
|
|
|
req.on('error', function (err) {
|
|
fs.unlink(tempPath, function () {});
|
|
reject(err);
|
|
});
|
|
} catch (err) {
|
|
reject(new Error('Node.js unavailable for download: ' + err.message));
|
|
}
|
|
});
|
|
}
|
|
|
|
function importFileToPremiereProject(filePath) {
|
|
return new Promise(function (resolve, reject) {
|
|
var safePath = filePath.replace(/\\/g, '\\\\');
|
|
|
|
// Premiere can deadlock on importFiles() if a hidden prompt appears
|
|
// (off-screen modal, etc.). The evalScript callback then never fires.
|
|
// Race the import against a 60s timeout so the user sees an error
|
|
// instead of a frozen spinner.
|
|
var done = false;
|
|
var timer = setTimeout(function () {
|
|
if (done) return;
|
|
done = true;
|
|
reject(new Error('ExtendScript importFiles() did not return within 60s — Premiere may have a hidden dialog'));
|
|
}, 60000);
|
|
|
|
var script = [
|
|
'(function() {',
|
|
' var result = { success: false, message: "" };',
|
|
' try {',
|
|
' 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 + '"], true);',
|
|
' result.success = true;',
|
|
' result.message = "Imported successfully";',
|
|
' } catch (e) {',
|
|
' result.message = e.message;',
|
|
' }',
|
|
' return JSON.stringify(result);',
|
|
'})();',
|
|
].join('\n');
|
|
|
|
csInterface.evalScript(script, function (resultStr) {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
try {
|
|
var parsed = JSON.parse(resultStr);
|
|
if (parsed.success) resolve(parsed);
|
|
else reject(new Error(parsed.message));
|
|
} catch (e) {
|
|
reject(new Error('ExtendScript error: ' + resultStr));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
function debounce(func, delay) {
|
|
var timeout;
|
|
return function () {
|
|
var args = arguments;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(function () { func.apply(null, args); }, delay);
|
|
};
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.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];
|
|
}
|