End-to-end debugging against a live Premiere Pro 2025 + auth-enabled mam-api
surfaced four real bugs that made v1.0.0 install cleanly but never load,
plus the missing auth flow. All four are fixed and the panel is verified
connected (status dot green, Reconnect button shown, project list populated).
- manifest.xml: a comment in the <Resources> block contained "--" (inside
"--enable-nodejs"/"--mixed-context"), which is illegal per the XML spec.
CEP 12's strict parser logged
ERROR XPATH Double hyphen within comment
and skipped the panel entirely. Comment rewritten without double hyphens.
- manifest.xml: lacked the Version="X.Y" attribute on <ExtensionManifest>
and used a non-standard AbstractionLayers/empty <ExtensionList/>
structure. CEP rejected it with
Unsupported Manifest version ''
Manifest rewritten to the standard CSXS 7.0 schema (ExtensionList +
DispatchInfoList + RequiredRuntimeList), matching the working AMPP
panel template.
- main.js: re-declared `const csInterface = new CSInterface()` at top
level even though CSInterface.js already declared the same binding.
CEP 12 shares script-realm lexical scope across <script> tags, so the
second const threw
Identifier 'csInterface' has already been declared
The throw fired before setupEventListeners(), so the Connect button's
click handler was never attached. This is the root cause of the
original "clicking Connect does nothing" symptom; everything else was
secondary. Removed the duplicate declaration; main.js now uses the
binding from CSInterface.js.
- No auth support against AUTH_ENABLED=true servers. mam-api supports
Bearer tokens (POST /api/v1/tokens), so added:
• API token input field (password-masked) next to Server URL
• localStorage persistence on every keystroke
• window.fetch monkey-patch that injects
Authorization: Bearer <token>
on every request whose URL starts with the configured server.
Signed S3 download URLs are NOT touched.
Drive-by fixes that came out of the same debugging pass:
- Server URL input listener was 'change' (fires on blur); switched to
'input' so typing-then-clicking-Connect immediately commits.
- restoreSettings() now strips trailing slashes from the stored URL so
older saved values like 'http://host/' stop producing //api/v1 404s.
- CSS selector `input[type="text"].server-url` didn't match the new
password input → the token field was unstyled and effectively invisible.
Generalized to `input.server-url`; restructured the connection bar into
`.connection-controls--stacked` (flex column) of two `.server-input-row`
rows so two input fields fit cleanly.
- Build scripts now parse ExtensionBundleVersion from both element form
(<ExtensionBundleVersion>X</...>) and attribute form
(ExtensionBundleVersion="X"), since the manifest rewrite switched
schemas.
Version bumped 1.0.0 → 1.0.1. New artifacts committed at
services/premiere-plugin/build/releases/v1.0.1/ (.exe 2 MB, .zxp 35 KB).
v1.0.0 left in place so editors who downloaded it can verify they're on
the broken version.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1211 lines
44 KiB
JavaScript
1211 lines
44 KiB
JavaScript
/**
|
|
* Wild Dragon MAM - Premiere Pro Panel
|
|
* Main JavaScript file for the CEP panel
|
|
*/
|
|
|
|
// Adobe CEP interface — CSInterface.js already declared `const csInterface`
|
|
// at the script-realm scope (top-level `const` is shared across non-module
|
|
// <script> tags), so re-declaring it here throws SyntaxError under CEP 12.
|
|
// We just use the binding that CSInterface.js provides.
|
|
|
|
// ============================================================================
|
|
// 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: {}, // 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'),
|
|
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'),
|
|
// 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() {
|
|
// Commit serverUrl on every keystroke so clicking Connect right after
|
|
// typing works even before the input loses focus.
|
|
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.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() {
|
|
// Normalize any trailing slash that the old `change`-event listener may
|
|
// have missed on previous sessions — otherwise fetches produce //api/v1
|
|
// and nginx 404s.
|
|
state.serverUrl = state.serverUrl.replace(/\/+$/, '');
|
|
localStorage.setItem('mam_server_url', state.serverUrl);
|
|
elements.serverUrlInput.value = state.serverUrl;
|
|
elements.apiTokenInput.value = state.apiToken;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Auth: monkey-patch fetch to inject Bearer token on every same-origin call
|
|
// to the configured MAM server. Mam-api requires this when AUTH_ENABLED=true.
|
|
// ============================================================================
|
|
|
|
const _originalFetch = window.fetch.bind(window);
|
|
window.fetch = function (input, init) {
|
|
init = init || {};
|
|
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
// Only attach the token for requests aimed at the configured MAM server.
|
|
// Signed S3 download URLs etc. must NOT get our Authorization header.
|
|
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');
|
|
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 = '<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;
|
|
|
|
// 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 = [
|
|
'<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');
|
|
|
|
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>';
|
|
}
|
|
|
|
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 = '<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(--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, '"')
|
|
.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];
|
|
}
|