/** * .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 } * @returns {Object} { buffer: Buffer, report: Object } */ async function remapPrproj(prprojBuffer, options = {}) { // 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); // 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 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 = /]*>([\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 blocks that tie .gves Media to AudioProxy const contentRegex = /]*>([\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 = /]*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 files containing that title if (hiresTitle) { try { const files = fs.readdirSync(searchFolder); const videoExts = new Set(['.mp4', '.mov', '.mxf', '.wav', '.aif', '.aiff']); const titleLower = hiresTitle.toLowerCase(); // Look for files that contain the title as a substring for (const file of files) { const ext = '.' + file.split('.').pop().toLowerCase(); if (!videoExts.has(ext)) continue; const fileLower = file.toLowerCase(); // Match if filename contains the title if (fileLower.includes(titleLower)) { const fullPath = `${searchFolder}/${file}`; return fullPath.replace(/\//g, '\\'); } } } catch (e) { // Ignore folder read failures, fall through to secondary search } } // 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); 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; 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(`${gvesPathEscaped}`, 'g'), `${hiresPath}` ); // Replace ActualMediaFilePath tags remappedXml = remappedXml.replace( new RegExp(`${gvesPathEscaped}`, 'g'), `${hiresPath}` ); // Replace RelativePath tags for this .gves if (gvesBlock.relativePath) { const relPathEscaped = escapeRegex(gvesBlock.relativePath); remappedXml = remappedXml.replace( new RegExp(`${relPathEscaped}`, 'g'), `${hiresPath}` ); } // 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(`${gvesTitleEscaped}`, 'g'), `${hiresTitle}` ); } // Replace ImplementationID (FramelightX → null GUID so AME uses native importer) remappedXml = remappedXml.replace( new RegExp(`${escapeRegex(FRAMELIGHTX_IMPL_ID)}`, 'g'), `${NULL_IMPL_ID}` ); } // Remove IsProxy and OfflineReason from high-res blocks so they're treated as online remappedXml = remappedXml.replace(/\s*true<\/IsProxy>/g, ''); remappedXml = remappedXml.replace(/\s*\d+<\/OfflineReason>/g, ''); return { remappedXml, swaps }; } // ─── Utility functions ───────────────────────────────────────────── function extractTag(xml, tagName) { const regex = new RegExp(`<${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)); // 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 })); 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 }; } module.exports = { remapPrproj, analyzePrproj };