Writes timestamped pre/post lines to C:/Temp/df-import-log.txt around the importFiles call so we can see whether importFiles hangs (only pre line present) or returns and evalScript callback gets lost (both lines present). Diagnostic only.
2059 lines
74 KiB
JavaScript
2059 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);',
|
|
' }',
|
|
' try { var __l = new File("C:/Temp/df-import-log.txt"); __l.open("a"); __l.writeln((new Date()).toString() + " pre-importFiles " + ' + safePath + '); __l.close(); } catch(_e){}',
|
|
' app.project.importFiles(["' + safePath + '"], true);',
|
|
' try { var __l2 = new File("C:/Temp/df-import-log.txt"); __l2.open("a"); __l2.writeln((new Date()).toString() + " post-importFiles RETURNED"); __l2.close(); } catch(_e){}',
|
|
' 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];
|
|
}
|