diff --git a/prproj-remapper.js b/prproj-remapper.js new file mode 100644 index 0000000..082b6b2 --- /dev/null +++ b/prproj-remapper.js @@ -0,0 +1,377 @@ +/** + * .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) + * @returns {Object} { buffer: Buffer, report: Object } + */ +async function remapPrproj(prprojBuffer) { + // 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); + + // 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; +} + +/** + * Perform the actual path swaps in the XML string. + */ +function performSwaps(xml, mediaBlocks, proxyLinks) { + let remappedXml = xml; + const swaps = []; + + // 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 || !hiresBlock.filePath) continue; + + 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(`${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); + + // 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()); + } + } + + 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))] + }; +} + +module.exports = { remapPrproj, analyzePrproj };