Added debug output to analyzePrproj() to show: - All media blocks found in the project (uid, filePath, isGves, isProxy, title) - Complete proxy linkage map (which .gves UIDs link to which high-res UIDs) - Raw media blocks in analysis response (_debug_mediaBlocks) - Proxy links in analysis response (_debug_proxyLinks) Also added console.log output for all media blocks and proxy links to help diagnose why high-res blocks aren't being found/linked. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
/**
|
|
* .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);
|
|
|
|
// 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 };
|