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:
twotalesanimation
2026-10-08 08:14:27 +02:00
parent 06a4122ebb
commit 4cbb5d341c
2 changed files with 270 additions and 2 deletions
+262 -2
View File
@@ -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) {
+8
View File
@@ -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.