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:
parent
c323872a0f
commit
205ef3f50a
3 changed files with 120 additions and 8 deletions
|
|
@ -31,9 +31,10 @@ const NULL_IMPL_ID = '00000000-0000-0000-0000-000000000000';
|
||||||
* Premiere expects (XML parsers tend to reformat and break .prproj files).
|
* Premiere expects (XML parsers tend to reformat and break .prproj files).
|
||||||
*
|
*
|
||||||
* @param {Buffer} prprojBuffer - The raw .prproj file (gzipped)
|
* @param {Buffer} prprojBuffer - The raw .prproj file (gzipped)
|
||||||
|
* @param {Object} options - Optional config { hiresMediaFolder: string, fsModule?: fs }
|
||||||
* @returns {Object} { buffer: Buffer, report: Object }
|
* @returns {Object} { buffer: Buffer, report: Object }
|
||||||
*/
|
*/
|
||||||
async function remapPrproj(prprojBuffer) {
|
async function remapPrproj(prprojBuffer, options = {}) {
|
||||||
// Step 1: Decompress
|
// Step 1: Decompress
|
||||||
const xml = zlib.gunzipSync(prprojBuffer).toString('utf-8');
|
const xml = zlib.gunzipSync(prprojBuffer).toString('utf-8');
|
||||||
|
|
||||||
|
|
@ -44,7 +45,7 @@ async function remapPrproj(prprojBuffer) {
|
||||||
const proxyLinks = parseProxyLinks(xml, mediaBlocks);
|
const proxyLinks = parseProxyLinks(xml, mediaBlocks);
|
||||||
|
|
||||||
// Step 4: Perform the path swaps
|
// Step 4: Perform the path swaps
|
||||||
const { remappedXml, swaps } = performSwaps(xml, mediaBlocks, proxyLinks);
|
const { remappedXml, swaps } = performSwaps(xml, mediaBlocks, proxyLinks, options);
|
||||||
|
|
||||||
// Step 5: Recompress
|
// Step 5: Recompress
|
||||||
const outputBuffer = zlib.gzipSync(Buffer.from(remappedXml, 'utf-8'));
|
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;
|
let remappedXml = xml;
|
||||||
const swaps = [];
|
const swaps = [];
|
||||||
|
const fs = options.fsModule || require('fs');
|
||||||
|
|
||||||
// Collect all unique .gves UIDs that have a mapped high-res block
|
// Collect all unique .gves UIDs that have a mapped high-res block
|
||||||
const gvesUids = Object.keys(proxyLinks);
|
const gvesUids = Object.keys(proxyLinks);
|
||||||
|
|
@ -237,7 +321,21 @@ function performSwaps(xml, mediaBlocks, proxyLinks) {
|
||||||
const gvesBlock = mediaBlocks[gvesUid];
|
const gvesBlock = mediaBlocks[gvesUid];
|
||||||
const hiresBlock = mediaBlocks[hiresUid];
|
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 hiresPath = hiresBlock.filePath;
|
||||||
const hiresTitle = hiresBlock.title || pathToTitle(hiresPath);
|
const hiresTitle = hiresBlock.title || pathToTitle(hiresPath);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<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/<user>/Documents/Adobe/Adobe Media Encoder/<version></code></div>
|
<div class="settings-help">Path to the folder containing <code>AMEEncodingLog.txt</code>. On macOS: <code>/Users/<user>/Documents/Adobe/Adobe Media Encoder/<version></code></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Section: SMB Credentials -->
|
<!-- Section: SMB Credentials -->
|
||||||
|
|
@ -807,6 +812,7 @@
|
||||||
document.getElementById('setting-watch-folder').value = data.watchFolder || '';
|
document.getElementById('setting-watch-folder').value = data.watchFolder || '';
|
||||||
document.getElementById('setting-output-folder').value = data.outputFolder || '';
|
document.getElementById('setting-output-folder').value = data.outputFolder || '';
|
||||||
document.getElementById('setting-ame-log-dir').value = data.ameLogDir || '';
|
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-username').value = data.smbUsername || '';
|
||||||
document.getElementById('setting-smb-password').value = ''; // never pre-fill password
|
document.getElementById('setting-smb-password').value = ''; // never pre-fill password
|
||||||
document.getElementById('setting-smb-domain').value = data.smbDomain || '';
|
document.getElementById('setting-smb-domain').value = data.smbDomain || '';
|
||||||
|
|
@ -824,6 +830,7 @@
|
||||||
watchFolder: document.getElementById('setting-watch-folder').value.trim(),
|
watchFolder: document.getElementById('setting-watch-folder').value.trim(),
|
||||||
outputFolder: document.getElementById('setting-output-folder').value.trim(),
|
outputFolder: document.getElementById('setting-output-folder').value.trim(),
|
||||||
ameLogDir: document.getElementById('setting-ame-log-dir').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(),
|
smbUsername: document.getElementById('setting-smb-username').value.trim(),
|
||||||
smbPassword: document.getElementById('setting-smb-password').value, // empty = keep existing
|
smbPassword: document.getElementById('setting-smb-password').value, // empty = keep existing
|
||||||
smbDomain: document.getElementById('setting-smb-domain').value.trim(),
|
smbDomain: document.getElementById('setting-smb-domain').value.trim(),
|
||||||
|
|
|
||||||
13
server.js
13
server.js
|
|
@ -83,7 +83,8 @@ function loadSettings() {
|
||||||
smbUsername: '',
|
smbUsername: '',
|
||||||
smbPassword: '',
|
smbPassword: '',
|
||||||
smbDomain: '',
|
smbDomain: '',
|
||||||
smbNotes: ''
|
smbNotes: '',
|
||||||
|
hiresMediaFolder: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +155,7 @@ app.get('/api/settings', requireAuth, (req, res) => {
|
||||||
watchFolder: settings.watchFolder || WATCH_FOLDER,
|
watchFolder: settings.watchFolder || WATCH_FOLDER,
|
||||||
outputFolder: settings.outputFolder || OUTPUT_FOLDER,
|
outputFolder: settings.outputFolder || OUTPUT_FOLDER,
|
||||||
ameLogDir: settings.ameLogDir || AME_LOG_DIR,
|
ameLogDir: settings.ameLogDir || AME_LOG_DIR,
|
||||||
|
hiresMediaFolder: settings.hiresMediaFolder || '',
|
||||||
smbWatchPath: settings.smbWatchPath || '',
|
smbWatchPath: settings.smbWatchPath || '',
|
||||||
smbUsername: settings.smbUsername || '',
|
smbUsername: settings.smbUsername || '',
|
||||||
// Return password masked — UI shows whether one is saved without exposing it
|
// 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) => {
|
app.post('/api/settings', requireAuth, (req, res) => {
|
||||||
const current = loadSettings();
|
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 = {
|
const updated = {
|
||||||
...current,
|
...current,
|
||||||
...(watchFolder !== undefined && { watchFolder }),
|
...(watchFolder !== undefined && { watchFolder }),
|
||||||
...(outputFolder !== undefined && { outputFolder }),
|
...(outputFolder !== undefined && { outputFolder }),
|
||||||
...(ameLogDir !== undefined && { ameLogDir }),
|
...(ameLogDir !== undefined && { ameLogDir }),
|
||||||
|
...(hiresMediaFolder !== undefined && { hiresMediaFolder }),
|
||||||
...(smbWatchPath !== undefined && { smbWatchPath }),
|
...(smbWatchPath !== undefined && { smbWatchPath }),
|
||||||
...(smbUsername !== undefined && { smbUsername }),
|
...(smbUsername !== undefined && { smbUsername }),
|
||||||
// Only update password if a non-empty value was sent (empty = keep existing)
|
// 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,
|
watchFolder: updated.watchFolder,
|
||||||
outputFolder: updated.outputFolder,
|
outputFolder: updated.outputFolder,
|
||||||
ameLogDir: updated.ameLogDir,
|
ameLogDir: updated.ameLogDir,
|
||||||
|
hiresMediaFolder: updated.hiresMediaFolder || '',
|
||||||
smbWatchPath: updated.smbWatchPath || '',
|
smbWatchPath: updated.smbWatchPath || '',
|
||||||
smbUsername: updated.smbUsername || '',
|
smbUsername: updated.smbUsername || '',
|
||||||
smbPasswordSet: !!(updated.smbPassword),
|
smbPasswordSet: !!(updated.smbPassword),
|
||||||
|
|
@ -256,7 +260,10 @@ app.post('/api/jobs', requireAuth, upload.single('prproj'), async (req, res) =>
|
||||||
// Perform the remap
|
// Perform the remap
|
||||||
let remapResult;
|
let remapResult;
|
||||||
try {
|
try {
|
||||||
remapResult = await remapPrproj(prprojBuffer);
|
const settings = loadSettings();
|
||||||
|
remapResult = await remapPrproj(prprojBuffer, {
|
||||||
|
hiresMediaFolder: settings.hiresMediaFolder
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fs.unlinkSync(uploadedPath);
|
fs.unlinkSync(uploadedPath);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue