/**
* Wild Dragon MAM - Premiere Pro Panel
* Main JavaScript file for the CEP panel
*/
// Adobe CEP interface — must be instantiated before any host (ExtendScript) calls
const csInterface = new CSInterface();
// ============================================================================
// State Management
// ============================================================================
const state = {
serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434',
isConnected: false,
isConnecting: false,
selectedAsset: null,
assets: [],
projects: [],
selectedProject: 'all',
searchQuery: '',
currentPage: 0,
pageSize: 50,
totalAssets: 0,
downloadProgress: 0,
isDownloading: false,
thumbCache: {}, // assetId -> signed URL
// Maps tempFilePath → { assetId, displayName } for timeline export resolution.
// Also stores 'name:filename.mp4' → same as a fuzzy fallback.
importedAssets: JSON.parse(localStorage.getItem('mam_imported_assets') || '{}'),
exportPanelVisible: false,
currentSequenceName: '',
};
// ============================================================================
// DOM Elements
// ============================================================================
let elements = {};
function initDOMElements() {
elements = {
serverUrlInput: document.getElementById('server-url'),
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'),
// Export panel
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'),
// Sequence info bar
seqInfoBar: document.getElementById('seq-info-bar'),
seqInfoName: document.getElementById('seq-info-name'),
seqRefreshBtn: document.getElementById('seq-refresh-btn'),
};
}
// ============================================================================
// Thumbnail Lazy Loading (IntersectionObserver)
// ============================================================================
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 (_) {
// thumbnail unavailable — leave placeholder
}
}
// ============================================================================
// Initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', () => {
initDOMElements();
setupEventListeners();
restoreSettings();
logMessage('Wild Dragon MAM panel initialized');
});
function setupEventListeners() {
elements.serverUrlInput.addEventListener('change', (e) => {
state.serverUrl = e.target.value.trim().replace(/\/$/, '');
localStorage.setItem('mam_server_url', state.serverUrl);
state.thumbCache = {};
});
elements.connectBtn.addEventListener('click', connectToServer);
elements.searchInput.addEventListener('input', debounce(handleSearch, 300));
elements.projectFilter.addEventListener('change', handleProjectFilter);
elements.assetGrid.addEventListener('click', handleAssetClick);
elements.importBtn.addEventListener('click', importSelectedAsset);
elements.importHiresBtn.addEventListener('click', importSelectedAssetHires);
elements.importAllBtn.addEventListener('click', importAllAssets);
elements.mountLiveBtn.addEventListener('click', mountLiveAsset);
elements.relinkBtn.addEventListener('click', relinkSelectedAsset);
elements.exportTimelineBtn.addEventListener('click', startExportTimeline);
elements.exportConfirmBtn.addEventListener('click', confirmExportTimeline);
elements.exportCancelBtn.addEventListener('click', cancelExportTimeline);
elements.seqRefreshBtn.addEventListener('click', refreshCurrentSequenceInfo);
}
function restoreSettings() {
elements.serverUrlInput.value = state.serverUrl;
}
// ============================================================================
// Server Connection
// ============================================================================
async function connectToServer() {
if (state.isConnecting) return;
state.isConnecting = true;
updateConnectionStatus('connecting');
elements.connectBtn.disabled = true;
try {
const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (response.ok) {
state.isConnected = true;
updateConnectionStatus('connected');
elements.connectBtn.textContent = 'Reconnect';
logMessage('Connected to Wild Dragon MAM');
const projectData = await response.json();
await fetchProjects(projectData);
await fetchAssets();
refreshCurrentSequenceInfo();
} 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}`);
} finally {
state.isConnecting = false;
elements.connectBtn.disabled = false;
}
}
function updateConnectionStatus(status) {
const indicator = elements.statusIndicator;
indicator.classList.remove('connected', 'connecting');
if (status === 'connected') indicator.classList.add('connected');
else if (status === 'connecting') indicator.classList.add('connecting');
}
// ============================================================================
// API Calls
// ============================================================================
async function fetchProjects(preloadedData) {
try {
let projects;
if (preloadedData) {
projects = Array.isArray(preloadedData) ? preloadedData : [];
} else {
const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
projects = Array.isArray(data) ? data : [];
}
state.projects = projects;
const savedProject = state.selectedProject;
elements.projectFilter.innerHTML = '';
state.projects.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
elements.projectFilter.appendChild(opt);
});
elements.projectFilter.value = savedProject;
// Also populate the export panel project selector
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;
}
}
/**
* Returns the download URL for the proxy. /stream returns a server-relative
* path (/api/v1/assets/:id/video) designed for browser playback; we prepend
* serverUrl so Node.js http.get() receives a valid absolute URL.
*/
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;
}
/**
* Returns presigned S3 URL + filename/ext/file_size for the hi-res original.
* GET /api/v1/assets/:id/hires
*/
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;
// Thumbnail — lazy loaded via IntersectionObserver
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);
// Info row
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);
const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
const codec = asset.codec || asset.media_type || 'video';
const meta = document.createElement('div');
meta.className = 'asset-meta';
meta.innerHTML = [
'' + (durationSec ? formatDuration(durationSec) : 'N/A') + '',
'' + escapeHtml(codec.toUpperCase()) + '',
].join('');
info.appendChild(meta);
const statusStr = asset.status || 'ready';
const statusBadge = document.createElement('div');
statusBadge.className = 'asset-status-badge status-badge ' + statusStr;
statusBadge.textContent = statusStr.toUpperCase();
info.appendChild(statusBadge);
card.appendChild(info);
return card;
}
function showAssetDetails(asset) {
state.selectedAsset = asset;
elements.detailsPanel.classList.remove('hidden');
elements.detailsFilename.textContent = asset.display_name || asset.filename;
elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown';
elements.detailsResolution.textContent = asset.resolution || 'N/A';
elements.detailsFps.textContent = asset.fps ? asset.fps + ' fps' : 'N/A';
elements.detailsDuration.textContent = asset.duration_ms
? formatDuration(asset.duration_ms / 1000)
: 'N/A';
elements.detailsSize.textContent = asset.file_size
? formatFileSize(asset.file_size)
: 'N/A';
elements.detailsTags.innerHTML = '';
const tags = asset.tags || [];
if (tags.length > 0) {
tags.forEach((tag) => {
const el = document.createElement('span');
el.className = 'tag';
el.textContent = tag;
elements.detailsTags.appendChild(el);
});
} else {
elements.detailsTags.innerHTML =
'No tags';
}
var isLive = asset.status === 'live';
elements.importBtn.disabled = isLive;
elements.importHiresBtn.disabled = isLive;
elements.mountLiveBtn.disabled = !isLive;
// The relink button only makes sense once the live file has been finalized
// AND we have a record of it being mounted in this session.
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;
}
// ============================================================================
// Mount Live — open the growing file directly from the SMB share
// ============================================================================
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();
// Premiere on Windows wants UNC paths with backslashes; on Mac
// POSIX-style smb:// paths route through the Finder mount.
var isMac = navigator.platform.indexOf('Mac') !== -1;
var hostPath = isMac ? info.posix_path : info.win_path;
showProgress('Importing live file…', 60);
await importFileToPremiereProject(hostPath);
// Remember this asset so the Relink button knows what to swap later.
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');
}
}
// Poll the asset until its status flips from 'live' to 'ready' so we can
// surface the Relink button. 5s cadence — matches the worker's promotion
// scan interval, so we typically catch the transition on the next tick.
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 (_) { /* transient — try again next tick */ }
}, 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;
}
}
// Walks every project item and swaps clip media paths matching `oldPath`
// onto `newPath`. Premiere's ProjectItem.changeMediaPath() does the relink
// without touching timeline placement.
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) {', /* clip or sub-clip */
' 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;
fetchAssets();
}
function handleProjectFilter(e) {
state.selectedProject = e.target.value;
state.currentPage = 0;
fetchAssets();
}
// ============================================================================
// 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;
// 1. Get proxy URL (absolute — /stream returns a relative path)
showProgress('Getting download link...', 5);
const url = await getSignedDownloadUrl(asset.id);
// 2. Download proxy to OS temp dir via Node.js
const safeName = sanitizeFilename(
(asset.display_name || asset.filename || asset.id) + '.mp4'
);
showProgress('Downloading ' + safeName + '...', 10);
const filePath = await downloadFile(url, safeName);
// 3. Hand the local path to Premiere Pro
showProgress('Importing into Premiere Pro...', 85);
await importFileToPremiereProject(filePath);
// 4. Store path → assetId so timeline export can resolve this clip
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;
// 1. Get hi-res presigned URL and metadata
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'
);
// 2. Download hi-res file to OS temp dir
showProgress('Downloading hi-res' + sizeNote + '...', 10);
const filePath = await downloadFile(hiresInfo.url, safeName);
// 3. Import into Premiere Pro
showProgress('Importing hi-res into Premiere Pro...', 85);
await importFileToPremiereProject(filePath);
// 4. Map this path to the same asset ID (hi-res round-trips to same MAM record)
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;
}
}
/**
* Persists two keys per import:
* - exact temp path → so Premiere getMediaPath() matches on the same machine
* - 'name:filename' → fuzzy fallback across sessions / machines
*/
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
// ============================================================================
function populateExportProjectSelect() {
elements.exportProjSelect.innerHTML = '';
state.projects.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === state.selectedProject) opt.selected = true;
elements.exportProjSelect.appendChild(opt);
});
}
function showExportPanel(timelineData) {
elements.exportSeqName.value = timelineData.sequenceName || 'Sequence 1';
populateExportProjectSelect();
var totalClips = (timelineData.clips || []).length;
var matchedClips = resolveClipsToAssets(timelineData.clips || []).filter(function (c) {
return c.asset_id;
}).length;
elements.exportClipInfo.textContent =
matchedClips + ' of ' + totalClips + ' clip(s) matched to MAM assets';
elements.exportClipInfo.style.color =
matchedClips === 0 ? 'var(--error)' : 'var(--text-secondary)';
// Stash the full timeline data for confirmExportTimeline
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
// ============================================================================
/**
* Step 1 of 2: read the Premiere timeline then show the export confirmation
* panel with pre-filled sequence name and matched clip count.
*/
async function startExportTimeline() {
if (!state.isConnected) {
showErrorMessage('Connect to MAM first');
return;
}
// Toggle: clicking again closes the panel
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);
}
/**
* Step 2 of 2: resolve clip paths to MAM asset IDs, upsert the sequence,
* then PUT the clip array to /api/v1/sequences/:id/clips.
*/
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();
}
/**
* Maps Premiere clip file paths back to MAM asset IDs.
* Lookup order: exact temp path → name-based fallback.
*/
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 });
});
}
/**
* Finds an existing sequence by name in the target project; creates it if
* absent. Updates frame_rate/width/height on the existing record.
* Returns the sequence ID.
*/
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;
}
// ============================================================================
// 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; });
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 (Node.js / ExtendScript)
// ============================================================================
/**
* Downloads a remote URL to a local temp file using Node.js http/https.
* Requires --enable-nodejs in the CEP manifest.
*/
function downloadFile(url, filename) {
return new Promise(function (resolve, reject) {
try {
var https = require('https');
var http = require('http');
var fs = require('fs');
var path = require('path');
var os = require('os');
var tempPath = path.join(os.tmpdir(), filename);
var file = fs.createWriteStream(tempPath);
var protocol = url.startsWith('https') ? https : http;
protocol.get(url, function (res) {
if (res.statusCode !== 200) {
file.close();
fs.unlink(tempPath, function () {});
reject(new Error('Download HTTP ' + res.statusCode));
return;
}
var total = parseInt(res.headers['content-length'] || '0', 10);
var received = 0;
res.on('data', function (chunk) {
received += chunk.length;
if (total > 0) {
state.downloadProgress = 10 + (received / total) * 75;
updateProgressUI();
}
});
res.pipe(file);
file.on('finish', function () {
file.close(function () { resolve(tempPath); });
});
file.on('error', function (err) {
fs.unlink(tempPath, function () {});
reject(err);
});
}).on('error', function (err) {
fs.unlink(tempPath, function () {});
reject(err);
});
} catch (err) {
reject(new Error('Node.js unavailable for download: ' + err.message));
}
});
}
/**
* Calls csInterface.evalScript to import a local file into the open Premiere project.
*/
function importFileToPremiereProject(filePath) {
return new Promise(function (resolve, reject) {
var safePath = filePath.replace(/\\/g, '\\\\');
var script = [
'(function() {',
' var result = { success: false, message: "" };',
' try {',
' if (!app.project) {',
' result.message = "No active Premiere Pro project";',
' return JSON.stringify(result);',
' }',
' var f = new File("' + safePath + '");',
' if (!f.exists) {',
' result.message = "File not found: ' + safePath + '";',
' return JSON.stringify(result);',
' }',
' app.project.importFiles(["' + safePath + '"]);',
' result.success = true;',
' result.message = "Imported successfully";',
' } catch (e) {',
' result.message = e.message;',
' }',
' return JSON.stringify(result);',
'})();',
].join('\n');
csInterface.evalScript(script, function (resultStr) {
try {
var parsed = JSON.parse(resultStr);
if (parsed.success) resolve(parsed);
else reject(new Error(parsed.message));
} catch (e) {
reject(new Error('ExtendScript error: ' + resultStr));
}
});
});
}
// ============================================================================
// Utility Functions
// ============================================================================
function debounce(func, delay) {
var timeout;
return function () {
var args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function () { func.apply(null, args); }, delay);
};
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function sanitizeFilename(name) {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
}
function formatDuration(seconds) {
if (!seconds) return 'N/A';
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = Math.floor(seconds % 60);
if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
return m + ':' + String(s).padStart(2, '0');
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}