From 205ef3f50aec1f389733b3f58bed24d2bc7b97a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 19:39:42 -0400 Subject: [PATCH] Add high-res media folder fallback lookup for unmapped proxy files - Added hiresMediaFolder setting to configuration UI - Implement findHighResFileForGves() to search for missing high-res media files - When .prproj has unlinked proxy media (FramelightX didn't populate paths), search configured folder for matches - Modified remapPrproj() to accept options with hiresMediaFolder path - Uses file Title metadata and video/audio extension matching as fallback - Server passes hiresMediaFolder setting when calling remapper --- prproj-remapper.js | 108 ++++++++++++++++++++++++++++++++++++++++++--- public/index.html | 7 +++ server.js | 13 ++++-- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/prproj-remapper.js b/prproj-remapper.js index 5a54cb9..038c267 100644 --- a/prproj-remapper.js +++ b/prproj-remapper.js @@ -31,9 +31,10 @@ const NULL_IMPL_ID = '00000000-0000-0000-0000-000000000000'; * Premiere expects (XML parsers tend to reformat and break .prproj files). * * @param {Buffer} prprojBuffer - The raw .prproj file (gzipped) + * @param {Object} options - Optional config { hiresMediaFolder: string, fsModule?: fs } * @returns {Object} { buffer: Buffer, report: Object } */ -async function remapPrproj(prprojBuffer) { +async function remapPrproj(prprojBuffer, options = {}) { // Step 1: Decompress const xml = zlib.gunzipSync(prprojBuffer).toString('utf-8'); @@ -44,7 +45,7 @@ async function remapPrproj(prprojBuffer) { const proxyLinks = parseProxyLinks(xml, mediaBlocks); // Step 4: Perform the path swaps - const { remappedXml, swaps } = performSwaps(xml, mediaBlocks, proxyLinks); + const { remappedXml, swaps } = performSwaps(xml, mediaBlocks, proxyLinks, options); // Step 5: Recompress const outputBuffer = zlib.gzipSync(Buffer.from(remappedXml, 'utf-8')); @@ -223,11 +224,94 @@ function parseProxyLinks(xml, mediaBlocks) { } /** - * Perform the actual path swaps in the XML string. + * Try to find a high-res file in the media folder when the .prproj doesn't have a path. + * Strategy: Look for files in the high-res media folder that might correspond to this .gves. + * + * For example: + * - .gves file: "01KMR6YXTCJHG0AGMWVF98JRMWC--01.gves" + * - Media blocks may have metadata (Title) like "ZacIsCool--File7" + * - High-res folder has: "ZacIsCool--File7.mp4" + * + * Fallback strategy: + * 1. If hiresBlock has a Title, search for that title in the high-res folder + * 2. Search for common video/audio extensions (.mp4, .mov, .wav, etc.) + * 3. If still not found, return null (path won't be swapped) + * + * @param {string} gvesPath - The .gves file path + * @param {string} hiresTitle - Title from the high-res Media block + * @param {string} hiresMediaFolder - UNC/SMB path to search + * @param {Object} fs - File system module + * @returns {string|null} The high-res file path if found, null otherwise */ -function performSwaps(xml, mediaBlocks, proxyLinks) { +function findHighResFileForGves(gvesPath, hiresTitle, hiresMediaFolder, fs) { + if (!hiresMediaFolder) return null; + + try { + // Normalize the folder path + const searchFolder = hiresMediaFolder.replace(/\\/g, '/').replace(/\/$/, ''); + + // If Title is provided, search for it + if (hiresTitle) { + const videoExts = ['.mp4', '.mov', '.mxf', '.mov', '.wav', '.aif', '.aiff']; + + for (const ext of videoExts) { + const candidate = `${searchFolder}/${hiresTitle}${ext}`; + try { + if (fs.existsSync(candidate)) { + // Convert back to UNC format if it was a local path + return candidate.replace(/\//g, '\\'); + } + } catch (e) { + // Ignore individual file check failures + } + } + } + + // Fallback: list files in the folder and try to match by base name + try { + const files = fs.readdirSync(searchFolder); + const videoExts = new Set(['.mp4', '.mov', '.mxf', '.wav', '.aif', '.aiff']); + + // Extract base name from .gves path (without extension) + const gvesFilename = gvesPath.split('\\').pop().split('/').pop(); + const gvesBaseName = gvesFilename.replace(/\.[^.]+$/, '').toLowerCase(); + + // Look for files that might match + for (const file of files) { + const ext = '.' + file.split('.').pop().toLowerCase(); + if (!videoExts.has(ext)) continue; + + const fileBaseName = file.replace(/\.[^.]+$/, '').toLowerCase(); + + // Partial match: if the file name contains a key part of the gves name + if (fileBaseName.includes(gvesBaseName.substring(0, Math.min(8, gvesBaseName.length)))) { + const fullPath = `${searchFolder}/${file}`; + return fullPath.replace(/\//g, '\\'); + } + } + } catch (e) { + // Folder read failed, return null + return null; + } + } catch (e) { + // Any errors during lookup, return null + return null; + } + + return null; +} + +/** + * Perform the actual path swaps in the XML string. + * @param {string} xml - The project XML + * @param {Object} mediaBlocks - Parsed media blocks + * @param {Object} proxyLinks - Proxy linkage map + * @param {Object} options - { hiresMediaFolder?: string, fsModule?: fs } + */ +function performSwaps(xml, mediaBlocks, proxyLinks, options = {}) { let remappedXml = xml; const swaps = []; + const fs = options.fsModule || require('fs'); // Collect all unique .gves UIDs that have a mapped high-res block const gvesUids = Object.keys(proxyLinks); @@ -237,7 +321,21 @@ function performSwaps(xml, mediaBlocks, proxyLinks) { const gvesBlock = mediaBlocks[gvesUid]; const hiresBlock = mediaBlocks[hiresUid]; - if (!gvesBlock || !hiresBlock || !hiresBlock.filePath) continue; + if (!gvesBlock || !hiresBlock) continue; + + let hiresPath = hiresBlock.filePath; + + // Fallback: if high-res path is null, try to find it in the media folder + if (!hiresPath && options.hiresMediaFolder && gvesBlock.filePath) { + hiresPath = findHighResFileForGves( + gvesBlock.filePath, + hiresBlock.title || gvesBlock.title, + options.hiresMediaFolder, + fs + ); + } + + if (!hiresPath) continue; const hiresPath = hiresBlock.filePath; const hiresTitle = hiresBlock.title || pathToTitle(hiresPath); diff --git a/public/index.html b/public/index.html index 1617136..ba65e6c 100644 --- a/public/index.html +++ b/public/index.html @@ -604,6 +604,11 @@
Path to the folder containing AMEEncodingLog.txt. On macOS: /Users/<user>/Documents/Adobe/Adobe Media Encoder/<version>
+
+ + +
UNC/SMB path where high-resolution source files are stored. When .prproj files reference proxy media that aren't linked in the project, the system will search this folder for matching high-res files. Leave blank to disable fallback lookup.
+
@@ -807,6 +812,7 @@ document.getElementById('setting-watch-folder').value = data.watchFolder || ''; document.getElementById('setting-output-folder').value = data.outputFolder || ''; document.getElementById('setting-ame-log-dir').value = data.ameLogDir || ''; + document.getElementById('setting-hires-media-folder').value = data.hiresMediaFolder || ''; document.getElementById('setting-smb-username').value = data.smbUsername || ''; document.getElementById('setting-smb-password').value = ''; // never pre-fill password document.getElementById('setting-smb-domain').value = data.smbDomain || ''; @@ -824,6 +830,7 @@ watchFolder: document.getElementById('setting-watch-folder').value.trim(), outputFolder: document.getElementById('setting-output-folder').value.trim(), ameLogDir: document.getElementById('setting-ame-log-dir').value.trim(), + hiresMediaFolder: document.getElementById('setting-hires-media-folder').value.trim(), smbUsername: document.getElementById('setting-smb-username').value.trim(), smbPassword: document.getElementById('setting-smb-password').value, // empty = keep existing smbDomain: document.getElementById('setting-smb-domain').value.trim(), diff --git a/server.js b/server.js index 9d53aab..e3f7aa6 100644 --- a/server.js +++ b/server.js @@ -83,7 +83,8 @@ function loadSettings() { smbUsername: '', smbPassword: '', smbDomain: '', - smbNotes: '' + smbNotes: '', + hiresMediaFolder: '' }; } } @@ -154,6 +155,7 @@ app.get('/api/settings', requireAuth, (req, res) => { watchFolder: settings.watchFolder || WATCH_FOLDER, outputFolder: settings.outputFolder || OUTPUT_FOLDER, ameLogDir: settings.ameLogDir || AME_LOG_DIR, + hiresMediaFolder: settings.hiresMediaFolder || '', smbWatchPath: settings.smbWatchPath || '', smbUsername: settings.smbUsername || '', // Return password masked — UI shows whether one is saved without exposing it @@ -171,13 +173,14 @@ app.get('/api/settings', requireAuth, (req, res) => { */ app.post('/api/settings', requireAuth, (req, res) => { const current = loadSettings(); - const { watchFolder, outputFolder, ameLogDir, smbWatchPath, smbUsername, smbPassword, smbDomain, smbNotes } = req.body; + const { watchFolder, outputFolder, ameLogDir, hiresMediaFolder, smbWatchPath, smbUsername, smbPassword, smbDomain, smbNotes } = req.body; const updated = { ...current, ...(watchFolder !== undefined && { watchFolder }), ...(outputFolder !== undefined && { outputFolder }), ...(ameLogDir !== undefined && { ameLogDir }), + ...(hiresMediaFolder !== undefined && { hiresMediaFolder }), ...(smbWatchPath !== undefined && { smbWatchPath }), ...(smbUsername !== undefined && { smbUsername }), // Only update password if a non-empty value was sent (empty = keep existing) @@ -196,6 +199,7 @@ app.post('/api/settings', requireAuth, (req, res) => { watchFolder: updated.watchFolder, outputFolder: updated.outputFolder, ameLogDir: updated.ameLogDir, + hiresMediaFolder: updated.hiresMediaFolder || '', smbWatchPath: updated.smbWatchPath || '', smbUsername: updated.smbUsername || '', smbPasswordSet: !!(updated.smbPassword), @@ -256,7 +260,10 @@ app.post('/api/jobs', requireAuth, upload.single('prproj'), async (req, res) => // Perform the remap let remapResult; try { - remapResult = await remapPrproj(prprojBuffer); + const settings = loadSettings(); + remapResult = await remapPrproj(prprojBuffer, { + hiresMediaFolder: settings.hiresMediaFolder + }); } catch (err) { fs.unlinkSync(uploadedPath); return res.status(500).json({