diff --git a/services/premiere-plugin/js/main.js b/services/premiere-plugin/js/main.js
index 421aaef..e337906 100644
--- a/services/premiere-plugin/js/main.js
+++ b/services/premiere-plugin/js/main.js
@@ -25,6 +25,11 @@ const state = {
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: '',
};
// ============================================================================
@@ -51,10 +56,23 @@ function initDOMElements() {
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'),
+ 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'),
};
}
@@ -116,7 +134,12 @@ function setupEventListeners() {
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.exportTimelineBtn.addEventListener('click', startExportTimeline);
+ elements.exportConfirmBtn.addEventListener('click', confirmExportTimeline);
+ elements.exportCancelBtn.addEventListener('click', cancelExportTimeline);
+ elements.seqRefreshBtn.addEventListener('click', refreshCurrentSequenceInfo);
}
function restoreSettings() {
@@ -135,7 +158,6 @@ async function connectToServer() {
elements.connectBtn.disabled = true;
try {
- // Use the projects endpoint as a connectivity check
const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
method: 'GET',
headers: { Accept: 'application/json' },
@@ -151,6 +173,7 @@ async function connectToServer() {
const projectData = await response.json();
await fetchProjects(projectData);
await fetchAssets();
+ refreshCurrentSequenceInfo();
} else {
throw new Error(`HTTP ${response.status}`);
}
@@ -204,6 +227,9 @@ async function fetchProjects(preloadedData) {
});
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);
@@ -255,8 +281,9 @@ async function fetchAssetDetails(assetId) {
}
/**
- * Returns a short-lived presigned URL for the H.264 proxy of the given asset.
- * GET /api/v1/assets/:id/stream -> { url: '...' }
+ * 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`, {
@@ -266,7 +293,22 @@ async function getSignedDownloadUrl(assetId) {
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;
+ 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;
}
// ============================================================================
@@ -320,7 +362,6 @@ function createAssetCard(asset) {
info.appendChild(filenameEl);
const durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
- // codec and media_type are top-level columns on the asset record
const codec = asset.codec || asset.media_type || 'video';
const meta = document.createElement('div');
meta.className = 'asset-meta';
@@ -344,7 +385,6 @@ function showAssetDetails(asset) {
state.selectedAsset = asset;
elements.detailsPanel.classList.remove('hidden');
- // All of these are top-level columns in the assets table
elements.detailsFilename.textContent = asset.display_name || asset.filename;
elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown';
elements.detailsResolution.textContent = asset.resolution || 'N/A';
@@ -370,13 +410,15 @@ function showAssetDetails(asset) {
'No tags';
}
- elements.importBtn.disabled = false;
+ elements.importBtn.disabled = false;
+ elements.importHiresBtn.disabled = false;
}
function hideAssetDetails() {
state.selectedAsset = null;
elements.detailsPanel.classList.add('hidden');
- elements.importBtn.disabled = true;
+ elements.importBtn.disabled = true;
+ elements.importHiresBtn.disabled = true;
}
// ============================================================================
@@ -396,7 +438,7 @@ function handleProjectFilter(e) {
}
// ============================================================================
-// Import Functionality
+// Import — Proxy
// ============================================================================
async function importSelectedAsset() {
@@ -422,7 +464,7 @@ async function importAsset(asset) {
try {
elements.importBtn.disabled = true;
- // 1. Get a presigned proxy URL
+ // 1. Get proxy URL (absolute — /stream returns a relative path)
showProgress('Getting download link...', 5);
const url = await getSignedDownloadUrl(asset.id);
@@ -437,6 +479,9 @@ async function importAsset(asset) {
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) {
@@ -448,6 +493,376 @@ async function importAsset(asset) {
}
}
+// ============================================================================
+// 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.
@@ -546,62 +961,6 @@ function importFileToPremiereProject(filePath) {
});
}
-// ============================================================================
-// 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);
-}
-
// ============================================================================
// Utility Functions
// ============================================================================