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
This commit is contained in:
Claude 2026-03-31 19:39:42 -04:00
parent c323872a0f
commit 205ef3f50a
3 changed files with 120 additions and 8 deletions

View file

@ -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);

View file

@ -604,6 +604,11 @@
<input type="text" id="setting-ame-log-dir" placeholder="e.g. /ame-logs or /Users/username/Documents/Adobe/Adobe Media Encoder/25.0">
<div class="settings-help">Path to the folder containing <code>AMEEncodingLog.txt</code>. On macOS: <code>/Users/&lt;user&gt;/Documents/Adobe/Adobe Media Encoder/&lt;version&gt;</code></div>
</div>
<div class="settings-field" style="grid-column: 1 / -1">
<label>High-Res Media Folder (Optional)</label>
<input type="text" id="setting-hires-media-folder" placeholder="e.g. //172.18.210.5/bmg_video/media">
<div class="settings-help">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.</div>
</div>
</div>
<!-- Section: SMB Credentials -->
@ -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(),

View file

@ -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({