Add prproj-remapper.js
This commit is contained in:
parent
e90ef6951f
commit
47ad07366d
1 changed files with 377 additions and 0 deletions
377
prproj-remapper.js
Normal file
377
prproj-remapper.js
Normal file
|
|
@ -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 <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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(`<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);
|
||||
|
||||
// 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 };
|
||||
Loading…
Reference in a new issue