ame-job-manager/prproj-remapper.js

502 lines
18 KiB
JavaScript
Raw Normal View History

2026-03-31 15:29:49 -04:00
/**
* .prproj Remapper Module
*
* Decompresses a Premiere Pro project file (gzipped XML), parses the media references,
* and swaps .gves file paths with their paired high-res media paths using the
* proxy linkage structure embedded in the project.
*
* The .prproj structure for FramelightX imports:
* - Each clip has a .gves Media block (the active media during editing)
* - Each clip also has a paired high-res Media block (marked IsProxy=true, OfflineReason=1)
* - AudioProxy/VideoProxy elements link .gves blocks high-res blocks via ProxyMedia ObjectURef
*
* This module:
* 1. Finds all .gves Media blocks
* 2. Follows proxy linkages to find paired high-res paths
* 3. Replaces .gves paths with high-res UNC paths
* 4. Removes IsProxy/OfflineReason flags so AME treats high-res as online
* 5. Clears the FramelightX ImplementationID so AME uses native importers
*/
const zlib = require('zlib');
const { XMLParser, XMLBuilder } = require('fast-xml-parser');
// FramelightX plugin importer GUID
const FRAMELIGHTX_IMPL_ID = '1fa18bfa-255c-44b1-ad73-56bcd99fceaf';
const NULL_IMPL_ID = '00000000-0000-0000-0000-000000000000';
/**
* Remap a .prproj buffer, swapping .gves references with high-res media paths.
* Uses string-based XML manipulation to preserve the exact XML structure that
* 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 }
2026-03-31 15:29:49 -04:00
* @returns {Object} { buffer: Buffer, report: Object }
*/
async function remapPrproj(prprojBuffer, options = {}) {
2026-03-31 15:29:49 -04:00
// Step 1: Decompress
const xml = zlib.gunzipSync(prprojBuffer).toString('utf-8');
// Step 2: Build a map of all Media blocks by ObjectUID
const mediaBlocks = parseMediaBlocks(xml);
// Step 3: Build proxy linkage map (gves ObjectUID → high-res ObjectUID)
const proxyLinks = parseProxyLinks(xml, mediaBlocks);
// Step 4: Perform the path swaps
const { remappedXml, swaps } = performSwaps(xml, mediaBlocks, proxyLinks, options);
2026-03-31 15:29:49 -04:00
// Step 5: Recompress
const outputBuffer = zlib.gzipSync(Buffer.from(remappedXml, 'utf-8'));
const report = {
totalMediaBlocks: Object.keys(mediaBlocks).length,
gvesBlocks: Object.values(mediaBlocks).filter(m => m.isGves).length,
highResBlocks: Object.values(mediaBlocks).filter(m => m.isProxy).length,
swapsPerformed: swaps.length,
swaps: swaps,
// "Unmapped" = .gves paths that were NOT covered by any swap (path-level, not block-level).
// Multiple Media blocks can share the same .gves path (video+audio streams), which is fine
// because the global string replacement catches all of them.
unmappedGves: (() => {
const swappedPaths = new Set(swaps.map(s => s.oldPath.toLowerCase()));
const unmapped = Object.values(mediaBlocks)
.filter(m => m.isGves && !swappedPaths.has(m.filePath.toLowerCase()));
// Deduplicate by path
const seen = new Set();
return unmapped.filter(m => {
if (seen.has(m.filePath.toLowerCase())) return false;
seen.add(m.filePath.toLowerCase());
return true;
}).map(m => ({ uid: m.objectUid, path: m.filePath }));
})()
};
return { buffer: outputBuffer, report };
}
/**
* Parse all <Media> blocks from the XML and extract key properties.
* Uses regex to avoid XML parser reformatting issues.
*/
function parseMediaBlocks(xml) {
const blocks = {};
// Match Media blocks with ObjectUID attribute
const mediaRegex = /<Media\s+ObjectUID="([^"]+)"[^>]*>([\s\S]*?)<\/Media>/g;
let match;
while ((match = mediaRegex.exec(xml)) !== null) {
const uid = match[1];
const body = match[2];
const filePath = extractTag(body, 'FilePath');
const actualMediaFilePath = extractTag(body, 'ActualMediaFilePath');
const isProxy = extractTag(body, 'IsProxy') === 'true';
const offlineReason = extractTag(body, 'OfflineReason');
const implementationID = extractTag(body, 'ImplementationID');
const title = extractTag(body, 'Title');
const relativePath = extractTag(body, 'RelativePath');
const isGves = filePath && filePath.toLowerCase().endsWith('.gves');
blocks[uid] = {
objectUid: uid,
filePath,
actualMediaFilePath,
relativePath,
isProxy,
offlineReason,
implementationID,
title,
isGves,
fullMatch: match[0],
startIndex: match.index
};
}
return blocks;
}
/**
* Parse AudioProxy and VideoProxy elements to build the linkage map.
* Structure: The proxy element is a child of a clip content block that references
* a .gves Media block. The proxy element's ProxyMedia ObjectURef points to the
* high-res Media block.
*/
function parseProxyLinks(xml, mediaBlocks) {
const links = {}; // highResUid → Set of gvesUids it's proxied by
// Find all AudioProxy elements
const proxyRegex = /<(?:Audio|Video)Proxy\s+ObjectID="[^"]*"[^>]*>([\s\S]*?)<\/(?:Audio|Video)Proxy>/g;
let match;
while ((match = proxyRegex.exec(xml)) !== null) {
const body = match[1];
const proxyMediaRef = extractAttr(body, 'ProxyMedia', 'ObjectURef');
if (proxyMediaRef && mediaBlocks[proxyMediaRef]) {
// proxyMediaRef points to the high-res (IsProxy=true) block
if (!links[proxyMediaRef]) {
links[proxyMediaRef] = new Set();
}
}
}
// Now we need to figure out which .gves block maps to which high-res block.
// The relationship is: a clip's content references a .gves Media block AND has
// AudioProxy/VideoProxy children that point to high-res blocks.
//
// Simpler approach: match by file name/title similarity, or by the fact that
// .gves and high-res blocks for the same clip appear in sequence and share
// the same Start timecode.
//
// Most reliable: match by Start timecode, which is identical for paired blocks.
const gvesBlocks = Object.values(mediaBlocks).filter(m => m.isGves);
const hiresBlocks = Object.values(mediaBlocks).filter(m => m.isProxy);
// Build a map from .gves UID → high-res block by matching via AudioProxy structure.
// We look for content blocks that contain both a Media ObjectURef (to .gves) and
// AudioProxy elements (pointing to high-res).
const gvesToHires = {};
// Strategy: find <Content> blocks that tie .gves Media to AudioProxy
const contentRegex = /<Content[^>]*>([\s\S]*?)<\/Content>/g;
while ((match = contentRegex.exec(xml)) !== null) {
const body = match[1];
// Find the Media ObjectURef in this content block
const mediaRef = extractAttr(body, 'Media', 'ObjectURef');
if (!mediaRef || !mediaBlocks[mediaRef] || !mediaBlocks[mediaRef].isGves) continue;
// Find AudioProxy references in this content block
const audioProxyRefs = [];
const apRegex = /<AudioProxyItem[^>]*ObjectRef="([^"]*)"[^>]*\/>/g;
let apMatch;
while ((apMatch = apRegex.exec(body)) !== null) {
audioProxyRefs.push(apMatch[1]);
}
// For each AudioProxy ObjectRef, find the actual AudioProxy element and its ProxyMedia
for (const apRef of audioProxyRefs) {
const apRegex2 = new RegExp(
`<(?:Audio|Video)Proxy\\s+ObjectID="${apRef}"[^>]*>([\\s\\S]*?)<\\/(?:Audio|Video)Proxy>`
);
const apMatch2 = apRegex2.exec(xml);
if (apMatch2) {
const proxyMediaRef = extractAttr(apMatch2[1], 'ProxyMedia', 'ObjectURef');
if (proxyMediaRef && mediaBlocks[proxyMediaRef] && mediaBlocks[proxyMediaRef].isProxy) {
gvesToHires[mediaRef] = proxyMediaRef;
break;
}
}
}
}
// Fallback: if Content-based matching didn't catch everything, try Start timecode matching
for (const gves of gvesBlocks) {
if (gvesToHires[gves.objectUid]) continue;
const gvesStart = extractTag(
xml.substring(gves.startIndex, gves.startIndex + gves.fullMatch.length),
'Start'
);
if (!gvesStart) continue;
for (const hires of hiresBlocks) {
if (Object.values(gvesToHires).includes(hires.objectUid)) continue;
const hiresStart = extractTag(
xml.substring(hires.startIndex, hires.startIndex + hires.fullMatch.length),
'Start'
);
if (hiresStart === gvesStart) {
gvesToHires[gves.objectUid] = hires.objectUid;
break;
}
}
}
return gvesToHires;
}
/**
* 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 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;
}
2026-03-31 15:29:49 -04:00
/**
* 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 }
2026-03-31 15:29:49 -04:00
*/
function performSwaps(xml, mediaBlocks, proxyLinks, options = {}) {
2026-03-31 15:29:49 -04:00
let remappedXml = xml;
const swaps = [];
const fs = options.fsModule || require('fs');
2026-03-31 15:29:49 -04:00
// Collect all unique .gves UIDs that have a mapped high-res block
const gvesUids = Object.keys(proxyLinks);
for (const gvesUid of gvesUids) {
const hiresUid = proxyLinks[gvesUid];
const gvesBlock = mediaBlocks[gvesUid];
const hiresBlock = mediaBlocks[hiresUid];
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;
2026-03-31 15:29:49 -04:00
const hiresPath = hiresBlock.filePath;
const hiresTitle = hiresBlock.title || pathToTitle(hiresPath);
// Calculate relative path for the high-res file
const hiresRelativePath = hiresPath; // UNC paths don't use relative paths
swaps.push({
gvesUid,
hiresUid,
oldPath: gvesBlock.filePath,
newPath: hiresPath,
newTitle: hiresTitle
});
// Replace ALL occurrences of this .gves path in the entire XML.
// This catches FilePath, ActualMediaFilePath, and any other references.
const gvesPath = gvesBlock.filePath;
const gvesPathEscaped = escapeRegex(gvesPath);
// Replace FilePath tags containing this .gves path
remappedXml = remappedXml.replace(
new RegExp(`<FilePath>${gvesPathEscaped}</FilePath>`, 'g'),
`<FilePath>${hiresPath}</FilePath>`
);
// Replace ActualMediaFilePath tags
remappedXml = remappedXml.replace(
new RegExp(`<ActualMediaFilePath>${gvesPathEscaped}</ActualMediaFilePath>`, 'g'),
`<ActualMediaFilePath>${hiresPath}</ActualMediaFilePath>`
);
// Replace RelativePath tags for this .gves
if (gvesBlock.relativePath) {
const relPathEscaped = escapeRegex(gvesBlock.relativePath);
remappedXml = remappedXml.replace(
new RegExp(`<RelativePath>${relPathEscaped}</RelativePath>`, 'g'),
`<RelativePath>${hiresPath}</RelativePath>`
);
}
// Replace Title tags that match the .gves filename
const gvesFilename = gvesPath.split('\\').pop().split('/').pop();
if (gvesFilename) {
const gvesTitleEscaped = escapeRegex(gvesFilename);
remappedXml = remappedXml.replace(
new RegExp(`<Title>${gvesTitleEscaped}</Title>`, 'g'),
`<Title>${hiresTitle}</Title>`
);
}
// Replace ImplementationID (FramelightX → null GUID so AME uses native importer)
remappedXml = remappedXml.replace(
new RegExp(`<ImplementationID>${escapeRegex(FRAMELIGHTX_IMPL_ID)}</ImplementationID>`, 'g'),
`<ImplementationID>${NULL_IMPL_ID}</ImplementationID>`
);
}
// Remove IsProxy and OfflineReason from high-res blocks so they're treated as online
remappedXml = remappedXml.replace(/\s*<IsProxy>true<\/IsProxy>/g, '');
remappedXml = remappedXml.replace(/\s*<OfflineReason>\d+<\/OfflineReason>/g, '');
return { remappedXml, swaps };
}
// ─── Utility functions ─────────────────────────────────────────────
function extractTag(xml, tagName) {
const regex = new RegExp(`<${tagName}>([^<]*)</${tagName}>`);
const match = regex.exec(xml);
return match ? match[1] : null;
}
function extractAttr(xml, tagName, attrName) {
const regex = new RegExp(`<${tagName}\\s+${attrName}="([^"]*)"`, '');
const match = regex.exec(xml);
return match ? match[1] : null;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function pathToTitle(filePath) {
const filename = filePath.split('\\').pop().split('/').pop();
// Remove extension
return filename.replace(/\.[^.]+$/, '');
}
/**
* Analyze a .prproj file without modifying it.
* Returns a report of all media references and their linkages.
*/
async function analyzePrproj(prprojBuffer) {
const xml = zlib.gunzipSync(prprojBuffer).toString('utf-8');
const mediaBlocks = parseMediaBlocks(xml);
const proxyLinks = parseProxyLinks(xml, mediaBlocks);
const gvesEntries = Object.values(mediaBlocks).filter(m => m.isGves);
const hiresEntries = Object.values(mediaBlocks).filter(m => m.isProxy);
// Debug: log all media blocks found
console.log('=== DEBUG: All Media Blocks ===');
Object.entries(mediaBlocks).forEach(([uid, block]) => {
console.log(`UID: ${uid}`);
console.log(` FilePath: ${block.filePath}`);
console.log(` IsGves: ${block.isGves}`);
console.log(` IsProxy: ${block.isProxy}`);
console.log(` Title: ${block.title}`);
console.log('---');
});
console.log('=== DEBUG: Proxy Links ===');
console.log(JSON.stringify(proxyLinks, null, 2));
2026-03-31 15:29:49 -04:00
// Deduplicate mappings by .gves path (multiple blocks can share the same path)
const mappings = [];
const seenGvesPaths = new Set();
for (const [gvesUid, hiresUid] of Object.entries(proxyLinks)) {
const gves = mediaBlocks[gvesUid];
const hires = mediaBlocks[hiresUid];
const gvesPath = gves ? gves.filePath.toLowerCase() : '';
if (seenGvesPaths.has(gvesPath)) continue;
seenGvesPaths.add(gvesPath);
mappings.push({
gvesPath: gves ? gves.filePath : 'unknown',
hiresPath: hires ? hires.filePath : 'unknown',
hiresExtension: hires ? hires.filePath.split('.').pop().toLowerCase() : 'unknown'
});
}
// Count unique unmapped .gves paths
const unmappedPaths = new Set();
for (const g of gvesEntries) {
if (!seenGvesPaths.has(g.filePath.toLowerCase())) {
unmappedPaths.add(g.filePath.toLowerCase());
}
}
// Debug: include all media blocks so we can see the structure
const allMediaBlocks = Object.entries(mediaBlocks).map(([uid, block]) => ({
uid,
filePath: block.filePath,
isGves: block.isGves,
isProxy: block.isProxy,
title: block.title,
implementationID: block.implementationID
}));
2026-03-31 15:29:49 -04:00
return {
totalMediaBlocks: Object.keys(mediaBlocks).length,
gvesReferences: gvesEntries.length,
hiresReferences: hiresEntries.length,
mappedPairs: mappings.length,
unmappedGves: unmappedPaths.size,
mappings,
hiresFormats: [...new Set(mappings.map(m => m.hiresExtension))],
// Debug: include raw media blocks
_debug_mediaBlocks: allMediaBlocks,
_debug_proxyLinks: proxyLinks
2026-03-31 15:29:49 -04:00
};
}
module.exports = { remapPrproj, analyzePrproj };