fix(connector): resolve server render paths on the workstation
Deploy / deploy (push) Successful in 6m4s

QC's Open Render used the server's outputDir verbatim. The server
addresses renders from its own root (render.outputRoot, e.g. //SAN),
which the render worker translates via its own pathMappings because it
runs as a service account — the panel had no equivalent, so Open Render
failed on any machine that could not resolve that root.

Resolution now tries, in order: an optional Path Mappings setting, the
server path as given, then re-basing on Export Root. The last one is why
this usually needs no configuration at all: both sides lay renders out as
{root}/{showId}/{episode}/{shot}/{version}, so the tail can simply be
re-attached. A failure names every candidate it tried rather than
reporting one path that was never going to work.

Diagnostics gained the same check against a real folder from the QC
queue, since "Export Root exists" and "this machine can reach a render
the server wrote" are different questions and only the second one breaks
QC.

Verified against the live V: drive: a //SAN path, a backslash variant and
an explicitly-mapped path all resolve to the real render folder, while an
unknown root and a different show correctly resolve to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-13 18:39:03 +02:00
parent ce7b73dd38
commit f4a2fee492
2 changed files with 205 additions and 9 deletions
+183 -9
View File
@@ -12,7 +12,13 @@ var CONFIG = {
bannerImagePath:"",
artistEmail: "",
handleFrames: 8,
lastProjectCode:""
lastProjectCode:"",
// Optional prefix rewrites for paths the *server* supplies (render folders
// are addressed as //SAN/... there, which a workstation may not resolve).
// Format: "//SAN/=V:/_EXPORTS/" — several separated by ";".
// Usually unnecessary: see resolveServerPath, which derives the local path
// from Export Root when the layouts mirror each other.
pathMappings: ""
};
var SETTINGS_GROUP = "VFXReviewConnector";
@@ -220,7 +226,8 @@ var PANEL_VERSION = "2.1.0";
" | footageRoot=" + (CONFIG.footageRoot || "(empty)") +
" | exportRoot=" + (CONFIG.exportRoot || "(empty)") +
" | artistEmail=" + (CONFIG.artistEmail || "(empty)") +
" | handleFrames=" + CONFIG.handleFrames;
" | handleFrames=" + CONFIG.handleFrames +
" | pathMappings=" + (CONFIG.pathMappings || "(none)");
}
function diagStart(action) {
@@ -786,6 +793,107 @@ var PANEL_VERSION = "2.1.0";
return path + "/" + shot.shotCode + "/" + (shot.shotVersion || "v001");
}
// ── Server paths → this machine ───────────────────────────────────────────
//
// The server addresses render folders from its own root (render.outputRoot,
// e.g. "//SAN"), which a workstation may not resolve. The worker solves this
// with pathMappings because it runs as a service account; the panel resolves
// it here, and mostly without configuration: the server's export layout
// mirrors the panel's from the show folder down —
// {root}/{showId}/{episode}/{shotCode}/{version} on both sides — so the tail
// can simply be re-based onto Export Root.
function normalizeSlashes(path) {
return String(path).replace(/\\/g, "/");
}
function parsePathMappings() {
var raw = trimText(CONFIG.pathMappings);
var mappings = [];
var parts;
var i;
var pair;
if (!raw) {
return mappings;
}
parts = raw.split(";");
for (i = 0; i < parts.length; i += 1) {
pair = trimText(parts[i]).split("=");
if (pair.length === 2 && trimText(pair[0]) && trimText(pair[1])) {
mappings.push({ from: normalizeSlashes(trimText(pair[0])), to: normalizeSlashes(trimText(pair[1])) });
}
}
return mappings;
}
function applyPathMappings(path) {
var mappings = parsePathMappings();
var normalized = normalizeSlashes(path);
var i;
for (i = 0; i < mappings.length; i += 1) {
if (normalized.toLowerCase().indexOf(mappings[i].from.toLowerCase()) === 0) {
return mappings[i].to + normalized.substring(mappings[i].from.length);
}
}
return null;
}
// Re-base a server path onto Export Root from the show folder down, which
// works whenever both sides use the documented layout.
function rebaseOnExportRoot(path) {
var showId = getShowId();
var normalized = normalizeSlashes(path);
var marker;
var index;
if (!showId || !trimText(CONFIG.exportRoot)) {
return null;
}
marker = "/" + showId + "/";
index = normalized.toLowerCase().indexOf(marker.toLowerCase());
if (index < 0) {
return null;
}
return normalizeSlashes(trimText(CONFIG.exportRoot).replace(/\/+$/, "")) + normalized.substring(index);
}
/**
* Resolves a server-supplied path to something that exists on this machine.
* Returns { path, how, tried } — `path` is null when nothing resolved, and
* `tried` lists every candidate so a failure names them all.
*/
function resolveServerPath(serverPath) {
var tried = [];
var candidates = [];
var mapped = applyPathMappings(serverPath);
var rebased;
var i;
if (mapped) {
candidates.push({ path: mapped, how: "path mapping" });
}
candidates.push({ path: normalizeSlashes(serverPath), how: "server path" });
rebased = rebaseOnExportRoot(serverPath);
if (rebased) {
candidates.push({ path: rebased, how: "re-based on Export Root" });
}
for (i = 0; i < candidates.length; i += 1) {
tried.push(candidates[i].how + ": " + candidates[i].path);
if (new Folder(candidates[i].path).exists) {
return { path: candidates[i].path, how: candidates[i].how, tried: tried };
}
}
return { path: null, how: "", tried: tried };
}
function ensureFolder(path) {
var folder = new Folder(path);
var parent;
@@ -1793,6 +1901,46 @@ var PANEL_VERSION = "2.1.0";
"\n" + failure.detail, diagReport());
}
/**
* Takes a real render folder from the server and checks this machine can
* reach it. The server addresses renders from its own root, so a
* workstation that has Export Root mapped can still fail here — which is
* exactly what QC's Open Render trips over. Returns a problem count.
*/
function checkServerPathReachable() {
var url = CONFIG.baseUrl + "/api/ext/qc/queue" +
(CURRENT_PROJECT ? "?projectCode=" + encodeURIComponent(CURRENT_PROJECT.code) : "");
var data = getAPIData(url);
var entries;
var resolved;
var i;
if (!data) {
diagLog("QC queue not reachable — skipping the render-path check");
return 0;
}
entries = getListFromResponse(data, "queue");
if (!entries.length) {
diagLog("no exports awaiting QC — render-path check skipped (nothing to point at)");
return 0;
}
resolved = resolveServerPath(entries[0].outputDir);
if (resolved.path) {
diagLog("render folder OK: " + entries[0].shotCode + " via " + resolved.how + " -> " + resolved.path);
return 0;
}
diagLog("PROBLEM cannot reach the render folder for " + entries[0].shotCode +
" — QC's Open Render will fail on this machine");
for (i = 0; i < resolved.tried.length; i += 1) {
diagLog(" tried " + resolved.tried[i]);
}
diagLog(" - fix: set Export Root to match the layout, or add Settings -> Path Mappings, e.g. //SAN/=V:/_EXPORTS/");
return 1;
}
// Checks everything Build Shot depends on, without building anything, so a
// workstation can be cleared start to finish before an artist is blocked.
function runDiagnostics() {
@@ -1909,6 +2057,11 @@ var PANEL_VERSION = "2.1.0";
(DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
}
}
// Can this machine actually reach a render folder the server wrote?
// That is a different question from Export Root existing, and it is
// the one QC fails on.
problems += checkServerPathReachable();
}
for (i = 0; i < requiredComps.length; i += 1) {
@@ -2919,6 +3072,12 @@ var PANEL_VERSION = "2.1.0";
var fBanner = addField(dlg, "Banner Path:", CONFIG.bannerImagePath);
var fEmail = addField(dlg, "Artist Email:", CONFIG.artistEmail);
var fHandles= addField(dlg, "Handle Frames:", String(CONFIG.handleFrames));
var fMappings = addField(dlg, "Path Mappings:", CONFIG.pathMappings);
fMappings.helpTip =
"Optional. Rewrites paths the server supplies (render folders are\n" +
"addressed from its own root). Format: //SAN/=V:/_EXPORTS/\n" +
"Several separated by ';'. Usually unnecessary — the panel first\n" +
"tries the path as given, then re-bases it on Export Root.";
var btnRow = dlg.add("group");
btnRow.orientation = "row";
@@ -2939,6 +3098,7 @@ var PANEL_VERSION = "2.1.0";
CONFIG.bannerImagePath = trimText(fBanner.text);
CONFIG.artistEmail = trimText(fEmail.text);
CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8;
CONFIG.pathMappings = trimText(fMappings.text);
saveConfig();
populateProjectDropdown();
}
@@ -3871,7 +4031,11 @@ var PANEL_VERSION = "2.1.0";
// The export's own outputPattern prefix, so a version folder that also holds
// the preview MOV/MP4 still resolves to the EXR sequence.
function importQCSequence(entry) {
var folder = new Folder(entry.outputDir);
// The server addresses this folder from its own render root, which this
// machine may not resolve — resolveServerPath tries the mapping, the
// literal path, then Export Root (§ Server paths → this machine).
var resolved = resolveServerPath(entry.outputDir);
var folder;
var prefix = String(entry.outputPattern || "").split("[")[0].replace(/\.$/, "");
var files;
var importOptions;
@@ -3886,14 +4050,21 @@ var PANEL_VERSION = "2.1.0";
return true;
}
if (!folder.exists) {
setQCStatus("Render folder not found: " + entry.outputDir);
diagFail("QC import", entry.outputDir + " does not exist on this machine",
[describeMissingPath(entry.outputDir),
"The render folder is written by the farm — check this machine maps the same drive."]);
if (!resolved.path) {
setQCStatus("Render folder not found for " + entry.shotCode + " — see the report");
diagFail("QC import", "could not resolve " + entry.outputDir + " on this machine",
resolved.tried.concat([
"The server writes renders under its own root (render.outputRoot); this machine has to reach the same storage.",
"Either set Export Root so the layout lines up, or add a rewrite in Settings → Path Mappings, e.g. //SAN/=V:/_EXPORTS/"
]));
return false;
}
folder = new Folder(resolved.path);
if (resolved.how !== "server path") {
diagLog("QC import: resolved " + entry.outputDir + " via " + resolved.how + " -> " + resolved.path);
}
files = folder.getFiles(function (f) {
return f instanceof File && /\.exr$/i.test(f.name) &&
(!prefix || f.name.indexOf(prefix) === 0);
@@ -3902,7 +4073,10 @@ var PANEL_VERSION = "2.1.0";
files = folder.getFiles("*.exr");
}
if (!files || files.length < 1) {
setQCStatus("No EXRs in " + entry.outputDir);
setQCStatus("No EXRs in " + resolved.path);
diagFail("QC import", "no .exr files in " + resolved.path,
["Resolved via " + resolved.how + " from the server path " + entry.outputDir + ".",
"Folder contents: " + describeFolderContents(folder)]);
return false;
}
+22
View File
@@ -17,6 +17,28 @@ Settings are stored per-machine via the **⚙ Settings** button in the panel (pe
| **Banner Path** | Path to the panel banner image |
| **Artist Email** | Used for render attribution in the pipeline |
| **Handle Frames** | Plate handle frames prepended before the shot's source TC in-point (default: 8) |
| **Path Mappings** | *Optional.* Rewrites paths the **server** supplies — see below |
### Server paths vs. this machine
The server addresses render folders from its own root (`render.outputRoot`,
e.g. `//SAN`), which a workstation may not resolve — the render worker
translates them with its own `pathMappings` because it runs as a service
account. The panel needs the same translation to open a render for QC.
It resolves this itself in most cases, trying in order:
1. **Path Mappings**, if configured — format `//SAN/=V:/_EXPORTS/`, several
separated by `;`.
2. **The server path as given** — correct when `render.outputRoot` is already
a path this machine can reach.
3. **Re-based on Export Root** — the server's export layout mirrors the panel's
from the show folder down (`{root}/{showId}/{episode}/{shot}/{version}`), so
the tail is simply re-attached to Export Root.
Step 3 is why this usually needs no configuration. Set Path Mappings only when
the two layouts genuinely differ. **Diagnostics** checks this against a real
render folder from the QC queue and names every path it tried when it fails.
---