646 lines
22 KiB
JavaScript
646 lines
22 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
|
|
// ============================================================================
|
|
// 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'),
|
|
importAllBtn: document.getElementById('import-all-btn'),
|
|
progressContainer: document.getElementById('progress-container'),
|
|
progressLabel: document.getElementById('progress-label'),
|
|
progressFill: document.getElementById('progress-fill'),
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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.importAllBtn.addEventListener('click', importAllAssets);
|
|
}
|
|
|
|
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 {
|
|
// Use the projects endpoint as a connectivity check
|
|
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();
|
|
} 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 = '<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;
|
|
|
|
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 a short-lived presigned URL for the H.264 proxy of the given asset.
|
|
* GET /api/v1/assets/:id/stream -> { 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;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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;
|
|
// 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';
|
|
meta.innerHTML = [
|
|
'<span>' + (durationSec ? formatDuration(durationSec) : 'N/A') + '</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');
|
|
|
|
// 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';
|
|
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-secondary)">No tags</span>';
|
|
}
|
|
|
|
elements.importBtn.disabled = false;
|
|
}
|
|
|
|
function hideAssetDetails() {
|
|
state.selectedAsset = null;
|
|
elements.detailsPanel.classList.add('hidden');
|
|
elements.importBtn.disabled = true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 Functionality
|
|
// ============================================================================
|
|
|
|
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 a presigned proxy URL
|
|
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);
|
|
|
|
hideProgress();
|
|
showSuccessMessage('Imported: ' + safeName);
|
|
} catch (error) {
|
|
console.error('Import error:', error);
|
|
hideProgress();
|
|
showErrorMessage('Import failed: ' + error.message);
|
|
} finally {
|
|
elements.importBtn.disabled = !state.selectedAsset;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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
|
|
// ============================================================================
|
|
|
|
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];
|
|
}
|