feat(connector): compare QC exports with earlier versions
Compare Versions in the QC section opens the export's QC comp and stacks earlier v### EXR sequences of the shot under it, one layer per version, lined up by frame number. Finds versions next to the export in both on-disk layouts (per-version folders and the older flat folder). Open Render now names the export's layer by version. Panel 2.8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+262
-2
@@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg";
|
||||
// Bump on every change deployed to a workstation. The panel reports this to
|
||||
// the server on load, so Pipeline → Machines shows which version each machine
|
||||
// is actually running and flags the ones left behind.
|
||||
var PANEL_VERSION = "2.7.1";
|
||||
var PANEL_VERSION = "2.8.0";
|
||||
|
||||
(function VFXReviewConnector(thisObj) {
|
||||
var uiState = {
|
||||
@@ -4584,7 +4584,8 @@ var PANEL_VERSION = "2.7.1";
|
||||
comp = app.project.items.addComp(compName, footage.width, footage.height, 1,
|
||||
footage.duration, entry.fps || 24);
|
||||
setCompStartFrame(comp, entry.frameStart || 0);
|
||||
comp.layers.add(footage);
|
||||
// Named by version so Compare Versions can stack earlier ones under it.
|
||||
comp.layers.add(footage).name = entry.versionString;
|
||||
comp.openInViewer();
|
||||
} catch (compError) {
|
||||
setQCStatus("Could not build QC comp: " + describeError(compError));
|
||||
@@ -4611,6 +4612,261 @@ var PANEL_VERSION = "2.7.1";
|
||||
app.endUndoGroup();
|
||||
}
|
||||
|
||||
// ── QC: compare with earlier versions ─────────────────────────────────────
|
||||
//
|
||||
// Earlier exports of the shot are found on disk around this one, in either
|
||||
// layout: farm version folders ({shot}/{ver}/*.exr) or the older flat
|
||||
// folder ({shot}/{shot}_cmp_TT_{ver}.#####.exr). Each is stacked under the
|
||||
// current version in the QC comp, lined up by frame number, so soloing a
|
||||
// layer or setting the top one to Difference compares them directly.
|
||||
|
||||
// 3 from "v003", "UNG_108_001_010_cmp_TT_v003." or a "v003" folder.
|
||||
function qcVersionNumber(text) {
|
||||
var match = /(?:^|[^A-Za-z0-9])v(\d{1,4})(?![0-9])/i.exec(String(text || ""));
|
||||
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
// EXR sequences in a folder, grouped by name prefix (the part before the
|
||||
// frame number). first is the lowest frame number, or null.
|
||||
function qcSequencesIn(folder) {
|
||||
var files = folder.getFiles("*.exr");
|
||||
var groups = {};
|
||||
var list = [];
|
||||
var i;
|
||||
var name;
|
||||
var match;
|
||||
var key;
|
||||
var frame;
|
||||
|
||||
for (i = 0; files && i < files.length; i += 1) {
|
||||
if (!(files[i] instanceof File)) {
|
||||
continue;
|
||||
}
|
||||
name = decodeURI(files[i].name);
|
||||
match = /^(.*?)(\d+)\.exr$/i.exec(name);
|
||||
key = match ? match[1] : name;
|
||||
if (!groups[key]) {
|
||||
groups[key] = { prefix: key, folder: folder, files: [], first: null };
|
||||
list.push(groups[key]);
|
||||
}
|
||||
groups[key].files.push(files[i]);
|
||||
if (match) {
|
||||
frame = parseInt(match[2], 10);
|
||||
if (groups[key].first === null || frame < groups[key].first) {
|
||||
groups[key].first = frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function findPreviousQCVersions(entry, resolvedPath) {
|
||||
var current = qcVersionNumber(entry.versionString);
|
||||
var here = new Folder(resolvedPath);
|
||||
var shotFolder = /^v\d+$/i.test(decodeURI(here.name)) ? here.parent : here;
|
||||
var byVersion = {};
|
||||
var found = [];
|
||||
var subFolders;
|
||||
var seqs;
|
||||
var version;
|
||||
var i;
|
||||
var j;
|
||||
var key;
|
||||
|
||||
function consider(seq, number) {
|
||||
var best;
|
||||
|
||||
if (number === null || number >= current) {
|
||||
return;
|
||||
}
|
||||
best = byVersion[number];
|
||||
// Several sequences for one version (passes, a stray re-render):
|
||||
// prefer the one named after the shot, then the longest.
|
||||
if (!best ||
|
||||
(seq.prefix.indexOf(entry.shotCode) === 0) > (best.prefix.indexOf(entry.shotCode) === 0) ||
|
||||
((seq.prefix.indexOf(entry.shotCode) === 0) === (best.prefix.indexOf(entry.shotCode) === 0) &&
|
||||
seq.files.length > best.files.length)) {
|
||||
seq.version = number;
|
||||
byVersion[number] = seq;
|
||||
}
|
||||
}
|
||||
|
||||
if (current === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Flat layout: every version side by side in the shot folder.
|
||||
seqs = qcSequencesIn(shotFolder);
|
||||
for (i = 0; i < seqs.length; i += 1) {
|
||||
consider(seqs[i], qcVersionNumber(seqs[i].prefix));
|
||||
}
|
||||
|
||||
// Farm layout: one folder per version.
|
||||
subFolders = shotFolder.getFiles(function (f) {
|
||||
return f instanceof Folder && /^v\d+$/i.test(decodeURI(f.name));
|
||||
});
|
||||
for (i = 0; subFolders && i < subFolders.length; i += 1) {
|
||||
version = qcVersionNumber(decodeURI(subFolders[i].name));
|
||||
seqs = qcSequencesIn(subFolders[i]);
|
||||
for (j = 0; j < seqs.length; j += 1) {
|
||||
consider(seqs[j], version);
|
||||
}
|
||||
}
|
||||
|
||||
for (key in byVersion) {
|
||||
if (byVersion.hasOwnProperty(key)) {
|
||||
found.push(byVersion[key]);
|
||||
}
|
||||
}
|
||||
found.sort(function (a, b) { return b.version - a.version; });
|
||||
return found;
|
||||
}
|
||||
|
||||
function qcVersionLabel(seq) {
|
||||
var text = String(seq.version);
|
||||
|
||||
while (text.length < 3) {
|
||||
text = "0" + text;
|
||||
}
|
||||
return "v" + text;
|
||||
}
|
||||
|
||||
// Lets the user untick versions they don't want. Newest first, all ticked.
|
||||
function chooseQCVersions(entry, found) {
|
||||
var dialog = new Window("dialog", "Compare " + entry.shotCode + " " + entry.versionString);
|
||||
var boxes = [];
|
||||
var buttons;
|
||||
var chosen = [];
|
||||
var i;
|
||||
|
||||
dialog.orientation = "column";
|
||||
dialog.alignChildren = ["fill", "top"];
|
||||
dialog.add("statictext", undefined, "Stack these earlier versions under " + entry.versionString + ":");
|
||||
for (i = 0; i < found.length; i += 1) {
|
||||
boxes.push(dialog.add("checkbox", undefined, qcVersionLabel(found[i]) + " " +
|
||||
found[i].files.length + " frames " + decodeURI(found[i].folder.name)));
|
||||
boxes[i].value = true;
|
||||
}
|
||||
buttons = dialog.add("group");
|
||||
buttons.alignment = ["right", "top"];
|
||||
buttons.add("button", undefined, "Import", { name: "ok" });
|
||||
buttons.add("button", undefined, "Cancel", { name: "cancel" });
|
||||
|
||||
if (dialog.show() !== 1) {
|
||||
return null;
|
||||
}
|
||||
for (i = 0; i < found.length; i += 1) {
|
||||
if (boxes[i].value) {
|
||||
chosen.push(found[i]);
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function findLayerByName(comp, name) {
|
||||
var i;
|
||||
|
||||
for (i = 1; i <= comp.numLayers; i += 1) {
|
||||
if (comp.layer(i).name === name) {
|
||||
return comp.layer(i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareQCVersions() {
|
||||
var entry = getSelectedQCEntry();
|
||||
var compName;
|
||||
var comp;
|
||||
var resolved;
|
||||
var found;
|
||||
var chosen;
|
||||
var fps;
|
||||
var added = [];
|
||||
var skipped = [];
|
||||
var i;
|
||||
var label;
|
||||
var importOptions;
|
||||
var footage;
|
||||
var layer;
|
||||
|
||||
if (!entry) {
|
||||
setQCStatus("Select a queue entry first");
|
||||
return;
|
||||
}
|
||||
if (qcVersionNumber(entry.versionString) === null) {
|
||||
setQCStatus("Can't tell which version " + entry.versionString + " is, so can't find earlier ones");
|
||||
return;
|
||||
}
|
||||
|
||||
compName = "QC_" + entry.shotCode + "_" + entry.versionString;
|
||||
fps = entry.fps || 24;
|
||||
|
||||
setQCStatus("Looking for earlier versions of " + entry.shotCode + "...");
|
||||
app.beginUndoGroup("VFXReview Connector - QC Compare Versions");
|
||||
try {
|
||||
// Opens the QC comp, building it first if needed.
|
||||
if (!importQCSequence(entry)) {
|
||||
return;
|
||||
}
|
||||
comp = findComp(compName);
|
||||
resolved = resolveServerPath(entry.outputDir);
|
||||
if (!comp || !resolved.path) {
|
||||
setQCStatus("Render folder not found for " + entry.shotCode + " — see the report");
|
||||
diagFail("QC compare", "could not resolve " + entry.outputDir + " on this machine", resolved.tried);
|
||||
return;
|
||||
}
|
||||
|
||||
found = findPreviousQCVersions(entry, resolved.path);
|
||||
if (!found.length) {
|
||||
setQCStatus("No earlier versions of " + entry.shotCode + " found next to " + resolved.path);
|
||||
diagLog("QC compare: looked for EXR sequences older than " + entry.versionString +
|
||||
" in " + resolved.path + ", its parent, and the parent's v### folders");
|
||||
return;
|
||||
}
|
||||
|
||||
chosen = found.length > 1 ? chooseQCVersions(entry, found) : found;
|
||||
if (!chosen || !chosen.length) {
|
||||
setQCStatus(describeQCEntry(entry));
|
||||
return;
|
||||
}
|
||||
|
||||
// Newest first, so after moveToEnd the stack reads current, v-1, v-2…
|
||||
for (i = 0; i < chosen.length; i += 1) {
|
||||
label = qcVersionLabel(chosen[i]);
|
||||
if (findLayerByName(comp, label)) {
|
||||
skipped.push(label);
|
||||
continue;
|
||||
}
|
||||
setQCStatus("Importing " + entry.shotCode + " " + label + "...");
|
||||
chosen[i].files.sort();
|
||||
importOptions = new ImportOptions(chosen[i].files[0]);
|
||||
importOptions.sequence = true;
|
||||
footage = app.project.importFile(importOptions);
|
||||
footage.mainSource.conformFrameRate = fps;
|
||||
layer = comp.layers.add(footage);
|
||||
layer.name = label;
|
||||
layer.moveToEnd();
|
||||
// Comp time 0 is entry.frameStart; line each version up by its
|
||||
// own first frame number, so a render with handles still matches.
|
||||
if (chosen[i].first !== null && entry.frameStart !== null && entry.frameStart !== undefined) {
|
||||
layer.startTime = (chosen[i].first - entry.frameStart) / fps;
|
||||
}
|
||||
added.push(label);
|
||||
diagLog("QC compare: " + label + " — " + chosen[i].files.length + " frames from " +
|
||||
chosen[i].folder.fsName + " (first frame " + chosen[i].first + ")");
|
||||
}
|
||||
|
||||
comp.openInViewer();
|
||||
setQCStatus((added.length ? "Stacked " + added.join(", ") + " under " + entry.versionString + "." : "") +
|
||||
(skipped.length ? " Already in the comp: " + skipped.join(", ") + "." : "") +
|
||||
"\nSolo a layer to view that version, or set the top layer to Difference.");
|
||||
} finally {
|
||||
app.endUndoGroup();
|
||||
}
|
||||
}
|
||||
|
||||
function submitQC(result) {
|
||||
var entry = getSelectedQCEntry();
|
||||
var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : "";
|
||||
@@ -4726,6 +4982,7 @@ var PANEL_VERSION = "2.7.1";
|
||||
var qcRow2;
|
||||
var loadQCButton;
|
||||
var openQCButton;
|
||||
var compareQCButton;
|
||||
var qcPassButton;
|
||||
var qcFailButton;
|
||||
|
||||
@@ -4891,6 +5148,8 @@ var PANEL_VERSION = "2.7.1";
|
||||
loadQCButton = qcRow1.add("button", undefined, "Load QC Queue");
|
||||
openQCButton = qcRow1.add("button", undefined, "Open Render");
|
||||
openQCButton.helpTip = "Import the selected export's EXR sequence into a QC comp at full resolution.";
|
||||
compareQCButton = qcRow1.add("button", undefined, "Compare Versions");
|
||||
compareQCButton.helpTip = "Import earlier versions of this shot under the selected export in its QC comp, lined up by frame.";
|
||||
|
||||
var qcNotesGroup = qcPanel.add("group");
|
||||
qcNotesGroup.orientation = "row";
|
||||
@@ -5045,6 +5304,7 @@ var PANEL_VERSION = "2.7.1";
|
||||
loadQCButton.onClick = guarded("Load QC Queue", loadQCQueue);
|
||||
openQCButton.onClick = guarded("Open QC Render", openSelectedQCEntry);
|
||||
uiState.qcList.onDoubleClick = guarded("Open QC Render", openSelectedQCEntry);
|
||||
compareQCButton.onClick = guarded("Compare QC Versions", compareQCVersions);
|
||||
uiState.qcList.onChange = function () {
|
||||
var entry = getSelectedQCEntry();
|
||||
if (entry) {
|
||||
|
||||
@@ -267,10 +267,18 @@ QC is per-export: a new export always needs new QC.
|
||||
|---|---|
|
||||
| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
|
||||
| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
|
||||
| **Compare Versions** | Opens the QC comp and stacks earlier versions of the shot under the export, one layer each (`v003`, `v002`…), lined up by frame number. Solo a layer to see that version, or set the top layer to **Difference** to see exactly what changed. With more than one earlier version, a dialog lets you untick the ones you don't need. Versions already in the comp are skipped. |
|
||||
| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
|
||||
| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
|
||||
| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
|
||||
|
||||
**Where Compare Versions looks:** next to the export's own render folder (resolved
|
||||
as for Open Render), in both on-disk layouts. That means farm version folders
|
||||
(`{shot}/v002/`, `{shot}/v003/`…) and the older flat folder with
|
||||
`{shot}_cmp_TT_{ver}.#####.exr` side by side. Only versions numbered below the
|
||||
export's are used. If earlier exports were written under a different root (see
|
||||
*More than one on-disk convention*), they won't be found.
|
||||
|
||||
Passing an export is what makes it eligible for a delivery package on the web
|
||||
app's **Pipeline → Deliveries** page.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user