From 4cbb5d341c18ad4a8846e2e7332c6ab900726bb4 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:14:27 +0200 Subject: [PATCH] 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 --- VFXReviewConnector.jsx | 264 ++++++++++++++++++++++++++++++++++++++++- VFXReviewConnector.md | 8 ++ 2 files changed, 270 insertions(+), 2 deletions(-) diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index b598aec..b8b7094 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -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) { diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index 5b8f3fe..ed86257 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -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.