dragonflight/services/premiere-plugin/js/main.js

1978 lines
71 KiB
JavaScript
Raw Normal View History

/**
* Wild Dragon MAM - Premiere Pro Panel
* Main JavaScript file for the CEP panel
* Features: #30 FCP XML Export & Conform, #31 Hi-Res Auto-Relink, #32 GUI Redesign
*/
// ============================================================================
// State Management
// ============================================================================
const state = {
serverUrl: localStorage.getItem('mam_server_url') || 'http://localhost:7434',
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
apiToken: localStorage.getItem('mam_api_token') || '',
isConnected: false,
isConnecting: false,
selectedAsset: null,
assets: [],
projects: [],
selectedProject: 'all',
searchQuery: '',
currentPage: 0,
pageSize: 50,
totalAssets: 0,
downloadProgress: 0,
isDownloading: false,
thumbCache: {},
importedAssets: JSON.parse(localStorage.getItem('mam_imported_assets') || '{}'),
exportPanelVisible: false,
currentSequenceName: '',
// Advanced features state
conformPanelVisible: false,
relinkPanelVisible: false,
selectedPreset: 'broadcast',
timelineData: null,
relinkClips: [],
conformJobId: null,
conformPollTimer: null,
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
// Tabs state
currentTab: 'library',
growingAssets: [],
growingPollInterval: null,
};
// ============================================================================
// DOM Elements
// ============================================================================
let elements = {};
function initDOMElements() {
elements = {
serverUrlInput: document.getElementById('server-url'),
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
apiTokenInput: document.getElementById('api-token'),
connectBtn: document.getElementById('connect-btn'),
statusIndicator: document.getElementById('status-indicator'),
searchInput: document.getElementById('search-input'),
projectFilter: document.getElementById('project-filter'),
assetGrid: document.getElementById('asset-grid'),
emptyState: document.getElementById('empty-state'),
detailsPanel: document.getElementById('details-panel'),
detailsFilename: document.getElementById('details-filename'),
detailsCodec: document.getElementById('details-codec'),
detailsResolution: document.getElementById('details-resolution'),
detailsFps: document.getElementById('details-fps'),
detailsDuration: document.getElementById('details-duration'),
detailsSize: document.getElementById('details-size'),
detailsTags: document.getElementById('details-tags'),
importBtn: document.getElementById('import-btn'),
importHiresBtn: document.getElementById('import-hires-btn'),
importAllBtn: document.getElementById('import-all-btn'),
mountLiveBtn: document.getElementById('mount-live-btn'),
relinkBtn: document.getElementById('relink-btn'),
exportTimelineBtn: document.getElementById('export-timeline-btn'),
progressContainer: document.getElementById('progress-container'),
progressLabel: document.getElementById('progress-label'),
progressFill: document.getElementById('progress-fill'),
exportPanel: document.getElementById('export-panel'),
exportSeqName: document.getElementById('export-seq-name'),
exportProjSelect: document.getElementById('export-proj-select'),
exportClipInfo: document.getElementById('export-clip-info'),
exportConfirmBtn: document.getElementById('export-confirm-btn'),
exportCancelBtn: document.getElementById('export-cancel-btn'),
seqInfoBar: document.getElementById('seq-info-bar'),
seqInfoName: document.getElementById('seq-info-name'),
seqRefreshBtn: document.getElementById('seq-refresh-btn'),
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
// Tabs
tabLibrary: document.getElementById('tab-library'),
tabGrowing: document.getElementById('tab-growing'),
growingCount: document.getElementById('growing-count'),
libraryContainer: document.getElementById('library-container'),
growingContainer: document.getElementById('growing-container'),
growingGrid: document.getElementById('growing-grid'),
growingEmptyState: document.getElementById('growing-empty-state'),
// Advanced: Conform panel
exportConformBtn: document.getElementById('export-conform-btn'),
exportConformOverlay: document.getElementById('export-conform-overlay'),
exportConformPanel: document.getElementById('export-conform-panel'),
exportConformCloseBtn: document.getElementById('export-conform-close-btn'),
exportConformCancelBtn:document.getElementById('export-conform-cancel-btn'),
exportConformStartBtn: document.getElementById('export-conform-start-btn'),
presetCards: document.getElementById('preset-cards'),
conformCodec: document.getElementById('conform-codec'),
conformQuality: document.getElementById('conform-quality'),
conformResolution: document.getElementById('conform-resolution'),
conformAudio: document.getElementById('conform-audio'),
conformClipInfo: document.getElementById('conform-clip-info'),
// Advanced: Relink panel
fetchRelinkBtn: document.getElementById('fetch-relink-btn'),
relinkOverlay: document.getElementById('relink-overlay'),
relinkPanel: document.getElementById('relink-panel'),
relinkCloseBtn: document.getElementById('relink-close-btn'),
relinkCancelBtn: document.getElementById('relink-cancel-btn'),
relinkStartBtn: document.getElementById('relink-start-btn'),
clipList: document.getElementById('clip-list'),
relinkSummary: document.getElementById('relink-summary'),
relinkSummaryText: document.getElementById('relink-summary-text'),
relinkSummaryDetail: document.getElementById('relink-summary-detail'),
};
}
// ============================================================================
// Thumbnail Lazy Loading
// ============================================================================
const thumbObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
const assetId = img.dataset.assetId;
if (assetId && !img.dataset.loaded) loadThumbnail(img, assetId);
thumbObserver.unobserve(img);
}
});
}, { rootMargin: '100px' });
async function loadThumbnail(img, assetId) {
if (state.thumbCache[assetId]) {
img.src = state.thumbCache[assetId];
img.dataset.loaded = '1';
return;
}
try {
const r = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/thumbnail`, {
headers: { Accept: 'application/json' },
});
if (!r.ok) return;
const { url } = await r.json();
if (!url) return;
state.thumbCache[assetId] = url;
img.src = url;
img.dataset.loaded = '1';
} catch (_) {}
}
// ============================================================================
// Initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', () => {
initDOMElements();
setupEventListeners();
restoreSettings();
logMessage('Wild Dragon MAM panel initialized');
});
function setupEventListeners() {
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
elements.serverUrlInput.addEventListener('input', (e) => {
state.serverUrl = e.target.value.trim().replace(/\/$/, '');
localStorage.setItem('mam_server_url', state.serverUrl);
state.thumbCache = {};
});
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
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);
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
// Tabs
elements.tabLibrary.addEventListener('click', () => switchTab('library'));
elements.tabGrowing.addEventListener('click', () => switchTab('growing'));
elements.growingGrid.addEventListener('click', handleAssetClick);
// Advanced: Conform panel
elements.exportConformBtn.addEventListener('click', showAdvancedExportPanel);
elements.exportConformCloseBtn.addEventListener('click', hideAdvancedExportPanel);
elements.exportConformCancelBtn.addEventListener('click', hideAdvancedExportPanel);
elements.exportConformStartBtn.addEventListener('click', startConformFromPanel);
elements.presetCards.addEventListener('click', handlePresetSelection);
// Advanced: Relink panel
elements.fetchRelinkBtn.addEventListener('click', fetchAndRelinkAll);
elements.relinkCloseBtn.addEventListener('click', hideRelinkPanel);
elements.relinkCancelBtn.addEventListener('click', hideRelinkPanel);
elements.relinkStartBtn.addEventListener('click', startBatchRelink);
}
function restoreSettings() {
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
state.serverUrl = state.serverUrl.replace(/\/+$/, '');
localStorage.setItem('mam_server_url', state.serverUrl);
elements.serverUrlInput.value = state.serverUrl;
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
elements.apiTokenInput.value = state.apiToken;
}
// ============================================================================
// Auth: inject Bearer token
fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12 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>
2026-05-23 19:24:10 -04:00
// ============================================================================
const _originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
const url = typeof input === 'string' ? input : (input && input.url) || '';
if (state.apiToken && state.serverUrl && url.startsWith(state.serverUrl)) {
const headers = new Headers(init.headers || {});
if (!headers.has('Authorization')) {
headers.set('Authorization', 'Bearer ' + state.apiToken);
}
init.headers = headers;
}
return _originalFetch(input, init);
};
// ============================================================================
// Server Connection
// ============================================================================
async function connectToServer() {
if (state.isConnecting) return;
state.isConnecting = true;
updateConnectionStatus('connecting');
elements.connectBtn.disabled = true;
try {
const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (response.ok) {
state.isConnected = true;
updateConnectionStatus('connected');
elements.connectBtn.textContent = 'Reconnect';
logMessage('Connected to Wild Dragon MAM');
const projectData = await response.json();
await fetchProjects(projectData);
await fetchAssets();
refreshCurrentSequenceInfo();
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
startGrowingPoll();
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('Connection error:', error);
state.isConnected = false;
updateConnectionStatus('disconnected');
elements.connectBtn.textContent = 'Connect';
showErrorMessage(`Failed to connect: ${error.message}`);
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
stopGrowingPoll();
} finally {
state.isConnecting = false;
elements.connectBtn.disabled = false;
}
}
function updateConnectionStatus(status) {
const indicator = elements.statusIndicator;
indicator.classList.remove('connected', 'connecting');
if (status === 'connected') indicator.classList.add('connected');
else if (status === 'connecting') indicator.classList.add('connecting');
}
// ============================================================================
// API Calls
// ============================================================================
async function fetchProjects(preloadedData) {
try {
let projects;
if (preloadedData) {
projects = Array.isArray(preloadedData) ? preloadedData : [];
} else {
const response = await fetch(`${state.serverUrl}/api/v1/projects`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
projects = Array.isArray(data) ? data : [];
}
state.projects = projects;
const savedProject = state.selectedProject;
elements.projectFilter.innerHTML = '<option value="all">All Projects</option>';
state.projects.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
elements.projectFilter.appendChild(opt);
});
elements.projectFilter.value = savedProject;
populateExportProjectSelect();
logMessage(`Loaded ${state.projects.length} projects`);
} catch (error) {
console.error('Error fetching projects:', error);
}
}
async function fetchAssets(page = 0) {
if (!state.isConnected) return;
try {
const params = new URLSearchParams({
offset: page * state.pageSize,
limit: state.pageSize,
});
if (state.searchQuery) params.append('search', state.searchQuery);
if (state.selectedProject !== 'all') params.append('project_id', state.selectedProject);
const response = await fetch(
`${state.serverUrl}/api/v1/assets?${params.toString()}`,
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
state.assets = data.assets || [];
state.totalAssets = data.total || 0;
state.currentPage = page;
renderAssets();
logMessage(`Loaded ${state.assets.length} assets`);
} catch (error) {
console.error('Error fetching assets:', error);
showErrorMessage('Failed to fetch assets');
}
}
async function fetchAssetDetails(assetId) {
if (!state.isConnected) return null;
try {
const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error('Error fetching asset details:', error);
return null;
}
}
async function getSignedDownloadUrl(assetId) {
const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/stream`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) throw new Error(`HTTP ${response.status} from /stream`);
const { url } = await response.json();
if (!url) throw new Error('Stream endpoint returned no URL');
return url.startsWith('/') ? state.serverUrl + url : url;
}
async function getHiresDownloadInfo(assetId) {
const response = await fetch(`${state.serverUrl}/api/v1/assets/${assetId}/hires`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) throw new Error(`HTTP ${response.status} from /hires`);
const data = await response.json();
if (!data.url) throw new Error('Hi-res endpoint returned no URL');
return data;
}
// ============================================================================
// UI Rendering
// ============================================================================
function renderAssets() {
elements.assetGrid.innerHTML = '';
if (state.assets.length === 0) {
elements.emptyState.style.display = 'flex';
return;
}
elements.emptyState.style.display = 'none';
state.assets.forEach((asset) => {
elements.assetGrid.appendChild(createAssetCard(asset));
});
}
function createAssetCard(asset) {
const card = document.createElement('div');
card.className = 'asset-card';
if (state.selectedAsset && state.selectedAsset.id === asset.id) {
card.classList.add('selected');
}
card.dataset.assetId = asset.id;
const thumbnail = document.createElement('div');
thumbnail.className = 'asset-thumbnail';
const img = document.createElement('img');
img.dataset.assetId = asset.id;
img.alt = escapeHtml(asset.display_name || asset.filename || '');
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
img.onerror = () => { img.style.display = 'none'; };
thumbnail.appendChild(img);
thumbObserver.observe(img);
card.appendChild(thumbnail);
const info = document.createElement('div');
info.className = 'asset-info';
const name = asset.display_name || asset.filename || 'Untitled';
const filenameEl = document.createElement('div');
filenameEl.className = 'asset-filename';
filenameEl.title = name;
filenameEl.textContent = name;
info.appendChild(filenameEl);
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
let durationSec = asset.duration_ms ? asset.duration_ms / 1000 : null;
if (asset.status === 'live' && asset.created_at) {
durationSec = Math.floor((Date.now() - Date.parse(asset.created_at)) / 1000);
}
const codec = asset.codec || asset.media_type || 'video';
const meta = document.createElement('div');
meta.className = 'asset-meta';
meta.innerHTML = [
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
'<span>' + (durationSec ? formatDuration(durationSec) : 'LIVE') + '</span>',
'<span>' + escapeHtml(codec.toUpperCase()) + '</span>',
].join('');
info.appendChild(meta);
const statusStr = asset.status || 'ready';
const statusBadge = document.createElement('div');
statusBadge.className = 'asset-status-badge status-badge ' + statusStr;
statusBadge.textContent = statusStr.toUpperCase();
info.appendChild(statusBadge);
card.appendChild(info);
return card;
}
function showAssetDetails(asset) {
state.selectedAsset = asset;
elements.detailsPanel.classList.remove('hidden');
elements.detailsFilename.textContent = asset.display_name || asset.filename;
elements.detailsCodec.textContent = asset.codec || asset.media_type || 'Unknown';
elements.detailsResolution.textContent = asset.resolution || 'N/A';
elements.detailsFps.textContent = asset.fps ? asset.fps + ' fps' : 'N/A';
elements.detailsDuration.textContent = asset.duration_ms
? formatDuration(asset.duration_ms / 1000)
: 'N/A';
elements.detailsSize.textContent = asset.file_size
? formatFileSize(asset.file_size)
: 'N/A';
elements.detailsTags.innerHTML = '';
const tags = asset.tags || [];
if (tags.length > 0) {
tags.forEach((tag) => {
const el = document.createElement('span');
el.className = 'tag';
el.textContent = tag;
elements.detailsTags.appendChild(el);
});
} else {
elements.detailsTags.innerHTML =
'<span style="color:var(--text-tertiary)">No tags</span>';
}
var isLive = asset.status === 'live';
elements.importBtn.disabled = isLive;
elements.importHiresBtn.disabled = isLive;
elements.mountLiveBtn.disabled = !isLive;
elements.relinkBtn.disabled = !(asset.status === 'ready' && state.importedAssets['live:' + asset.id]);
}
function hideAssetDetails() {
state.selectedAsset = null;
elements.detailsPanel.classList.add('hidden');
elements.importBtn.disabled = true;
elements.importHiresBtn.disabled = true;
elements.mountLiveBtn.disabled = true;
elements.relinkBtn.disabled = true;
}
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
// ============================================================================
// Tabs and Growing Assets Polling
// ============================================================================
function switchTab(tabName) {
if (!state.isConnected) return;
state.currentTab = tabName;
elements.tabLibrary.classList.toggle('active', tabName === 'library');
elements.tabGrowing.classList.toggle('active', tabName === 'growing');
elements.libraryContainer.classList.toggle('hidden', tabName !== 'library');
elements.growingContainer.classList.toggle('hidden', tabName !== 'growing');
hideAssetDetails();
if (tabName === 'library') {
fetchAssets();
} else {
pollGrowingAssets();
}
}
function startGrowingPoll() {
stopGrowingPoll();
pollGrowingAssets();
state.growingPollInterval = setInterval(pollGrowingAssets, 5000);
}
function stopGrowingPoll() {
if (state.growingPollInterval) {
clearInterval(state.growingPollInterval);
state.growingPollInterval = null;
}
}
async function pollGrowingAssets() {
if (!state.isConnected) return;
try {
const params = new URLSearchParams({
limit: 100
});
if (state.selectedProject !== 'all') {
params.append('project_id', state.selectedProject);
}
if (state.searchQuery) {
params.append('search', state.searchQuery);
}
const response = await fetch(`${state.serverUrl}/api/v1/assets?${params.toString()}`, {
headers: { Accept: 'application/json' },
credentials: 'include'
});
if (!response.ok) return;
const data = await response.json();
const allAssets = data.assets || [];
state.growingAssets = allAssets.filter(a =>
a.status === 'live' ||
a.status === 'ingesting' ||
a.status === 'processing' ||
(a.status === 'ready' && state.importedAssets['live:' + a.id])
);
const count = state.growingAssets.filter(a => a.status === 'live' || a.status === 'ingesting' || a.status === 'processing').length;
elements.growingCount.textContent = count;
elements.growingCount.style.display = count > 0 ? 'inline-block' : 'none';
if (state.currentTab === 'growing') {
renderGrowingAssets();
if (state.selectedAsset) {
const updatedSelected = state.growingAssets.find(a => a.id === state.selectedAsset.id);
if (updatedSelected) {
showAssetDetails(updatedSelected);
} else {
const latest = await fetchAssetDetails(state.selectedAsset.id);
if (latest) {
showAssetDetails(latest);
}
}
}
}
} catch (error) {
console.error('Error polling growing assets:', error);
}
}
function renderGrowingAssets() {
elements.growingGrid.innerHTML = '';
if (state.growingAssets.length === 0) {
elements.growingEmptyState.style.display = 'flex';
return;
}
elements.growingEmptyState.style.display = 'none';
state.growingAssets.forEach((asset) => {
elements.growingGrid.appendChild(createAssetCard(asset));
});
}
// ============================================================================
// Mount Live
// ============================================================================
async function mountLiveAsset() {
if (!state.selectedAsset) {
showErrorMessage('No asset selected');
return;
}
var asset = state.selectedAsset;
try {
elements.mountLiveBtn.disabled = true;
showProgress('Resolving SMB path…', 10);
var res = await fetch(state.serverUrl + '/api/v1/assets/' + asset.id + '/live-path', {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!res.ok) {
var body = await res.json().catch(function () { return {}; });
throw new Error(body.error || ('HTTP ' + res.status));
}
var info = await res.json();
var isMac = navigator.platform.indexOf('Mac') !== -1;
var hostPath = isMac ? info.posix_path : info.win_path;
showProgress('Importing live file…', 60);
await importFileToPremiereProject(hostPath);
state.importedAssets['live:' + asset.id] = {
assetId: asset.id,
displayName: info.display_name,
livePath: hostPath,
};
try { localStorage.setItem('mam_imported_assets', JSON.stringify(state.importedAssets)); } catch (_) {}
startLiveStatusPoll(asset.id);
hideProgress();
showSuccessMessage('Mounted live: ' + info.display_name);
} catch (err) {
hideProgress();
showErrorMessage('Mount live failed: ' + err.message);
} finally {
elements.mountLiveBtn.disabled = !(state.selectedAsset && state.selectedAsset.status === 'live');
}
}
var _livePolls = {};
function startLiveStatusPoll(assetId) {
if (_livePolls[assetId]) return;
_livePolls[assetId] = setInterval(async function () {
try {
var r = await fetch(state.serverUrl + '/api/v1/assets/' + assetId, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!r.ok) return;
var a = await r.json();
if (a.status === 'ready') {
clearInterval(_livePolls[assetId]);
delete _livePolls[assetId];
logMessage('Live asset ' + assetId + ' finalized — relink available');
if (state.selectedAsset && state.selectedAsset.id === assetId) {
state.selectedAsset = a;
showAssetDetails(a);
}
_showFlash('"' + (a.display_name || assetId) + '" finalized — click Relink to swap to hi-res', 'info-message');
}
} catch (_) {}
}, 5000);
}
async function relinkSelectedAsset() {
if (!state.selectedAsset) return;
var asset = state.selectedAsset;
var entry = state.importedAssets['live:' + asset.id];
if (!entry) {
showErrorMessage('No live mount recorded for this asset');
return;
}
try {
elements.relinkBtn.disabled = true;
showProgress('Fetching hi-res link…', 10);
var hires = await getHiresDownloadInfo(asset.id);
var safeName = sanitizeFilename(hires.filename || (asset.display_name || asset.id) + '.mov');
showProgress('Downloading hi-res' + (hires.file_size ? ' (' + formatFileSize(hires.file_size) + ')' : '') + '…', 20);
var localPath = await downloadFile(hires.url, safeName);
showProgress('Relinking in Premiere…', 85);
await relinkInPremiere(entry.livePath, localPath);
saveImportMapping(localPath, safeName, asset);
hideProgress();
showSuccessMessage('Relinked to hi-res: ' + safeName);
} catch (err) {
hideProgress();
showErrorMessage('Relink failed: ' + err.message);
} finally {
elements.relinkBtn.disabled = false;
}
}
function relinkInPremiere(oldPath, newPath) {
var oldEsc = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var newEsc = newPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var script = [
'(function () {',
' var out = { success: false, relinked: 0, message: "" };',
' try {',
' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }',
' var oldPath = "' + oldEsc + '";',
' var newPath = "' + newEsc + '";',
' function walk(item) {',
' for (var i = 0; i < item.children.numItems; i++) {',
' var c = item.children[i];',
' if (c.type === 1 || c.type === 2) {',
' if (c.getMediaPath() === oldPath) {',
' c.changeMediaPath(newPath);',
' out.relinked++;',
' }',
' }',
' if (c.children && c.children.numItems > 0) walk(c);',
' }',
' }',
' walk(app.project.rootItem);',
' out.success = out.relinked > 0;',
' out.message = out.relinked + " clip(s) relinked";',
' } catch (e) { out.message = e.message; }',
' return JSON.stringify(out);',
'})();',
].join('\n');
return new Promise(function (resolve, reject) {
csInterface.evalScript(script, function (resultStr) {
try {
var parsed = JSON.parse(resultStr);
if (parsed.success) resolve(parsed);
else reject(new Error(parsed.message || 'relink found no matching clips'));
} catch (e) {
reject(new Error('ExtendScript error: ' + resultStr));
}
});
});
}
// ============================================================================
// Search and Filter
// ============================================================================
function handleSearch(e) {
state.searchQuery = e.target.value;
state.currentPage = 0;
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
if (state.currentTab === 'library') {
fetchAssets();
} else {
pollGrowingAssets();
}
}
function handleProjectFilter(e) {
state.selectedProject = e.target.value;
state.currentPage = 0;
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
if (state.currentTab === 'library') {
fetchAssets();
} else {
pollGrowingAssets();
}
}
// ============================================================================
// Import — Proxy
// ============================================================================
async function importSelectedAsset() {
if (!state.selectedAsset) {
showErrorMessage('No asset selected');
return;
}
await importAsset(state.selectedAsset);
}
async function importAllAssets() {
if (state.assets.length === 0) {
showErrorMessage('No assets to import');
return;
}
for (const asset of state.assets) {
await importAsset(asset);
}
showSuccessMessage('All assets imported');
}
async function importAsset(asset) {
try {
elements.importBtn.disabled = true;
showProgress('Getting download link...', 5);
const url = await getSignedDownloadUrl(asset.id);
const safeName = sanitizeFilename(
(asset.display_name || asset.filename || asset.id) + '.mp4'
);
showProgress('Downloading ' + safeName + '...', 10);
const filePath = await downloadFile(url, safeName);
showProgress('Importing into Premiere Pro...', 85);
await importFileToPremiereProject(filePath);
saveImportMapping(filePath, safeName, asset);
hideProgress();
showSuccessMessage('Imported: ' + safeName);
} catch (error) {
console.error('Import error:', error);
hideProgress();
showErrorMessage('Import failed: ' + error.message);
} finally {
elements.importBtn.disabled = !state.selectedAsset;
}
}
// ============================================================================
// Import — Hi-Res Original
// ============================================================================
async function importSelectedAssetHires() {
if (!state.selectedAsset) {
showErrorMessage('No asset selected');
return;
}
await importAssetHires(state.selectedAsset);
}
async function importAssetHires(asset) {
try {
elements.importHiresBtn.disabled = true;
showProgress('Getting hi-res link...', 5);
let hiresInfo;
try {
hiresInfo = await getHiresDownloadInfo(asset.id);
} catch (err) {
throw new Error('No hi-res source: ' + err.message);
}
const sizeNote = hiresInfo.file_size ? ' (' + formatFileSize(hiresInfo.file_size) + ')' : '';
const safeName = sanitizeFilename(
hiresInfo.filename || (asset.display_name || asset.id) + '.mxf'
);
showProgress('Downloading hi-res' + sizeNote + '...', 10);
const filePath = await downloadFile(hiresInfo.url, safeName);
showProgress('Importing hi-res into Premiere Pro...', 85);
await importFileToPremiereProject(filePath);
saveImportMapping(filePath, safeName, asset);
hideProgress();
showSuccessMessage('Hi-res imported: ' + safeName);
} catch (error) {
console.error('Hi-res import error:', error);
hideProgress();
showErrorMessage('Hi-res import failed: ' + error.message);
} finally {
elements.importHiresBtn.disabled = !state.selectedAsset;
}
}
function saveImportMapping(filePath, safeName, asset) {
const entry = {
assetId: asset.id,
displayName: asset.display_name || asset.filename || '',
};
state.importedAssets[filePath] = entry;
state.importedAssets['name:' + safeName] = entry;
try {
localStorage.setItem('mam_imported_assets', JSON.stringify(state.importedAssets));
} catch (_) {}
}
// ============================================================================
// Active Sequence Info Bar
// ============================================================================
function refreshCurrentSequenceInfo() {
csInterface.evalScript('getActiveSequence()', function (resultStr) {
try {
var parsed = JSON.parse(resultStr);
state.currentSequenceName = parsed.sequenceName || '';
if (state.currentSequenceName) {
elements.seqInfoName.textContent = state.currentSequenceName;
elements.seqInfoBar.classList.remove('hidden');
} else {
elements.seqInfoBar.classList.add('hidden');
}
} catch (e) {
elements.seqInfoBar.classList.add('hidden');
}
});
}
// ============================================================================
// Export Panel UI (Push Timeline to MAM)
// ============================================================================
function populateExportProjectSelect() {
elements.exportProjSelect.innerHTML = '<option value="">— Select project —</option>';
state.projects.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === state.selectedProject) opt.selected = true;
elements.exportProjSelect.appendChild(opt);
});
}
function showExportPanel(timelineData) {
elements.exportSeqName.value = timelineData.sequenceName || 'Sequence 1';
populateExportProjectSelect();
var totalClips = (timelineData.clips || []).length;
var matchedClips = resolveClipsToAssets(timelineData.clips || []).filter(function (c) {
return c.asset_id;
}).length;
elements.exportClipInfo.textContent =
matchedClips + ' of ' + totalClips + ' clip(s) matched to MAM assets';
elements.exportClipInfo.style.color =
matchedClips === 0 ? 'var(--status-red)' : 'var(--text-secondary)';
elements.exportPanel.dataset.timelineJson = JSON.stringify(timelineData);
elements.exportPanel.classList.remove('hidden');
state.exportPanelVisible = true;
elements.exportSeqName.focus();
}
function hideExportPanel() {
elements.exportPanel.classList.add('hidden');
state.exportPanelVisible = false;
}
// ============================================================================
// Timeline Export — Premiere → MAM
// ============================================================================
async function startExportTimeline() {
if (!state.isConnected) {
showErrorMessage('Connect to MAM first');
return;
}
if (state.exportPanelVisible) {
hideExportPanel();
return;
}
showProgress('Reading Premiere timeline...', 20);
const timelineData = await new Promise(function (resolve) {
csInterface.evalScript('exportTimelineData()', function (resultStr) {
try { resolve(JSON.parse(resultStr)); }
catch (e) { resolve({ success: false, message: 'Parse error: ' + e.message, clips: [] }); }
});
});
hideProgress();
if (!timelineData.success) {
showErrorMessage('Timeline read failed: ' + (timelineData.message || 'unknown error'));
return;
}
if (!timelineData.clips || timelineData.clips.length === 0) {
showErrorMessage('No clips found in the active sequence');
return;
}
showExportPanel(timelineData);
}
async function confirmExportTimeline() {
var timelineData;
try {
timelineData = JSON.parse(elements.exportPanel.dataset.timelineJson || '{}');
} catch (e) {
showErrorMessage('Invalid timeline data — try reading again');
return;
}
var seqName = (elements.exportSeqName.value || '').trim() || 'Sequence 1';
var projectId = elements.exportProjSelect.value;
if (!projectId) {
showErrorMessage('Select a target project');
return;
}
var resolved = resolveClipsToAssets(timelineData.clips || []);
var matched = resolved.filter(function (c) { return c.asset_id; });
if (matched.length === 0) {
hideExportPanel();
showErrorMessage('No clips matched MAM assets — import proxies or hi-res first');
return;
}
hideExportPanel();
showProgress('Creating sequence in MAM...', 20);
try {
var seqId = await upsertSequence(
projectId,
seqName,
timelineData.frameRate || 59.94,
timelineData.width || 1920,
timelineData.height || 1080
);
showProgress('Writing ' + matched.length + ' clip(s)...', 60);
var clipPayload = matched.map(function (c) {
return {
asset_id: c.asset_id,
track: c.trackIndex,
timeline_in_frames: c.timelineInFrames,
timeline_out_frames: c.timelineOutFrames,
source_in_frames: c.sourceInFrames,
source_out_frames: c.sourceOutFrames,
};
});
var clipsRes = await fetch(
state.serverUrl + '/api/v1/sequences/' + seqId + '/clips',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify(clipPayload),
}
);
if (!clipsRes.ok) throw new Error('Clip push failed: HTTP ' + clipsRes.status);
hideProgress();
showSuccessMessage('Timeline pushed: ' + matched.length + ' clip(s) → "' + seqName + '"');
var skipped = resolved.length - matched.length;
if (skipped > 0) {
_showFlash(skipped + ' clip(s) skipped — not in MAM (import them first)', 'info-message');
}
} catch (err) {
hideProgress();
showErrorMessage('Export failed: ' + err.message);
}
}
function cancelExportTimeline() {
hideExportPanel();
}
function resolveClipsToAssets(clips) {
return clips.map(function (clip) {
var entry = state.importedAssets[clip.filePath];
if (!entry) entry = state.importedAssets['name:' + clip.fileName];
return Object.assign({}, clip, { asset_id: entry ? entry.assetId : null });
});
}
async function upsertSequence(projectId, name, frameRate, width, height) {
var listRes = await fetch(
state.serverUrl + '/api/v1/sequences?project_id=' + encodeURIComponent(projectId),
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
if (listRes.ok) {
var seqs = await listRes.json();
var existing = seqs.find(function (s) { return s.name === name; });
if (existing) {
await fetch(state.serverUrl + '/api/v1/sequences/' + existing.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ frame_rate: frameRate, width: width, height: height }),
});
return existing.id;
}
}
var createRes = await fetch(state.serverUrl + '/api/v1/sequences', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({
project_id: projectId,
name: name,
frame_rate: frameRate,
width: width,
height: height,
}),
});
if (!createRes.ok) throw new Error('Failed to create sequence: HTTP ' + createRes.status);
var seq = await createRes.json();
return seq.id;
}
// ============================================================================
// #30 — FCP XML Export & Conform
// ============================================================================
async function showAdvancedExportPanel() {
if (!state.isConnected) {
showErrorMessage('Connect to MAM first');
return;
}
if (state.conformPanelVisible) {
hideAdvancedExportPanel();
return;
}
showProgress('Reading Premiere timeline...', 20);
const timelineData = await new Promise(function (resolve) {
csInterface.evalScript('exportTimelineData()', function (resultStr) {
try { resolve(JSON.parse(resultStr)); }
catch (e) { resolve({ success: false, message: 'Parse error: ' + e.message, clips: [] }); }
});
});
hideProgress();
if (!timelineData.success) {
showErrorMessage('Timeline read failed: ' + (timelineData.message || 'unknown error'));
return;
}
if (!timelineData.clips || timelineData.clips.length === 0) {
showErrorMessage('No clips found in the active sequence');
return;
}
state.timelineData = timelineData;
// Update clip info in panel
var totalClips = timelineData.clips.length;
var matchedClips = resolveClipsToAssets(timelineData.clips).filter(function (c) {
return c.asset_id;
}).length;
elements.conformClipInfo.textContent = matchedClips + ' of ' + totalClips + ' clip(s) matched';
// Enable start button only if some clips are matched
elements.exportConformStartBtn.disabled = matchedClips === 0;
// Apply preset defaults
applyPreset(state.selectedPreset);
openSlidePanel(
elements.exportConformOverlay,
elements.exportConformPanel
);
state.conformPanelVisible = true;
}
function hideAdvancedExportPanel() {
closeSlidePanel(
elements.exportConformOverlay,
elements.exportConformPanel
);
state.conformPanelVisible = false;
if (state.conformPollTimer) {
clearInterval(state.conformPollTimer);
state.conformPollTimer = null;
}
}
function handlePresetSelection(e) {
var card = e.target.closest('.preset-card');
if (!card) return;
document.querySelectorAll('.preset-card').forEach(function (el) {
el.classList.remove('selected');
});
card.classList.add('selected');
var preset = card.dataset.preset;
state.selectedPreset = preset;
applyPreset(preset);
}
function applyPreset(preset) {
switch (preset) {
case 'broadcast':
elements.conformCodec.value = 'prores_hq';
elements.conformQuality.value = 'high';
elements.conformResolution.value = '1080p';
elements.conformAudio.value = 'broadcast';
break;
case 'web':
elements.conformCodec.value = 'h264';
elements.conformQuality.value = 'medium';
elements.conformResolution.value = '1080p';
elements.conformAudio.value = 'web';
break;
case 'archive':
elements.conformCodec.value = 'prores_4444';
elements.conformQuality.value = 'high';
elements.conformResolution.value = 'uhd';
elements.conformAudio.value = 'archive';
break;
case 'custom':
// Leave current selections as-is
break;
}
}
async function startConformFromPanel() {
if (!state.timelineData) {
showErrorMessage('No timeline data — re-open the panel');
return;
}
elements.exportConformStartBtn.disabled = true;
showProgress('Generating FCP XML...', 10);
try {
var fcpXml = generateFcpXml(state.timelineData);
var codec = elements.conformCodec.value;
var quality = elements.conformQuality.value;
var resolution = elements.conformResolution.value;
var audio = elements.conformAudio.value;
showProgress('Starting conform job...', 30);
var job = await startConformJob(fcpXml, codec, quality, resolution, audio);
hideAdvancedExportPanel();
showProgress('Conform job started — polling...', 40);
state.conformJobId = job.jobId;
pollConformProgress(job.jobId);
} catch (err) {
hideProgress();
showErrorMessage('Conform failed: ' + err.message);
elements.exportConformStartBtn.disabled = false;
}
}
function generateFcpXml(timelineData) {
var seqName = escapeXml(timelineData.sequenceName || 'Sequence 1');
var frameRate = timelineData.frameRate || 29.97;
var width = timelineData.width || 1920;
var height = timelineData.height || 1080;
var clips = timelineData.clips || [];
// Calculate total duration from clips
var totalFrames = 0;
clips.forEach(function (c) {
var end = c.timelineOutFrames || 0;
if (end > totalFrames) totalFrames = end;
});
if (totalFrames < 1) totalFrames = 100;
var duration = timecodeFromFrames(totalFrames, frameRate);
var frameRateStr = formatFrameRate(frameRate);
var xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE fcpxml>\n';
xml += '<fcpxml version="1.10">\n';
xml += ' <resources>\n';
// Build a resource for each unique source
var seen = {};
var resourceId = 1;
clips.forEach(function (clip) {
var key = clip.filePath || clip.fileName || 'clip_' + resourceId;
if (!seen[key]) {
seen[key] = true;
var name = escapeXml(clip.fileName || 'Clip ' + resourceId);
var path = escapeXml(clip.filePath || '');
var srcDur = timecodeFromFrames(
(clip.sourceOutFrames || 100) - (clip.sourceInFrames || 0),
frameRate
);
xml += ' <asset id="r' + resourceId + '" name="' + name + '" src="' + path + '" duration="' + srcDur + '" start="' + timecodeFromFrames(0, frameRate) + '" format="r0"/>\n';
resourceId++;
}
});
xml += ' <format id="r0" frameDuration="' + frameRateStr + '" width="' + width + '" height="' + height + '"/>\n';
xml += ' </resources>\n';
xml += ' <library>\n';
xml += ' <event name="Conform Export">\n';
xml += ' <project name="' + seqName + '" duration="' + duration + '">\n';
xml += ' <sequence duration="' + duration + '" format="r0">\n';
xml += ' <spine>\n';
// Resolve clip paths to asset IDs and build track layout
var resolvedClips = resolveClipsToAssets(clips);
var trackGroups = {};
resolvedClips.forEach(function (clip) {
var track = clip.trackIndex !== undefined ? clip.trackIndex : 0;
if (!trackGroups[track]) trackGroups[track] = [];
trackGroups[track].push(clip);
});
var trackKeys = Object.keys(trackGroups).sort();
if (trackKeys.length === 0) trackKeys = ['0'];
trackKeys.forEach(function (trackKey) {
var trackClips = trackGroups[trackKey];
trackClips.forEach(function (clip) {
var name = escapeXml(clip.fileName || 'Clip');
var tcStart = timecodeFromFrames(clip.timelineInFrames || 0, frameRate);
var dur = timecodeFromFrames(
(clip.timelineOutFrames || clip.sourceOutFrames || 100) - (clip.timelineInFrames || 0),
frameRate
);
var srcStart = timecodeFromFrames(clip.sourceInFrames || 0, frameRate);
// Find the resource ID
var rid = 1;
var seen2 = {};
var count = 0;
resolvedClips.forEach(function (c) {
var key = c.filePath || c.fileName || 'clip_';
if (!seen2[key]) {
seen2[key] = true;
count++;
if (key === (clip.filePath || clip.fileName || '')) {
rid = count;
}
}
});
xml += ' <clip name="' + name + '" offset="' + tcStart + '" duration="' + dur + '" start="' + srcStart + '" ref="r' + rid + '"/>\n';
});
});
xml += ' </spine>\n';
xml += ' </sequence>\n';
xml += ' </project>\n';
xml += ' </event>\n';
xml += ' </library>\n';
xml += '</fcpxml>\n';
return xml;
}
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function formatFrameRate(fps) {
// FCP XML uses rational time scale: e.g. 100/2997 for 29.97fps
var tolerance = 0.001;
if (Math.abs(fps - 23.976) < tolerance || Math.abs(fps - 23.98) < tolerance) return '1001/24000';
if (Math.abs(fps - 24) < tolerance) return '1/24';
if (Math.abs(fps - 25) < tolerance) return '1/25';
if (Math.abs(fps - 29.97) < tolerance) return '1001/30000';
if (Math.abs(fps - 30) < tolerance) return '1/30';
if (Math.abs(fps - 50) < tolerance) return '1/50';
if (Math.abs(fps - 59.94) < tolerance) return '1001/60000';
if (Math.abs(fps - 60) < tolerance) return '1/60';
return '1001/30000'; // default to 29.97
}
function timecodeFromFrames(frames, fps) {
if (fps <= 0) fps = 29.97;
var totalSeconds = frames / fps;
var h = Math.floor(totalSeconds / 3600);
var m = Math.floor((totalSeconds % 3600) / 60);
var s = Math.floor(totalSeconds % 60);
var f = Math.round((totalSeconds - Math.floor(totalSeconds)) * fps);
return pad2(h) + ':' + pad2(m) + ':' + pad2(s) + ':' + pad2(f);
}
function pad2(num) {
return (num < 10 ? '0' : '') + num;
}
async function startConformJob(fcpXml, codec, quality, resolution, audio) {
// Normalize codec value for the worker
var workerCodec = codec;
if (codec === 'prores_hq' || codec === 'prores_4444') workerCodec = 'prores';
// Normalize resolution value for the worker
var workerResolution = resolution;
if (resolution === 'uhd') workerResolution = '3840x2160';
else if (resolution === '1080p') workerResolution = '1920x1080';
else if (resolution === '720p') workerResolution = '1280x720';
// Create or find the sequence in MAM (same flow as export timeline)
var timelineData = state.timelineData;
var seqName = (timelineData && timelineData.sequenceName) || 'Conformed Sequence';
var frameRate = (timelineData && timelineData.frameRate) || 29.97;
var width = (timelineData && timelineData.width) || 1920;
var height = (timelineData && timelineData.height) || 1080;
var seqId = await upsertSequence(
state.selectedProject,
seqName,
frameRate,
width,
height
);
var response = await fetch(state.serverUrl + '/api/v1/sequences/' + seqId + '/conform', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({
fcp_xml: fcpXml,
codec: workerCodec,
quality: quality,
resolution: workerResolution,
audio: audio,
}),
});
if (!response.ok) {
var errBody = {};
try { errBody = await response.json(); } catch (_) {}
throw new Error(errBody.error || ('HTTP ' + response.status));
}
return await response.json();
}
function pollConformProgress(jobId) {
// jobId is in format "conform:123" — matches GET /api/v1/jobs/:id
var pollInterval = setInterval(async function () {
try {
var response = await fetch(
state.serverUrl + '/api/v1/jobs/' + encodeURIComponent(jobId),
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
if (!response.ok) {
clearInterval(pollInterval);
hideProgress();
showErrorMessage('Conform job status check failed');
return;
}
var status = await response.json();
var apiStatus = status.status || 'waiting';
if (status.progress !== undefined) {
showProgress('Conforming... ' + status.progress + '%', status.progress);
}
if (apiStatus === 'completed') {
clearInterval(pollInterval);
hideProgress();
showSuccessMessage('Conform complete! Asset ready in MAM.');
state.conformPollTimer = null;
// Refresh assets to show the new conformed asset
fetchAssets();
} else if (apiStatus === 'failed') {
clearInterval(pollInterval);
hideProgress();
showErrorMessage('Conform failed: ' + (status.error || 'Unknown error'));
state.conformPollTimer = null;
}
} catch (err) {
// Transient error — keep polling
}
}, 2000);
state.conformPollTimer = pollInterval;
}
// ============================================================================
// #31 — Hi-Res Auto-Relink
// ============================================================================
async function fetchAndRelinkAll() {
if (!state.isConnected) {
showErrorMessage('Connect to MAM first');
return;
}
if (state.relinkPanelVisible) {
hideRelinkPanel();
return;
}
showProgress('Reading Premiere timeline...', 20);
const timelineData = await new Promise(function (resolve) {
// Use the enhanced function that includes clipInstanceId
csInterface.evalScript('exportTimelineDataWithIds()', function (resultStr) {
try { resolve(JSON.parse(resultStr)); }
catch (e) { resolve({ success: false, message: 'Parse error: ' + e.message, clips: [] }); }
});
});
hideProgress();
if (!timelineData.success) {
showErrorMessage('Timeline read failed: ' + (timelineData.message || 'unknown error'));
return;
}
if (!timelineData.clips || timelineData.clips.length === 0) {
showErrorMessage('No clips found in the active sequence');
return;
}
// Resolve clips to MAM assets
var resolved = resolveClipsToAssets(timelineData.clips);
var matched = resolved.filter(function (c) { return c.asset_id; });
if (matched.length === 0) {
showErrorMessage('No timeline clips matched MAM assets — import them first');
return;
}
state.timelineData = timelineData;
state.relinkClips = resolved;
showClipSelection(resolved);
}
function showClipSelection(clips) {
elements.clipList.innerHTML = '';
clips.forEach(function (clip, index) {
var item = document.createElement('div');
item.className = 'clip-list-item';
item.dataset.index = index;
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'clip-list-item-checkbox';
checkbox.checked = !!clip.asset_id;
checkbox.disabled = !clip.asset_id;
var info = document.createElement('div');
info.className = 'clip-list-item-info';
var name = document.createElement('div');
name.className = 'clip-list-item-name';
name.textContent = clip.fileName || 'Unknown clip';
var meta = document.createElement('div');
meta.className = 'clip-list-item-meta';
meta.textContent = clip.asset_id
? 'Track ' + (clip.trackIndex + 1) + ' | MAM asset: ' + clip.asset_id
: 'Track ' + (clip.trackIndex + 1) + ' | Not matched';
info.appendChild(name);
info.appendChild(meta);
var status = document.createElement('div');
status.className = 'clip-list-item-status ' + (clip.asset_id ? 'matched' : 'unmatched');
status.textContent = clip.asset_id ? 'Matched' : 'Unmatched';
item.appendChild(checkbox);
item.appendChild(info);
item.appendChild(status);
elements.clipList.appendChild(item);
});
elements.relinkSummary.classList.add('hidden');
elements.relinkStartBtn.disabled = false;
openSlidePanel(
elements.relinkOverlay,
elements.relinkPanel
);
state.relinkPanelVisible = true;
}
function hideRelinkPanel() {
closeSlidePanel(
elements.relinkOverlay,
elements.relinkPanel
);
state.relinkPanelVisible = false;
state.relinkClips = [];
}
async function startBatchRelink() {
var checkboxes = elements.clipList.querySelectorAll('.clip-list-item-checkbox');
var selectedClips = [];
checkboxes.forEach(function (cb, index) {
if (cb.checked && !cb.disabled) {
var clip = state.relinkClips[index];
if (clip && clip.asset_id) {
selectedClips.push(clip);
}
}
});
if (selectedClips.length === 0) {
showErrorMessage('No clips selected for relink');
return;
}
elements.relinkStartBtn.disabled = true;
showProgress('Requesting batch trim for ' + selectedClips.length + ' clip(s)...', 10);
try {
var segments = await requestBatchTrim(selectedClips);
showProgress('Downloading ' + segments.length + ' segment(s)...', 30);
var results = await downloadAndRelink(segments);
hideProgress();
showRelinkSummary(results);
} catch (err) {
hideProgress();
showErrorMessage('Batch relink failed: ' + err.message);
elements.relinkStartBtn.disabled = false;
}
}
async function requestBatchTrim(clips) {
var payload = clips.map(function (clip) {
return {
assetId: clip.asset_id,
filename: clip.fileName || 'clip.mxf',
trackIndex: clip.trackIndex || 0,
sourceInFrames: clip.sourceInFrames || 0,
sourceOutFrames: clip.sourceOutFrames || 0,
timelineInFrames: clip.timelineInFrames || 0,
timelineOutFrames: clip.timelineOutFrames || 0,
};
});
var response = await fetch(state.serverUrl + '/api/v1/assets/batch-trim', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ clips: payload }),
});
if (!response.ok) {
var errBody = {};
try { errBody = await response.json(); } catch (_) {}
throw new Error(errBody.error || ('HTTP ' + response.status));
}
var data = await response.json();
return data.clips || [];
}
async function downloadAndRelink(segments) {
var results = [];
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
try {
showProgress(
'Getting signed URL for segment ' + (i + 1) + ' of ' + segments.length + '...',
25 + ((i / segments.length) * 50)
);
// Fetch signed URL for the temp segment
var urlRes = await fetch(
state.serverUrl + '/api/v1/assets/temp-segment-url/' + encodeURIComponent(seg.clipInstanceId),
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
if (!urlRes.ok) throw new Error('Segment not ready: HTTP ' + urlRes.status);
var urlData = await urlRes.json();
var safeName = sanitizeFilename('segment_' + seg.clipInstanceId + '.mov');
var localPath = await downloadFile(urlData.url, safeName);
showProgress('Relinking segment ' + (i + 1) + ' in Premiere...', 85);
var relinkResult = await relinkClipToNewMedia(seg.clipInstanceId, localPath);
results.push({
clipName: seg.clipInstanceId || safeName,
success: true,
localPath: localPath,
message: relinkResult.message || 'Relinked',
});
} catch (err) {
results.push({
clipName: seg.clipInstanceId || 'Unknown',
success: false,
localPath: null,
message: err.message,
});
}
}
return results;
}
function relinkClipToNewMedia(clipId, filePath) {
var safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
var script = [
'(function () {',
' var out = { success: false, message: "" };',
' try {',
' if (!app.project) { out.message = "No project open"; return JSON.stringify(out); }',
' var newPath = "' + safePath + '";',
' function walk(item) {',
' for (var i = 0; i < item.children.numItems; i++) {',
' var c = item.children[i];',
' if (c.type === 1 || c.type === 2) {',
' try {',
' c.changeMediaPath(newPath);',
' out.success = true;',
' out.message = "Relinked: " + c.name;',
' return;',
' } catch (e) {}',
' }',
' if (c.children && c.children.numItems > 0) walk(c);',
' }',
' }',
' walk(app.project.rootItem);',
' if (!out.success) out.message = "No matching clip found for relink";',
' } catch (e) { out.message = e.message; }',
' return JSON.stringify(out);',
'})();',
].join('\n');
return new Promise(function (resolve, reject) {
csInterface.evalScript(script, function (resultStr) {
try {
var parsed = JSON.parse(resultStr);
resolve(parsed);
} catch (e) {
reject(new Error('ExtendScript error: ' + resultStr));
}
});
});
}
function showRelinkSummary(results) {
var succeeded = results.filter(function (r) { return r.success; }).length;
var failed = results.filter(function (r) { return !r.success; }).length;
elements.relinkSummary.classList.remove('hidden');
elements.relinkSummaryText.textContent = succeeded + ' of ' + results.length + ' clip(s) relinked to hi-res';
var detailParts = [];
if (succeeded > 0) detailParts.push(succeeded + ' succeeded');
if (failed > 0) detailParts.push(failed + ' failed');
elements.relinkSummaryDetail.textContent = detailParts.join(', ') || 'No clips processed';
elements.relinkStartBtn.disabled = false;
_showFlash(results.length + ' clip(s) processed — ' + succeeded + ' relinked, ' + failed + ' failed', 'info-message');
}
// ============================================================================
// #32 — Slide Panel Management
// ============================================================================
function openSlidePanel(overlay, panel) {
overlay.classList.add('open');
panel.classList.add('open');
}
function closeSlidePanel(overlay, panel) {
overlay.classList.remove('open');
panel.classList.remove('open');
}
// ============================================================================
// UI Helpers
// ============================================================================
function handleAssetClick(e) {
var card = e.target.closest('.asset-card');
if (!card) return;
document.querySelectorAll('.asset-card.selected').forEach(function (el) {
el.classList.remove('selected');
});
card.classList.add('selected');
var assetId = card.dataset.assetId;
feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze Root causes found: 1. Scheduler crashing every 15s: assets table has no error_message column. Fix: remove error_message from UPDATE in scheduler.js (#66 regression). 2. Clip freezing: client-side filmstrip seek loop runs on main thread, seeks same proxy the player is streaming → both stall → freeze. Fix: replace browser seek loop entirely with server-side FFmpeg worker. 3. No dedicated filmstrip worker: filmstrip was never pre-built server-side. Changes: - services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql Add filmstrip_s3_key TEXT column to assets table - services/worker/src/workers/filmstrip.js (new) BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract 28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON array to S3 at filmstrips/<assetId>.json, stores key in DB - services/worker/src/workers/thumbnail.js Queue filmstrip job automatically after thumbnail completes - services/worker/src/index.js Register filmstrip worker (concurrency=2), export filmstripQueue singleton, close it on SIGTERM - services/mam-api/src/routes/assets.js - filmstripQueue added - POST /reprocess?type=filmstrip now supported - GET /:id/filmstrip returns signed S3 URL for JSON frames - services/mam-api/src/routes/jobs.js filmstrip queue visible in Jobs UI - services/web-ui/public/screens-asset.jsx Replace browser seek loop with fetch of /assets/:id/filmstrip → fetch S3 JSON → render frames. Zero browser-side video seeking. Right-click and Files tab re-generate via API endpoint.
2026-05-26 12:39:44 -04:00
var asset = state.assets.find(function (a) { return a.id === assetId; }) ||
state.growingAssets.find(function (a) { return a.id === assetId; });
if (asset) showAssetDetails(asset);
}
function showProgress(label, percent) {
elements.progressContainer.classList.add('visible');
elements.progressLabel.textContent = label;
state.downloadProgress = percent;
updateProgressUI();
}
function hideProgress() {
elements.progressContainer.classList.remove('visible');
state.downloadProgress = 0;
updateProgressUI();
}
function updateProgressUI() {
elements.progressFill.style.width = state.downloadProgress + '%';
}
function showErrorMessage(message) {
_showFlash(message, 'error-message');
}
function showSuccessMessage(message) {
_showFlash(message, 'success-message');
}
function _showFlash(message, className) {
var el = document.createElement('div');
el.className = className;
el.textContent = message;
var anchor = document.querySelector('.search-filter-area');
anchor.insertBefore(el, anchor.firstChild);
setTimeout(function () { el.remove(); }, 5000);
}
function logMessage(message) {
console.log('[MAM Panel] ' + message);
}
// ============================================================================
// File Download and Premiere Import
// ============================================================================
function downloadFile(url, filename) {
return new Promise(function (resolve, reject) {
try {
var https = require('https');
var http = require('http');
var fs = require('fs');
var path = require('path');
var os = require('os');
var tempPath = path.join(os.tmpdir(), filename);
var file = fs.createWriteStream(tempPath);
var protocol = url.startsWith('https') ? https : http;
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));
}
});
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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];
}