4929db45a2
Deploy / deploy (push) Successful in 6m5s
A studio whose render output root changed carries both conventions in the
database permanently — changing render.outputRoot only affects new
exports, so old rows keep the path they were queued with. On UNG that is
//SAN/renders/{projectCode}/... for older exports and //SAN/{showId}/...
for current ones, living side by side under one Export Root.
applyPathMappings returned only the first prefix match, so one rewrite
had to serve both and could not. It now returns every match and
resolveServerPath tests each for existence, which is what the candidate
ordering was there for.
Verified against the live drive: with "//SAN/=V:/_EXPORTS/UNG/" the
legacy export resolves via the mapping and current ones via the Export
Root re-base; with both rewrites mapped, all three resolve directly.
Panel 2.1.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4597 lines
162 KiB
React
4597 lines
162 KiB
React
/*
|
||
VFXReview Connector
|
||
Adobe After Effects 2024+ dockable ScriptUI panel
|
||
*/
|
||
|
||
// Default config — overridden by stored settings at runtime
|
||
var CONFIG = {
|
||
baseUrl: "http://localhost:3000",
|
||
token: "",
|
||
exportRoot: "",
|
||
footageRoot: "",
|
||
bannerImagePath:"",
|
||
artistEmail: "",
|
||
handleFrames: 8,
|
||
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";
|
||
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.1.1";
|
||
|
||
(function VFXReviewConnector(thisObj) {
|
||
var uiState = {
|
||
projectDropdown: null,
|
||
episodeDropdown: null,
|
||
shotDropdown: null,
|
||
episodeLabel: null,
|
||
selectionText: null,
|
||
lastActionText: null,
|
||
pipelineStatusText: null,
|
||
urgentCheckbox: null,
|
||
submissionNoteField: null,
|
||
qcList: null,
|
||
qcNotesField: null,
|
||
qcStatusText: null
|
||
};
|
||
|
||
var qcState = {
|
||
entries: []
|
||
};
|
||
|
||
var CURRENT_PROJECT = null; // { code, name, showId, projectType }
|
||
|
||
// Only used when no project is selected, or for a comp belonging to another
|
||
// show than the selected one. Assumes a three-letter show code, which is why
|
||
// it cannot be the primary pattern any more -- see getShotCodeRegex.
|
||
var LEGACY_SHOT_CODE_REGEX = /([A-Z]{3}_\d{3}_\d{3}_\d{3})/;
|
||
var LEGACY_EPISODE_CODE_REGEX = /^[A-Z]{3}_\d{3}$/;
|
||
|
||
var pipelineState = {
|
||
pollShotCode: null,
|
||
pollTaskId: 0,
|
||
prefilledShotCode: null
|
||
};
|
||
|
||
var buildState = {
|
||
lastStartSource: "",
|
||
lastStartFrames: 0,
|
||
lastStartShotCode: ""
|
||
};
|
||
|
||
function parseJSON(text) {
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
if (typeof JSON !== "undefined" && JSON.parse) {
|
||
return JSON.parse(text);
|
||
}
|
||
} catch (jsonError) {
|
||
}
|
||
|
||
try {
|
||
return eval("(" + text + ")");
|
||
} catch (evalError) {
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ExtendScript is ES3 — String.prototype.trim does not exist.
|
||
function trimText(value) {
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
return String(value).replace(/^\s+/, "").replace(/\s+$/, "");
|
||
}
|
||
|
||
// ── Diagnostics ───────────────────────────────────────────────────────────
|
||
//
|
||
// Every button records what it tried, in order, to an in-memory trace and to
|
||
// a log file next to config.json. A failure therefore reports the stage it
|
||
// died at and the paths/HTTP codes it was working with, instead of a bare
|
||
// "failed" that can only be diagnosed by sitting at the machine.
|
||
|
||
var DIAG = {
|
||
lines: [],
|
||
maxLines: 300,
|
||
lastFailure: null
|
||
};
|
||
|
||
function getLogPath() {
|
||
return Folder.userData.fsName + "/VFXReviewConnector/connector.log";
|
||
}
|
||
|
||
// ExtendScript exposes no hostname directly; $.getenv reaches the shell's.
|
||
function getHostName() {
|
||
var name = "";
|
||
|
||
try {
|
||
name = $.getenv("COMPUTERNAME") || $.getenv("HOSTNAME") || "";
|
||
} catch (envError) {
|
||
name = "";
|
||
}
|
||
return trimText(name) || "unknown-machine";
|
||
}
|
||
|
||
/**
|
||
* Reports this panel's version to the server so Pipeline → Machines shows
|
||
* what is deployed where. Fire-and-forget: a failure here must never stop
|
||
* the panel loading, so it is logged and dropped.
|
||
*/
|
||
function reportPanelVersion() {
|
||
var response;
|
||
|
||
if (!trimText(CONFIG.baseUrl) || !trimText(CONFIG.token)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
response = postJSON(CONFIG.baseUrl + "/api/ext/tools/ping", {
|
||
tool: "AE_PANEL",
|
||
machineName: getHostName(),
|
||
version: PANEL_VERSION,
|
||
hostUser: CONFIG.artistEmail || "",
|
||
hostApp: "After Effects " + app.version,
|
||
details: {
|
||
os: String($.os).substring(0, 120),
|
||
project: CONFIG.lastProjectCode || ""
|
||
}
|
||
});
|
||
diagLog("version ping: HTTP " + response.status);
|
||
} catch (pingError) {
|
||
diagLog("version ping failed: " + describeError(pingError));
|
||
}
|
||
}
|
||
|
||
function padTwo(value) {
|
||
return ("0" + value).slice(-2);
|
||
}
|
||
|
||
function diagTimestamp() {
|
||
var now = new Date();
|
||
|
||
return now.getFullYear() + "-" + padTwo(now.getMonth() + 1) + "-" + padTwo(now.getDate()) +
|
||
" " + padTwo(now.getHours()) + ":" + padTwo(now.getMinutes()) + ":" + padTwo(now.getSeconds());
|
||
}
|
||
|
||
function diagWriteToFile(text) {
|
||
var dir;
|
||
var file;
|
||
|
||
try {
|
||
dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector");
|
||
if (!dir.exists) {
|
||
dir.create();
|
||
}
|
||
|
||
file = new File(getLogPath());
|
||
// Keep the log small enough to paste into a message or email.
|
||
if (file.exists && file.length > 1048576) {
|
||
file.remove();
|
||
file = new File(getLogPath());
|
||
}
|
||
|
||
file.encoding = "UTF-8";
|
||
if (!file.open("a")) {
|
||
return;
|
||
}
|
||
file.write(text + "\n");
|
||
file.close();
|
||
} catch (logError) {
|
||
}
|
||
}
|
||
|
||
function diagLog(message) {
|
||
var line = "[" + diagTimestamp() + "] " + message;
|
||
|
||
DIAG.lines.push(line);
|
||
if (DIAG.lines.length > DIAG.maxLines) {
|
||
DIAG.lines.shift();
|
||
}
|
||
diagWriteToFile(line);
|
||
}
|
||
|
||
function describeError(err) {
|
||
var text;
|
||
|
||
if (!err) {
|
||
return "unknown error";
|
||
}
|
||
|
||
text = err.toString ? err.toString() : String(err);
|
||
if (err.line) {
|
||
text += " (line " + err.line + (err.fileName ? " of " + err.fileName : "") + ")";
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// The token is never logged — only whether one is configured, which is what
|
||
// an artist actually needs to know when the API says 401.
|
||
function describeConfig() {
|
||
return "baseUrl=" + (CONFIG.baseUrl || "(empty)") +
|
||
" | token=" + (trimText(CONFIG.token) ? trimText(CONFIG.token).length + " chars" : "EMPTY") +
|
||
" | footageRoot=" + (CONFIG.footageRoot || "(empty)") +
|
||
" | exportRoot=" + (CONFIG.exportRoot || "(empty)") +
|
||
" | artistEmail=" + (CONFIG.artistEmail || "(empty)") +
|
||
" | handleFrames=" + CONFIG.handleFrames +
|
||
" | pathMappings=" + (CONFIG.pathMappings || "(none)");
|
||
}
|
||
|
||
function diagStart(action) {
|
||
DIAG.lines = [];
|
||
DIAG.lastFailure = null;
|
||
diagWriteToFile("");
|
||
diagLog("=== " + action + " ===");
|
||
diagLog("panel v" + PANEL_VERSION + " | " + getHostName());
|
||
diagLog("AE " + app.version + " | " + $.os + " | project " +
|
||
(app.project && app.project.file ? app.project.file.fsName : "(unsaved)"));
|
||
diagLog("config: " + describeConfig());
|
||
diagLog("selected project: " + (CURRENT_PROJECT
|
||
? CURRENT_PROJECT.code + " (showId " + CURRENT_PROJECT.showId + ", " + CURRENT_PROJECT.projectType + ")"
|
||
: "(none)"));
|
||
}
|
||
|
||
function diagFail(stage, detail, hints) {
|
||
var i;
|
||
|
||
DIAG.lastFailure = { stage: stage, detail: detail, hints: hints || [] };
|
||
diagLog("FAILED [" + stage + "] " + detail);
|
||
for (i = 0; hints && i < hints.length; i += 1) {
|
||
diagLog(" - " + hints[i]);
|
||
}
|
||
}
|
||
|
||
function diagReport() {
|
||
var text = DIAG.lines.join("\n");
|
||
var failure = DIAG.lastFailure;
|
||
var i;
|
||
|
||
if (failure) {
|
||
text += "\n\n--- Failure -------------------------------------------------\n";
|
||
text += "Stage: " + failure.stage + "\n";
|
||
text += "Detail: " + failure.detail + "\n";
|
||
for (i = 0; i < failure.hints.length; i += 1) {
|
||
text += " - " + failure.hints[i] + "\n";
|
||
}
|
||
}
|
||
|
||
return text + "\n\nFull log: " + getLogPath();
|
||
}
|
||
|
||
// A scrollable, selectable dialog rather than alert(): the point is that the
|
||
// artist can copy the whole trace and send it on.
|
||
function showDiagDialog(titleText, bodyText) {
|
||
var dlg;
|
||
var heading;
|
||
var box;
|
||
var row;
|
||
var openLogButton;
|
||
|
||
try {
|
||
dlg = new Window("dialog", "VFXReview Connector — Diagnostics");
|
||
dlg.orientation = "column";
|
||
dlg.alignChildren = ["fill", "top"];
|
||
dlg.margins = 14;
|
||
dlg.spacing = 8;
|
||
|
||
heading = dlg.add("statictext", undefined, titleText, { multiline: true });
|
||
heading.preferredSize = [620, 34];
|
||
|
||
box = dlg.add("edittext", undefined, bodyText, { multiline: true, scrolling: true });
|
||
box.preferredSize = [620, 340];
|
||
|
||
row = dlg.add("group");
|
||
row.orientation = "row";
|
||
row.alignment = ["right", "top"];
|
||
row.spacing = 8;
|
||
|
||
openLogButton = row.add("button", undefined, "Open Log Folder");
|
||
openLogButton.onClick = function () {
|
||
try {
|
||
new File(getLogPath()).parent.execute();
|
||
} catch (openError) {
|
||
}
|
||
};
|
||
row.add("button", undefined, "Close", { name: "ok" });
|
||
|
||
dlg.layout.layout(true);
|
||
dlg.center();
|
||
dlg.show();
|
||
} catch (dialogError) {
|
||
alert(titleText + "\n\n" + bodyText);
|
||
}
|
||
}
|
||
|
||
// ScriptUI silently swallows exceptions thrown inside onClick handlers, so a
|
||
// hard failure looks like a button that did nothing at all. Every handler is
|
||
// wrapped: the click is traced, and anything thrown is surfaced.
|
||
function guarded(label, handler) {
|
||
return function () {
|
||
diagStart(label);
|
||
try {
|
||
handler();
|
||
} catch (handlerError) {
|
||
diagFail("unexpected error", describeError(handlerError),
|
||
[$.stack ? "stack: " + String($.stack).replace(/[\r\n]+/g, " > ") : "no stack available"]);
|
||
try {
|
||
app.endUndoGroup();
|
||
} catch (undoError) {
|
||
}
|
||
updateStatus(getSelectedComps().length, label + " failed: " + describeError(handlerError));
|
||
showDiagDialog(label + " failed unexpectedly. Send this to the pipeline TD.", diagReport());
|
||
}
|
||
};
|
||
}
|
||
|
||
// The first ancestor that does exist separates "drive not mapped" from
|
||
// "show folder misnamed" from "only the leaf is missing".
|
||
function describeMissingPath(path) {
|
||
var probe = new Folder(path);
|
||
var guard = 0;
|
||
|
||
while (probe && !probe.exists && guard < 24) {
|
||
probe = probe.parent;
|
||
guard += 1;
|
||
}
|
||
|
||
if (!probe || !probe.exists) {
|
||
return "no part of that path exists on this machine — is the drive mapped?";
|
||
}
|
||
return "deepest folder that does exist: " + probe.fsName;
|
||
}
|
||
|
||
function listFolderNames(folder, limit) {
|
||
var names = [];
|
||
var entries;
|
||
var i;
|
||
|
||
try {
|
||
entries = folder.getFiles();
|
||
} catch (listError) {
|
||
return names;
|
||
}
|
||
|
||
for (i = 0; i < entries.length && names.length < limit; i += 1) {
|
||
if (entries[i] instanceof Folder) {
|
||
names.push(entries[i].name);
|
||
}
|
||
}
|
||
|
||
return names;
|
||
}
|
||
|
||
function describeFolderContents(folder) {
|
||
var entries;
|
||
var exrCount = 0;
|
||
var otherCount = 0;
|
||
var folderCount = 0;
|
||
var i;
|
||
|
||
try {
|
||
entries = folder.getFiles();
|
||
} catch (contentsError) {
|
||
return "unreadable (permissions?)";
|
||
}
|
||
|
||
for (i = 0; i < entries.length; i += 1) {
|
||
if (entries[i] instanceof Folder) {
|
||
folderCount += 1;
|
||
} else if (/\.exr$/i.test(entries[i].name)) {
|
||
exrCount += 1;
|
||
} else {
|
||
otherCount += 1;
|
||
}
|
||
}
|
||
|
||
return exrCount + " .exr, " + otherCount + " other file(s), " + folderCount + " sub-folder(s)";
|
||
}
|
||
|
||
function getSelectedComps() {
|
||
var comps = [];
|
||
var selection;
|
||
var i;
|
||
|
||
if (!app.project) {
|
||
return comps;
|
||
}
|
||
|
||
selection = app.project.selection;
|
||
for (i = 0; i < selection.length; i += 1) {
|
||
if (selection[i] instanceof CompItem) {
|
||
comps.push(selection[i]);
|
||
}
|
||
}
|
||
|
||
return comps;
|
||
}
|
||
|
||
// GET that keeps the HTTP code and the raw response body, so "could not
|
||
// reach the server" and "server said 401" stay distinguishable instead of
|
||
// both arriving as null. curl's own errors are folded into stdout with
|
||
// --stderr - rather than a 2>&1 redirect, which needs a shell that
|
||
// system.callSystem does not reliably give us. Returns { status, body, data }.
|
||
function httpGetDetailed(url) {
|
||
var command;
|
||
var response = null;
|
||
var markerIndex;
|
||
var result = { status: 0, body: "", data: null };
|
||
|
||
command = "curl -s -S --stderr - " +
|
||
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
|
||
"-H \"Accept: application/json\" " +
|
||
"-w \"__HTTP__:%{http_code}\" " +
|
||
"\"" + url + "\"";
|
||
|
||
try {
|
||
response = system.callSystem(command);
|
||
} catch (callError) {
|
||
result.body = "curl could not be run: " + describeError(callError);
|
||
return result;
|
||
}
|
||
|
||
if (!response) {
|
||
result.body = "empty response (curl produced no output)";
|
||
return result;
|
||
}
|
||
|
||
markerIndex = String(response).lastIndexOf("__HTTP__:");
|
||
if (markerIndex >= 0) {
|
||
result.status = parseInt(String(response).substring(markerIndex + 9), 10) || 0;
|
||
result.body = String(response).substring(0, markerIndex);
|
||
} else {
|
||
result.body = String(response);
|
||
}
|
||
|
||
result.data = parseJSON(result.body);
|
||
return result;
|
||
}
|
||
|
||
function summariseResponse(result) {
|
||
var body = trimText(result.body).replace(/[\r\n]+/g, " ");
|
||
|
||
if (body.length > 200) {
|
||
body = body.substring(0, 200) + "...";
|
||
}
|
||
return "HTTP " + result.status + (body ? " — " + body : "");
|
||
}
|
||
|
||
// Hints that turn the HTTP code into the thing to actually go and check.
|
||
function hintsForResponse(result) {
|
||
if (result.status === 0) {
|
||
return ["The server was not reached at all: check Server URL in Settings, that the machine can " +
|
||
"resolve/route to it, and that curl.exe exists (Windows 10 1803+ ships it)."];
|
||
}
|
||
if (result.status === 401 || result.status === 403) {
|
||
return ["The API token was rejected: paste the token from the server .env (API_SECRET_KEY) into Settings."];
|
||
}
|
||
if (result.status === 404) {
|
||
return ["The server answered but has no such record — check the shot/project exists in the web app."];
|
||
}
|
||
if (result.status >= 500) {
|
||
return ["The server errored. Check the web app logs for the same timestamp."];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getShotData(shotCode) {
|
||
var url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
|
||
"?shotCode=" + encodeURIComponent(shotCode) +
|
||
"&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
|
||
var result = httpGetDetailed(url);
|
||
|
||
DIAG.lastResponse = result;
|
||
|
||
if (!result.data || !result.data.shot) {
|
||
diagLog("shot lookup " + shotCode + ": " + summariseResponse(result));
|
||
}
|
||
|
||
return result.data;
|
||
}
|
||
|
||
function getAPIData(url) {
|
||
var result = httpGetDetailed(url);
|
||
|
||
DIAG.lastResponse = result;
|
||
|
||
if (!result.data) {
|
||
diagLog("GET " + url + ": " + summariseResponse(result));
|
||
}
|
||
|
||
return result.data;
|
||
}
|
||
|
||
function getListFromResponse(data, keyName) {
|
||
if (!data) {
|
||
return [];
|
||
}
|
||
|
||
if (data.length !== undefined) {
|
||
return data;
|
||
}
|
||
|
||
if (keyName && data[keyName] && data[keyName].length !== undefined) {
|
||
return data[keyName];
|
||
}
|
||
|
||
if (data.data && data.data.length !== undefined) {
|
||
return data.data;
|
||
}
|
||
|
||
if (data.results && data.results.length !== undefined) {
|
||
return data.results;
|
||
}
|
||
|
||
if (data.items && data.items.length !== undefined) {
|
||
return data.items;
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function padEpisodeNumber(value) {
|
||
var text = String(value);
|
||
|
||
while (text.length < 3) {
|
||
text = "0" + text;
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
// Episode folders on disk are named {showId}_{episode} (UNG/UNG_102/...), so
|
||
// the prefix comes from the selected project rather than a hardcoded "UNG_".
|
||
function deriveEpisodeCode(value) {
|
||
var text = trimText(value);
|
||
var showId = getShowId();
|
||
|
||
if (showId && text.indexOf(showId + "_") === 0) {
|
||
return text;
|
||
}
|
||
|
||
if (/^\d+$/.test(text)) {
|
||
return (showId ? showId + "_" : "") + padEpisodeNumber(text);
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
function deriveEpisodeAPIValue(value) {
|
||
var text = trimText(value);
|
||
var showId = getShowId();
|
||
var match;
|
||
|
||
if (showId && text.indexOf(showId + "_") === 0) {
|
||
text = text.substring(showId.length + 1);
|
||
}
|
||
|
||
match = text.match(/^\d+/);
|
||
if (match) {
|
||
return String(parseInt(match[0], 10));
|
||
}
|
||
|
||
match = text.match(/[A-Z]{3}_(\d{3})/);
|
||
if (match && match.length > 1) {
|
||
return String(parseInt(match[1], 10));
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
function getEpisodeValue(item) {
|
||
if (item === null || item === undefined) {
|
||
return "";
|
||
}
|
||
|
||
if (typeof item !== "object") {
|
||
return deriveEpisodeAPIValue(item);
|
||
}
|
||
|
||
return deriveEpisodeAPIValue(item.episode || item.episodeNumber || item.number || item.id || item.code || item.episodeCode || item.name || "");
|
||
}
|
||
|
||
function getEpisodeLabel(item) {
|
||
if (item === null || item === undefined) {
|
||
return "";
|
||
}
|
||
|
||
if (typeof item !== "object") {
|
||
return deriveEpisodeCode(item);
|
||
}
|
||
|
||
return String(item.episodeCode || item.code || item.name || deriveEpisodeCode(getEpisodeValue(item)));
|
||
}
|
||
|
||
function getShotCodeValue(item) {
|
||
if (item === null || item === undefined) {
|
||
return "";
|
||
}
|
||
|
||
if (typeof item !== "object") {
|
||
return String(item);
|
||
}
|
||
|
||
return String(item.shotCode || item.code || item.name || "");
|
||
}
|
||
|
||
function getEpisodesFromAPI() {
|
||
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/episodes";
|
||
var data = getAPIData(url);
|
||
return getListFromResponse(data, "episodes");
|
||
}
|
||
|
||
function getShotsFromAPI(value) {
|
||
var param = isEpisodicProject() ? "episode" : "scene";
|
||
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?" + param + "=" + encodeURIComponent(value);
|
||
var data = getAPIData(url);
|
||
return getListFromResponse(data, "shots");
|
||
}
|
||
|
||
function getScenesFromAPI() {
|
||
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?limit=500";
|
||
var data = getAPIData(url);
|
||
var shots = getListFromResponse(data, "shots");
|
||
var seen = {};
|
||
var scenes = [];
|
||
for (var i = 0; i < shots.length; i += 1) {
|
||
var sc = shots[i].scene || "";
|
||
if (sc && !seen[sc]) { seen[sc] = true; scenes.push(sc); }
|
||
}
|
||
scenes.sort();
|
||
return scenes;
|
||
}
|
||
|
||
function getProjectsFromAPI() {
|
||
var url = CONFIG.baseUrl + "/api/ext/projects";
|
||
var data = getAPIData(url);
|
||
return getListFromResponse(data, "projects");
|
||
}
|
||
|
||
function normalizeShotData(data) {
|
||
if (!data) {
|
||
return null;
|
||
}
|
||
|
||
if (data.shotCode) {
|
||
return data;
|
||
}
|
||
|
||
if (data.shot && data.shot.shotCode) {
|
||
return data.shot;
|
||
}
|
||
|
||
if (data.data && data.data.shotCode) {
|
||
return data.data;
|
||
}
|
||
|
||
if (data.result && data.result.shotCode) {
|
||
return data.result;
|
||
}
|
||
|
||
if (data.results && data.results.length && data.results[0].shotCode) {
|
||
return data.results[0];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// The showId is the single source of truth for both the on-disk show folder
|
||
// and the shot-code prefix: UNG (episodic) and DH2_VFX (standard) both follow
|
||
// {showId}_{...}, so anything parsing a code anchors on this rather than
|
||
// counting underscores or assuming a three-letter code.
|
||
function getShowId() {
|
||
return CURRENT_PROJECT ? trimText(CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : "";
|
||
}
|
||
|
||
function isEpisodicProject() {
|
||
return !!(CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC");
|
||
}
|
||
|
||
function escapeRegexText(text) {
|
||
return String(text).replace(/([.*+?^${}()|[\]\\\-])/g, "\\$1");
|
||
}
|
||
|
||
// Shot codes are {showId}_{episode}_{scene}_{shot} on episodic shows and
|
||
// {showId}_{scene}_{shot} on standard ones -- so the number of 3-digit tokens
|
||
// after the showId is what distinguishes them.
|
||
function getShotCodeRegex() {
|
||
var showId = getShowId();
|
||
|
||
if (!showId) {
|
||
return LEGACY_SHOT_CODE_REGEX;
|
||
}
|
||
|
||
return new RegExp("(" + escapeRegexText(showId) +
|
||
"(?:_\\d{3}){" + (isEpisodicProject() ? 3 : 2) + "})");
|
||
}
|
||
|
||
function isEpisodeCode(value) {
|
||
var showId = getShowId();
|
||
|
||
if (!showId) {
|
||
return LEGACY_EPISODE_CODE_REGEX.test(String(value));
|
||
}
|
||
|
||
return new RegExp("^" + escapeRegexText(showId) + "_\\d{3}$").test(String(value));
|
||
}
|
||
|
||
function getShowExportRoot() {
|
||
var showId = getShowId();
|
||
return CONFIG.exportRoot + (showId ? "/" + showId : "");
|
||
}
|
||
|
||
function getShowFootageRoot() {
|
||
var showId = getShowId();
|
||
return CONFIG.footageRoot + (showId ? "/" + showId : "");
|
||
}
|
||
|
||
// First 3-digit token after the showId, for episodic shots whose API record
|
||
// is missing `episode`.
|
||
function deriveEpisodeFromShotCode(shotCode) {
|
||
var showId = getShowId();
|
||
var text = String(shotCode);
|
||
var match;
|
||
|
||
if (showId && text.indexOf(showId + "_") === 0) {
|
||
match = text.substring(showId.length + 1).match(/^(\d{3})_/);
|
||
return match ? match[1] : "";
|
||
}
|
||
|
||
match = text.match(/^[A-Z]{3}_(\d{3})_/);
|
||
return match ? match[1] : "";
|
||
}
|
||
|
||
// The prefix episode-wide files share: "UNG_102" for episodic shows, plain
|
||
// "DH2_VFX" for standard ones, which have no episode level. Replaces
|
||
// shotCode.substring(0, 7), which only produced this for a 3-letter showId.
|
||
function getEpisodePrefix(shotCode) {
|
||
var showId = getShowId();
|
||
var episode;
|
||
|
||
if (!isEpisodicProject()) {
|
||
return showId;
|
||
}
|
||
|
||
episode = deriveEpisodeFromShotCode(shotCode);
|
||
return episode ? showId + "_" + episode : showId;
|
||
}
|
||
|
||
// Deliveries are grouped per episode ("DELIVERY/UNG_102/..."). Standard shows
|
||
// have nothing to group by, so they get no segment at all.
|
||
function getDeliveryGroupSegment(shotCode) {
|
||
return isEpisodicProject() ? "/" + getEpisodePrefix(shotCode) : "";
|
||
}
|
||
|
||
// Mirrors the server's export layout so farm and local renders land together:
|
||
// {exportRoot}/{showId}/[{episode} for episodic]/{shotCode}/{version}
|
||
function getShotExportDir(shot) {
|
||
var path = getShowExportRoot();
|
||
var episode;
|
||
|
||
if (isEpisodicProject()) {
|
||
episode = trimText(shot.episode);
|
||
if (!episode) {
|
||
episode = deriveEpisodeFromShotCode(shot.shotCode);
|
||
}
|
||
if (episode) {
|
||
path += "/" + episode;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Returns *every* rewrite whose prefix matches, not just the first: a shop
|
||
// with more than one on-disk convention (an old export root and a current
|
||
// one) can map the same prefix twice, and resolveServerPath picks whichever
|
||
// candidate actually exists.
|
||
function applyPathMappings(path) {
|
||
var mappings = parsePathMappings();
|
||
var normalized = normalizeSlashes(path);
|
||
var results = [];
|
||
var i;
|
||
|
||
for (i = 0; i < mappings.length; i += 1) {
|
||
if (normalized.toLowerCase().indexOf(mappings[i].from.toLowerCase()) === 0) {
|
||
results.push(mappings[i].to + normalized.substring(mappings[i].from.length));
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// 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;
|
||
|
||
for (i = 0; i < mapped.length; i += 1) {
|
||
candidates.push({ path: mapped[i], 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;
|
||
|
||
if (folder.exists) {
|
||
return true;
|
||
}
|
||
|
||
parent = folder.parent;
|
||
if (parent && !parent.exists) {
|
||
if (!ensureFolder(parent.fsName)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
return folder.create();
|
||
} catch (folderError) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function setCompStartFrame(comp, frameNumber) {
|
||
try {
|
||
comp.displayStartFrame = frameNumber;
|
||
return;
|
||
} catch (frameError) {
|
||
}
|
||
|
||
try {
|
||
comp.displayStartTime = frameNumber / comp.frameRate;
|
||
} catch (timeError) {
|
||
}
|
||
}
|
||
|
||
function deselectAllProjectItems() {
|
||
var i;
|
||
|
||
try {
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
app.project.item(i).selected = false;
|
||
}
|
||
} catch (deselectError) {
|
||
}
|
||
}
|
||
|
||
// AE scripting exposes no direct read of a footage item's embedded start
|
||
// timecode. Recreate the UI behaviour instead: "New Comp from Selection"
|
||
// inherits the footage's start timecode from the file metadata, so build a
|
||
// throwaway comp, read the start time AE gave it, then delete it.
|
||
//
|
||
// Returns null when the probe could not run (menu command unavailable or
|
||
// disabled), so callers can fall back to the API timecode.
|
||
function probeFootageStartFrames(footageItem, fps) {
|
||
var commandId;
|
||
var projectPanelId;
|
||
var existingIds = {};
|
||
var probeComp = null;
|
||
var frames = null;
|
||
var i;
|
||
var item;
|
||
|
||
try {
|
||
commandId = app.findMenuCommandId("New Comp from Selection");
|
||
if (!commandId) {
|
||
return null;
|
||
}
|
||
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
existingIds[app.project.item(i).id] = true;
|
||
}
|
||
|
||
// executeCommand mirrors the menu bar, so the Project panel has to
|
||
// be frontmost or "New Comp from Selection" is greyed out and the
|
||
// call silently does nothing.
|
||
try {
|
||
projectPanelId = app.findMenuCommandId("Project");
|
||
if (projectPanelId) {
|
||
app.executeCommand(projectPanelId);
|
||
}
|
||
} catch (focusError) {
|
||
}
|
||
|
||
deselectAllProjectItems();
|
||
footageItem.selected = true;
|
||
app.executeCommand(commandId);
|
||
|
||
// Only ever touch a comp that did not exist before the probe —
|
||
// never app.project.activeItem, which is an existing open comp
|
||
// when the command did not run.
|
||
for (i = app.project.numItems; i >= 1; i -= 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof CompItem && !existingIds[item.id]) {
|
||
probeComp = item;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (probeComp) {
|
||
frames = Math.round((probeComp.displayStartTime || 0) * (fps || probeComp.frameRate || 24));
|
||
}
|
||
} catch (probeError) {
|
||
frames = null;
|
||
}
|
||
|
||
try {
|
||
if (probeComp) {
|
||
probeComp.remove();
|
||
}
|
||
} catch (removeError) {
|
||
}
|
||
|
||
return frames;
|
||
}
|
||
|
||
// The plate's own start timecode: AE metadata when we can read it,
|
||
// otherwise the API's source clip timecode less the handle frames — the
|
||
// same convention the Set Start Timecode button uses.
|
||
function getFootageStartFrames(footageItem, apiShot, fps) {
|
||
var probed = probeFootageStartFrames(footageItem, fps);
|
||
var frames;
|
||
|
||
if (probed !== null && probed > 0) {
|
||
return { frames: probed, source: "footage metadata" };
|
||
}
|
||
|
||
if (apiShot && apiShot.timecodeStart) {
|
||
frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - CONFIG.handleFrames;
|
||
if (frames > 0) {
|
||
return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + CONFIG.handleFrames + "f" };
|
||
}
|
||
}
|
||
|
||
return { frames: 0, source: probed === 0 ? "footage starts at 00:00:00:00" : "no timecode found" };
|
||
}
|
||
|
||
function findComp(name) {
|
||
var i;
|
||
var item;
|
||
|
||
if (!app.project) {
|
||
return null;
|
||
}
|
||
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof CompItem && item.name === name) {
|
||
return item;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findFolder(name) {
|
||
var i;
|
||
var item;
|
||
|
||
if (!app.project) {
|
||
return null;
|
||
}
|
||
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof FolderItem && item.name === name) {
|
||
return item;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findEpisodeProjectFolder(episodeCode) {
|
||
var shotsRoot = findFolder("__SHOTS");
|
||
var i;
|
||
var item;
|
||
|
||
if (!shotsRoot) {
|
||
return findFolder(episodeCode);
|
||
}
|
||
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof FolderItem && item.name === episodeCode && item.parentFolder === shotsRoot) {
|
||
return item;
|
||
}
|
||
}
|
||
|
||
return findFolder(episodeCode);
|
||
}
|
||
|
||
// The folder a shot's plates are grouped under: the {showId}_{episode} folder
|
||
// on episodic shows, the show root on standard ones -- a scene never gets its
|
||
// own folder on disk, so the scene value from the dropdown is not a path.
|
||
function getFootageGroupFolder(groupCode) {
|
||
var root = getShowFootageRoot();
|
||
|
||
if (isEpisodicProject() && trimText(groupCode)) {
|
||
return new Folder(root + "/" + trimText(groupCode));
|
||
}
|
||
|
||
return new Folder(root);
|
||
}
|
||
|
||
// Underscore-prefixed folders are never shots or plates: UNG already uses the
|
||
// convention for _ARCHIVE, _GFX, _PICLOCKS and _PULLS.
|
||
function isNonShotFolderName(name) {
|
||
return String(name).indexOf("_") === 0;
|
||
}
|
||
|
||
function getEpisodeCodesFromDisk() {
|
||
var root = new Folder(getShowFootageRoot());
|
||
var folders;
|
||
var codes = [];
|
||
var i;
|
||
|
||
if (!root.exists) {
|
||
return codes;
|
||
}
|
||
|
||
folders = root.getFiles();
|
||
for (i = 0; i < folders.length; i += 1) {
|
||
if (folders[i] instanceof Folder) {
|
||
codes.push(folders[i].name);
|
||
}
|
||
}
|
||
|
||
codes.sort();
|
||
return codes;
|
||
}
|
||
|
||
function addUniqueText(values, value) {
|
||
var i;
|
||
|
||
for (i = 0; i < values.length; i += 1) {
|
||
if (values[i] === value) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
values.push(value);
|
||
}
|
||
|
||
function getShotCodesForEpisode(episodeCode) {
|
||
var root = getFootageGroupFolder(episodeCode);
|
||
var folders;
|
||
var shotCodes = [];
|
||
var i;
|
||
var shotCode;
|
||
|
||
if (!root.exists) {
|
||
return shotCodes;
|
||
}
|
||
|
||
folders = root.getFiles();
|
||
for (i = 0; i < folders.length; i += 1) {
|
||
if (folders[i] instanceof Folder) {
|
||
shotCode = extractShotCode(folders[i].name);
|
||
if (shotCode && folders[i].name.indexOf(shotCode) === 0) {
|
||
addUniqueText(shotCodes, shotCode);
|
||
}
|
||
}
|
||
}
|
||
|
||
shotCodes.sort();
|
||
return shotCodes;
|
||
}
|
||
|
||
// Two layouts in the wild and neither is being migrated -- moving delivered
|
||
// footage would break the file links in every AE project already pointing at
|
||
// it. DH2 groups a shot's plates under a {shotCode}/ folder; UNG scatters them
|
||
// directly in the episode folder:
|
||
//
|
||
// {show}/[{show}_{episode}/]{shotCode}/{shotCode}_{ELEM}_{VER}/ (standard)
|
||
// {show}/{show}_{episode}/{shotCode}_{ELEM}_{VER}/ (UNG)
|
||
//
|
||
// The grouped form wins outright where it exists -- an empty shot folder means
|
||
// "no plates yet", not "go look at the show root", where stray plate folders
|
||
// for the same shot code would otherwise be picked up as its footage.
|
||
function findSequenceFolders(shotCode, groupCode) {
|
||
var root = getFootageGroupFolder(groupCode);
|
||
var shotFolder;
|
||
var folders;
|
||
var matches = [];
|
||
var i;
|
||
|
||
if (!root.exists) {
|
||
return matches;
|
||
}
|
||
|
||
shotFolder = new Folder(root.fsName + "/" + shotCode);
|
||
if (shotFolder.exists) {
|
||
folders = shotFolder.getFiles();
|
||
for (i = 0; i < folders.length; i += 1) {
|
||
if (folders[i] instanceof Folder && !isNonShotFolderName(folders[i].name)) {
|
||
matches.push(folders[i]);
|
||
}
|
||
}
|
||
matches.sort();
|
||
return matches;
|
||
}
|
||
|
||
folders = root.getFiles();
|
||
for (i = 0; i < folders.length; i += 1) {
|
||
if (folders[i] instanceof Folder && folders[i].name.indexOf(shotCode + "_") === 0) {
|
||
matches.push(folders[i]);
|
||
}
|
||
}
|
||
|
||
matches.sort();
|
||
return matches;
|
||
}
|
||
|
||
function importEXRSequence(folder) {
|
||
var files = folder.getFiles("*.exr");
|
||
var importOptions;
|
||
var footage;
|
||
|
||
if (files.length < 1) {
|
||
return null;
|
||
}
|
||
|
||
files.sort();
|
||
importOptions = new ImportOptions(files[0]);
|
||
importOptions.sequence = true;
|
||
footage = app.project.importFile(importOptions);
|
||
footage.mainSource.conformFrameRate = 24;
|
||
return footage;
|
||
}
|
||
|
||
function addOCIOToLayer(layer) {
|
||
var ocio;
|
||
var outputProp;
|
||
|
||
try {
|
||
ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
|
||
ocio.enabled = false;
|
||
} catch (ocioError) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
outputProp = ocio.property("Output Color Space");
|
||
} catch (propNameError) {
|
||
outputProp = null;
|
||
}
|
||
if (!outputProp) {
|
||
try {
|
||
outputProp = ocio.property(2);
|
||
} catch (propIdxError) {
|
||
outputProp = null;
|
||
}
|
||
}
|
||
if (outputProp) {
|
||
try {
|
||
outputProp.setValue(93);
|
||
} catch (setValueError) {
|
||
}
|
||
}
|
||
}
|
||
|
||
function getDateString() {
|
||
var today = new Date();
|
||
|
||
return today.getFullYear() +
|
||
"/" +
|
||
("0" + (today.getMonth() + 1)).slice(-2) +
|
||
"/" +
|
||
("0" + today.getDate()).slice(-2);
|
||
}
|
||
|
||
function parseTimecodeToSeconds(tc, fps) {
|
||
var parts = String(tc).split(/[:;]/);
|
||
var hh;
|
||
var mm;
|
||
var ss;
|
||
var ff;
|
||
|
||
if (parts.length < 4) {
|
||
return 0;
|
||
}
|
||
|
||
hh = parseInt(parts[0], 10);
|
||
mm = parseInt(parts[1], 10);
|
||
ss = parseInt(parts[2], 10);
|
||
ff = parseInt(parts[3], 10);
|
||
|
||
return hh * 3600 + mm * 60 + ss + ff / (fps || 24);
|
||
}
|
||
|
||
function parseTimecodeToFrames(tc, fps) {
|
||
var parts = String(tc).split(/[:;]/);
|
||
var hh;
|
||
var mm;
|
||
var ss;
|
||
var ff;
|
||
|
||
if (parts.length < 4) {
|
||
return 0;
|
||
}
|
||
|
||
hh = parseInt(parts[0], 10);
|
||
mm = parseInt(parts[1], 10);
|
||
ss = parseInt(parts[2], 10);
|
||
ff = parseInt(parts[3], 10);
|
||
|
||
return (hh * 3600 + mm * 60 + ss) * Math.round(fps || 24) + ff;
|
||
}
|
||
|
||
function findOverlayLayer(comp) {
|
||
var i;
|
||
var layer;
|
||
|
||
for (i = 1; i <= comp.numLayers; i += 1) {
|
||
layer = comp.layer(i);
|
||
|
||
try {
|
||
if (layer.source && layer.source instanceof CompItem && layer.source.name === "UNG_VFX_OVERLAY") {
|
||
return layer;
|
||
}
|
||
} catch (layerError) {
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function setEssentialProperty(layer, propName, value) {
|
||
try {
|
||
layer.property("Essential Properties").property(propName).setValue(String(value));
|
||
return true;
|
||
} catch (propertyError) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function applyOutputModuleTemplate(outputModule, templateName) {
|
||
try {
|
||
outputModule.applyTemplate(templateName);
|
||
return true;
|
||
} catch (templateError) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function queueCompWithOutput(comp, templateName, outputPath) {
|
||
var renderQueueItem;
|
||
var outputModule;
|
||
|
||
try {
|
||
renderQueueItem = app.project.renderQueue.items.add(comp);
|
||
outputModule = renderQueueItem.outputModule(1);
|
||
if (!applyOutputModuleTemplate(outputModule, templateName)) {
|
||
renderQueueItem.remove();
|
||
return false;
|
||
}
|
||
outputModule.file = new File(outputPath);
|
||
return true;
|
||
} catch (queueError) {
|
||
try {
|
||
if (renderQueueItem) {
|
||
renderQueueItem.remove();
|
||
}
|
||
} catch (removeError) {
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function updateStatus(selectedCount, message) {
|
||
if (uiState.selectionText) {
|
||
uiState.selectionText.text = selectedCount + " comps selected";
|
||
}
|
||
if (uiState.lastActionText) {
|
||
uiState.lastActionText.text = message || "";
|
||
}
|
||
}
|
||
|
||
function refreshSelection() {
|
||
var comps = getSelectedComps();
|
||
var message = comps.length + " comps selected";
|
||
updateStatus(comps.length, message);
|
||
return comps;
|
||
}
|
||
|
||
function extractShotCode(name) {
|
||
var text = String(name);
|
||
var match = text.match(getShotCodeRegex());
|
||
|
||
if (match && match.length > 1) {
|
||
return match[1];
|
||
}
|
||
|
||
// A comp from a different show than the one selected in the panel: read it
|
||
// with the old pattern rather than reporting no shot code at all.
|
||
match = text.match(LEGACY_SHOT_CODE_REGEX);
|
||
if (match && match.length > 1) {
|
||
return match[1];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getShotCodeFromComp(comp) {
|
||
return extractShotCode(comp.name) || comp.name;
|
||
}
|
||
|
||
function isPreviewComp(comp) {
|
||
return /_PREVIEW$/.test(comp.name);
|
||
}
|
||
|
||
function getMainCompForSelection(comp) {
|
||
var shotCode = getShotCodeFromComp(comp);
|
||
|
||
if (isPreviewComp(comp)) {
|
||
return findComp(shotCode);
|
||
}
|
||
|
||
return comp;
|
||
}
|
||
|
||
function getPreviewCompName(shotCode) {
|
||
return shotCode + "_PREVIEW";
|
||
}
|
||
|
||
function getPreviewCompForShot(shotCode) {
|
||
return findComp(getPreviewCompName(shotCode));
|
||
}
|
||
|
||
function getTargetComps() {
|
||
var comps = getSelectedComps();
|
||
|
||
if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
|
||
comps.push(app.project.activeItem);
|
||
}
|
||
|
||
return comps;
|
||
}
|
||
|
||
function disableLayerByName(comp, layerName) {
|
||
var i;
|
||
var layer;
|
||
|
||
for (i = 1; i <= comp.numLayers; i += 1) {
|
||
try {
|
||
layer = comp.layer(i);
|
||
if (layer.name === layerName || (layer.source && layer.source instanceof CompItem && layer.source.name === layerName)) {
|
||
layer.enabled = false;
|
||
}
|
||
} catch (disableError) {
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateOverlayForComp(comp, shotCode, apiData) {
|
||
var overlayComp = findComp("UNG_VFX_OVERLAY");
|
||
var overlayLayer;
|
||
var essentialProperties;
|
||
var overlayShotCode;
|
||
|
||
if (!overlayComp || !apiData || !apiData.shot) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
overlayShotCode = apiData.shot.shotCode || shotCode;
|
||
overlayLayer = findOverlayLayer(comp);
|
||
if (!overlayLayer) {
|
||
overlayLayer = comp.layers.add(overlayComp);
|
||
overlayLayer.moveToBeginning();
|
||
}
|
||
|
||
overlayLayer.enabled = true;
|
||
essentialProperties = overlayLayer.property("Essential Properties");
|
||
essentialProperties.property("DATE").setValue(getDateString());
|
||
essentialProperties.property("SHOT NAME").setValue(overlayShotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001"));
|
||
return true;
|
||
} catch (overlayError) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function makeBatchResult(total) {
|
||
return {
|
||
total: total,
|
||
queued: 0,
|
||
renamed: 0,
|
||
skipped: 0,
|
||
failures: []
|
||
};
|
||
}
|
||
|
||
function addFailure(result, compName, reason) {
|
||
result.skipped += 1;
|
||
result.failures.push(compName + ": " + reason);
|
||
}
|
||
|
||
function fixCompNames() {
|
||
var comps = getSelectedComps();
|
||
var renamed = 0;
|
||
var i;
|
||
var shotCode;
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Fix Comp Names");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = extractShotCode(comps[i].name);
|
||
if (shotCode && comps[i].name !== shotCode) {
|
||
comps[i].name = shotCode;
|
||
renamed += 1;
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, renamed + " comp(s) renamed.");
|
||
}
|
||
|
||
function queueEXR() {
|
||
var comps = getSelectedComps();
|
||
var queued = 0;
|
||
var i;
|
||
var comp;
|
||
var lookupCode;
|
||
var shot;
|
||
var result = makeBatchResult(comps.length);
|
||
var shotCode;
|
||
var exrOutput;
|
||
var folderPath;
|
||
var outputPath;
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Queue EXR");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = comps[i];
|
||
lookupCode = getShotCodeFromComp(comp);
|
||
updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
shot = normalizeShotData(getShotData(lookupCode));
|
||
|
||
if (!shot || !shot.shotCode || !shot.exrOutput) {
|
||
addFailure(result, comp.name, "API lookup failed or missing EXR output");
|
||
continue;
|
||
}
|
||
|
||
shotCode = shot.shotCode;
|
||
exrOutput = shot.exrOutput;
|
||
disableLayerByName(comp, "UNG_VFX_OVERLAY");
|
||
disableLayerByName(comp, "_SHOW LUT");
|
||
|
||
folderPath = getShotExportDir(shot);
|
||
if (!ensureFolder(folderPath)) {
|
||
addFailure(result, comp.name, "Could not create output folder");
|
||
continue;
|
||
}
|
||
|
||
outputPath = folderPath + "/" + exrOutput + ".[#####].exr";
|
||
if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
|
||
queued += 1;
|
||
result.queued += 1;
|
||
} else {
|
||
addFailure(result, comp.name, "Could not queue EXR render");
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
|
||
}
|
||
|
||
function addOverlay() {
|
||
var comps = getSelectedComps();
|
||
var overlayComp = findComp("UNG_VFX_OVERLAY");
|
||
var overlayLayer;
|
||
var added = 0;
|
||
var updated = 0;
|
||
var skipped = 0;
|
||
var i;
|
||
var shotCode;
|
||
var data;
|
||
var dateString;
|
||
var essentialProperties;
|
||
|
||
if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
|
||
comps.push(app.project.activeItem);
|
||
}
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select a comp first.");
|
||
return;
|
||
}
|
||
|
||
if (!overlayComp) {
|
||
updateStatus(comps.length, "UNG_VFX_OVERLAY not found.");
|
||
return;
|
||
}
|
||
|
||
dateString = getDateString();
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Add Overlay");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = getShotCodeFromComp(comps[i]);
|
||
updateStatus(comps.length, "Updating overlay for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
data = getShotData(shotCode);
|
||
|
||
if (!data || !data.shot) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
overlayLayer = findOverlayLayer(comps[i]);
|
||
if (!overlayLayer) {
|
||
overlayLayer = comps[i].layers.add(overlayComp);
|
||
overlayLayer.moveToBeginning();
|
||
added += 1;
|
||
}
|
||
|
||
essentialProperties = overlayLayer.property("Essential Properties");
|
||
essentialProperties.property("DATE").setValue(dateString);
|
||
essentialProperties.property("SHOT NAME").setValue(shotCode + "_cmp_TT_" + (data.shot.shotVersion || "v001"));
|
||
updated += 1;
|
||
} catch (overlayError) {
|
||
skipped += 1;
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, updated + " overlay(s) updated. " + added + " added. " + skipped + " skipped.");
|
||
}
|
||
|
||
function buildPreviewForShot(shotComp, shotCode, apiData) {
|
||
var templateComp = findComp("UNG_EXPORT_TEMPLATE");
|
||
var previewsFolder = findFolder("_PREVIEWS");
|
||
var shot;
|
||
var previewComp;
|
||
var slateLayer;
|
||
var frameCount;
|
||
var essentialProperties;
|
||
var shotLayer;
|
||
|
||
if (!templateComp || !apiData || !apiData.shot) {
|
||
return null;
|
||
}
|
||
|
||
shot = apiData.shot;
|
||
previewComp = null;
|
||
|
||
try {
|
||
previewComp = templateComp.duplicate();
|
||
previewComp.name = getPreviewCompName(shotCode);
|
||
|
||
if (previewsFolder) {
|
||
previewComp.parentFolder = previewsFolder;
|
||
}
|
||
|
||
previewComp.layer("SHOT").replaceSource(shotComp, false);
|
||
previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);
|
||
|
||
slateLayer = previewComp.layer("NETFLIX_SLATE");
|
||
frameCount = Math.round(shotComp.duration * shotComp.frameRate);
|
||
essentialProperties = slateLayer.property("Essential Properties");
|
||
|
||
essentialProperties.property(3).setValue(shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"));
|
||
setEssentialProperty(slateLayer, "Date", getDateString());
|
||
setEssentialProperty(slateLayer, "Desc", shot.description);
|
||
setEssentialProperty(slateLayer, "Notes", shot.notes);
|
||
essentialProperties.property(10).setValue(shotCode);
|
||
setEssentialProperty(slateLayer, "Episode", shot.episode);
|
||
setEssentialProperty(slateLayer, "Scene", shot.scene);
|
||
setEssentialProperty(slateLayer, "Frames", frameCount);
|
||
|
||
shotLayer = previewComp.layer("SHOT");
|
||
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
|
||
if (targetOutPoint > previewComp.duration) {
|
||
previewComp.duration = targetOutPoint;
|
||
}
|
||
shotLayer.outPoint = targetOutPoint;
|
||
previewComp.duration = targetOutPoint;
|
||
|
||
return previewComp;
|
||
} catch (previewError) {
|
||
try {
|
||
if (previewComp) {
|
||
previewComp.remove();
|
||
}
|
||
} catch (removePreviewError) {
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// groupCode is the episode code for episodic shows and the scene for standard
|
||
// ones -- see getFootageGroupFolder for how each resolves on disk.
|
||
function buildShotFromCode(groupCode, shotCode) {
|
||
var existingShotComp = findComp(shotCode);
|
||
var sequenceFolders;
|
||
var footageItems = [];
|
||
var footageFolder = findFolder("_FOOTAGE_4K");
|
||
var precompFolder = findFolder("_PRECOMPS");
|
||
var episodeProjectFolder = findEpisodeProjectFolder(groupCode);
|
||
var apiData;
|
||
var footage;
|
||
var footageComp;
|
||
var shotComp;
|
||
var footageLayer;
|
||
var showLut;
|
||
var lutLayer;
|
||
var maxDuration = 0;
|
||
var width = 1920;
|
||
var height = 1080;
|
||
var primaryFootage = null;
|
||
var startInfo;
|
||
var i;
|
||
var groupFolder;
|
||
var folderNames;
|
||
var emptyFolderNotes = [];
|
||
var importFailure;
|
||
|
||
diagLog("build: shot=" + shotCode + " group=" + (groupCode || "(none)") +
|
||
" episodic=" + isEpisodicProject());
|
||
|
||
if (!CURRENT_PROJECT) {
|
||
diagFail("project", "no project is selected in the panel",
|
||
["Pick a project in the Project dropdown. If it is empty, the server URL or token is wrong."]);
|
||
return null;
|
||
}
|
||
|
||
apiData = getShotData(shotCode);
|
||
if (!apiData || !apiData.shot) {
|
||
diagFail("shot lookup", "the server returned no shot record for " + shotCode +
|
||
" (" + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response") + ")",
|
||
hintsForResponse(DIAG.lastResponse || { status: 0, body: "" }).concat(
|
||
["Looked up under project " + CURRENT_PROJECT.code + " — a shot belonging to another project will not be found."]));
|
||
return null;
|
||
}
|
||
diagLog("shot found: version " + (apiData.shot.shotVersion || "?") +
|
||
", episode " + (apiData.shot.episode || "-") + ", scene " + (apiData.shot.scene || "-") +
|
||
", tc " + (apiData.shot.timecodeStart || "-"));
|
||
|
||
if (existingShotComp) {
|
||
diagLog("comp " + shotCode + " already exists — refreshing its overlay only");
|
||
if (!updateOverlayForComp(existingShotComp, shotCode, apiData)) {
|
||
diagLog("overlay refresh failed (non-fatal): UNG_VFX_OVERLAY comp or its Essential Properties are missing");
|
||
}
|
||
return existingShotComp;
|
||
}
|
||
|
||
if (!trimText(CONFIG.footageRoot)) {
|
||
diagFail("configuration", "Footage Root is empty in Settings",
|
||
["Open Settings (the gear button) and set Footage Root — the bare root, without the show folder, e.g. V:/_FOOTAGE.",
|
||
"Settings are per-machine — a workstation that has never been configured starts blank."]);
|
||
return null;
|
||
}
|
||
|
||
groupFolder = getFootageGroupFolder(groupCode);
|
||
diagLog("footage root: " + CONFIG.footageRoot + " -> show root: " + getShowFootageRoot());
|
||
diagLog("plate search folder: " + groupFolder.fsName);
|
||
|
||
if (!groupFolder.exists) {
|
||
diagFail("footage folder", groupFolder.fsName + " does not exist on this machine",
|
||
[describeMissingPath(groupFolder.fsName),
|
||
"Check the drive letter/UNC in Settings matches this machine's mapping — the path is stored per-machine.",
|
||
"Check the show folder is named " + (getShowId() || "(showId missing)") + ", which comes from the project's Show ID."]);
|
||
return null;
|
||
}
|
||
|
||
sequenceFolders = findSequenceFolders(shotCode, groupCode);
|
||
diagLog(sequenceFolders.length + " plate folder(s) matched for " + shotCode);
|
||
if (sequenceFolders.length < 1) {
|
||
folderNames = listFolderNames(groupFolder, 15);
|
||
diagFail("plate folders", "no plate folder for " + shotCode + " under " + groupFolder.fsName,
|
||
[folderNames.length
|
||
? "folders present there: " + folderNames.join(", ")
|
||
: "that folder holds no sub-folders at all (" + describeFolderContents(groupFolder) + ")",
|
||
"Expected either " + shotCode + "/ with plate sub-folders, or folders named " + shotCode + "_*.",
|
||
"A " + shotCode + "/ folder with no plate sub-folders inside also lands here — it is treated as 'no plates yet'."]);
|
||
return null;
|
||
}
|
||
|
||
for (i = 0; i < sequenceFolders.length; i += 1) {
|
||
footage = null;
|
||
importFailure = "";
|
||
try {
|
||
footage = importEXRSequence(sequenceFolders[i]);
|
||
} catch (importError) {
|
||
importFailure = describeError(importError);
|
||
diagLog("import threw for " + sequenceFolders[i].fsName + ": " + importFailure);
|
||
}
|
||
|
||
if (footage) {
|
||
diagLog("imported " + sequenceFolders[i].name + " (" + footage.width + "x" + footage.height +
|
||
", " + Math.round(footage.duration * 24) + " frames)");
|
||
footage.name = sequenceFolders[i].name;
|
||
if (footageFolder) {
|
||
footage.parentFolder = footageFolder;
|
||
}
|
||
footageItems.push(footage);
|
||
|
||
if (footage.duration > maxDuration) {
|
||
maxDuration = footage.duration;
|
||
width = footage.width;
|
||
height = footage.height;
|
||
primaryFootage = footage;
|
||
}
|
||
} else {
|
||
emptyFolderNotes.push(sequenceFolders[i].name + ": " +
|
||
(importFailure ? "import failed — " + importFailure : describeFolderContents(sequenceFolders[i])));
|
||
}
|
||
}
|
||
|
||
if (footageItems.length < 1) {
|
||
diagFail("plate import", sequenceFolders.length + " plate folder(s) matched but none held an importable EXR sequence",
|
||
emptyFolderNotes.concat(["Only *.exr files are imported — DPX/JPEG plates and .exr files inside a further sub-folder are ignored."]));
|
||
return null;
|
||
}
|
||
if (!primaryFootage) {
|
||
primaryFootage = footageItems[0];
|
||
}
|
||
|
||
try {
|
||
// Both comps start at the plate's native start timecode (as AE would
|
||
// when creating a comp from the footage), not at frame 0.
|
||
startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24);
|
||
buildState.lastStartSource = startInfo.source;
|
||
buildState.lastStartFrames = startInfo.frames;
|
||
buildState.lastStartShotCode = shotCode;
|
||
diagLog("start frame " + startInfo.frames + " from " + startInfo.source);
|
||
|
||
footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24);
|
||
setCompStartFrame(footageComp, startInfo.frames);
|
||
if (precompFolder) {
|
||
footageComp.parentFolder = precompFolder;
|
||
}
|
||
|
||
for (i = footageItems.length - 1; i >= 0; i -= 1) {
|
||
footageLayer = footageComp.layers.add(footageItems[i]);
|
||
addOCIOToLayer(footageLayer);
|
||
}
|
||
|
||
shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24);
|
||
setCompStartFrame(shotComp, startInfo.frames);
|
||
if (episodeProjectFolder) {
|
||
shotComp.parentFolder = episodeProjectFolder;
|
||
} else {
|
||
diagLog("no " + groupCode + " folder under __SHOTS — comp left at the project root");
|
||
}
|
||
|
||
shotComp.layers.add(footageComp);
|
||
} catch (compError) {
|
||
diagFail("comp creation", describeError(compError),
|
||
["The plates imported, so this is an After Effects error rather than a path or server problem.",
|
||
"Run Initialise Workspace if the project's folder structure is missing."]);
|
||
return null;
|
||
}
|
||
|
||
showLut = findComp("_SHOW LUT");
|
||
if (showLut) {
|
||
lutLayer = shotComp.layers.add(showLut);
|
||
lutLayer.collapseTransformation = true;
|
||
} else {
|
||
diagLog("no _SHOW LUT comp in this project — shot built without it");
|
||
}
|
||
|
||
if (!updateOverlayForComp(shotComp, shotCode, apiData)) {
|
||
diagLog("overlay not applied (non-fatal): UNG_VFX_OVERLAY comp missing, or its DATE/SHOT NAME Essential Properties are not exposed");
|
||
}
|
||
diagLog("built comp " + shotComp.name + " (" + width + "x" + height + ", " +
|
||
Math.round(maxDuration * 24) + " frames, " + footageItems.length + " plate layer(s))");
|
||
return shotComp;
|
||
}
|
||
|
||
function buildShotFromDropdown() {
|
||
var groupCode;
|
||
var shotCode;
|
||
var shotComp;
|
||
var isEpisodic = isEpisodicProject();
|
||
var failure;
|
||
|
||
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) {
|
||
updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first.");
|
||
return;
|
||
}
|
||
|
||
// For episodic, derive full episode code; for standard pass the selected scene value
|
||
if (isEpisodic) {
|
||
groupCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text);
|
||
} else {
|
||
groupCode = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
|
||
}
|
||
shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text;
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Build Shot");
|
||
updateStatus(getSelectedComps().length, "Building shot " + shotCode);
|
||
shotComp = buildShotFromCode(groupCode, shotCode);
|
||
app.endUndoGroup();
|
||
|
||
if (shotComp) {
|
||
updateStatus(getSelectedComps().length, "Shot built: " + shotCode +
|
||
" (start frame " + buildState.lastStartFrames +
|
||
(buildState.lastStartSource ? " from " + buildState.lastStartSource : "") + ")");
|
||
return;
|
||
}
|
||
|
||
// Report the stage it died at rather than a bare "failed", and hand the
|
||
// artist the whole trace so it can be sent on without a screen-share.
|
||
failure = DIAG.lastFailure || { stage: "unknown", detail: "no reason was recorded", hints: [] };
|
||
updateStatus(getSelectedComps().length, "Build failed at " + failure.stage + ": " + failure.detail);
|
||
showDiagDialog("Could not build " + shotCode + " — failed at: " + failure.stage +
|
||
"\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() {
|
||
var problems = 0;
|
||
var curlVersion;
|
||
var projects;
|
||
var groupCode = "";
|
||
var shotCode = "";
|
||
var groupFolder;
|
||
var sequenceFolders;
|
||
var exportRoot;
|
||
var names;
|
||
var missingItems = [];
|
||
var requiredComps = ["UNG_VFX_OVERLAY", "UNG_EXPORT_TEMPLATE", "_SHOW LUT"];
|
||
var requiredFolders = ["__SHOTS", "_FOOTAGE_4K", "_PRECOMPS", "_PREVIEWS"];
|
||
var i;
|
||
|
||
diagLog("config file: " + getConfigPath());
|
||
|
||
try {
|
||
curlVersion = trimText(String(system.callSystem("curl --version")).split(/[\r\n]/)[0]);
|
||
} catch (curlError) {
|
||
curlVersion = "";
|
||
}
|
||
if (/^curl /i.test(curlVersion)) {
|
||
diagLog("curl: " + curlVersion);
|
||
} else {
|
||
problems += 1;
|
||
diagLog("PROBLEM curl not usable: " + (curlVersion || "no output") +
|
||
" — every server call goes through curl.exe, so nothing will load until it is on PATH");
|
||
}
|
||
|
||
if (!trimText(CONFIG.baseUrl)) {
|
||
problems += 1;
|
||
diagLog("PROBLEM Server URL is empty in Settings");
|
||
}
|
||
if (!trimText(CONFIG.token)) {
|
||
problems += 1;
|
||
diagLog("PROBLEM API Token is empty in Settings");
|
||
}
|
||
|
||
projects = getAPIData(CONFIG.baseUrl + "/api/ext/projects");
|
||
if (projects) {
|
||
diagLog("API OK: " + getListFromResponse(projects, "projects").length + " project(s) visible");
|
||
} else {
|
||
problems += 1;
|
||
diagLog("PROBLEM API call failed: " +
|
||
(DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
|
||
names = hintsForResponse(DIAG.lastResponse || { status: 0, body: "" });
|
||
for (i = 0; i < names.length; i += 1) {
|
||
diagLog(" - " + names[i]);
|
||
}
|
||
}
|
||
|
||
if (!CURRENT_PROJECT) {
|
||
problems += 1;
|
||
diagLog("PROBLEM no project selected — the rest of the path checks need one");
|
||
} else {
|
||
if (uiState.episodeDropdown && uiState.episodeDropdown.selection) {
|
||
groupCode = isEpisodicProject()
|
||
? (uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text))
|
||
: (uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text);
|
||
}
|
||
if (uiState.shotDropdown && uiState.shotDropdown.selection) {
|
||
shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text;
|
||
}
|
||
|
||
if (!trimText(CONFIG.footageRoot)) {
|
||
problems += 1;
|
||
diagLog("PROBLEM Footage Root is empty in Settings");
|
||
} else {
|
||
groupFolder = getFootageGroupFolder(groupCode);
|
||
if (groupFolder.exists) {
|
||
diagLog("footage folder OK: " + groupFolder.fsName + " (" + describeFolderContents(groupFolder) + ")");
|
||
if (shotCode) {
|
||
sequenceFolders = findSequenceFolders(shotCode, groupCode);
|
||
if (sequenceFolders.length) {
|
||
for (i = 0; i < sequenceFolders.length; i += 1) {
|
||
diagLog(" plate folder " + sequenceFolders[i].name + ": " +
|
||
describeFolderContents(sequenceFolders[i]));
|
||
}
|
||
} else {
|
||
problems += 1;
|
||
names = listFolderNames(groupFolder, 15);
|
||
diagLog("PROBLEM no plate folder for " + shotCode +
|
||
(names.length ? " — folders present: " + names.join(", ") : " — folder is empty"));
|
||
}
|
||
}
|
||
} else {
|
||
problems += 1;
|
||
diagLog("PROBLEM footage folder missing: " + groupFolder.fsName);
|
||
diagLog(" - " + describeMissingPath(groupFolder.fsName));
|
||
}
|
||
}
|
||
|
||
exportRoot = getShowExportRoot();
|
||
if (!trimText(CONFIG.exportRoot)) {
|
||
problems += 1;
|
||
diagLog("PROBLEM Export Root is empty in Settings — renders and deliveries have nowhere to go");
|
||
} else if (new Folder(exportRoot).exists) {
|
||
diagLog("export root OK: " + exportRoot);
|
||
} else {
|
||
problems += 1;
|
||
diagLog("PROBLEM export root missing: " + exportRoot);
|
||
diagLog(" - " + describeMissingPath(exportRoot));
|
||
}
|
||
|
||
if (shotCode) {
|
||
if (getShotData(shotCode)) {
|
||
diagLog("shot lookup OK for " + shotCode);
|
||
} else {
|
||
problems += 1;
|
||
diagLog("PROBLEM shot lookup failed for " + shotCode + ": " +
|
||
(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) {
|
||
if (!findComp(requiredComps[i])) {
|
||
missingItems.push("comp " + requiredComps[i]);
|
||
}
|
||
}
|
||
for (i = 0; i < requiredFolders.length; i += 1) {
|
||
if (!findFolder(requiredFolders[i])) {
|
||
missingItems.push("folder " + requiredFolders[i]);
|
||
}
|
||
}
|
||
if (missingItems.length) {
|
||
diagLog("missing from the open AE project: " + missingItems.join(", ") +
|
||
" — run Initialise Workspace, or open the show template");
|
||
} else {
|
||
diagLog("AE project template items all present");
|
||
}
|
||
|
||
diagLog(problems ? problems + " problem(s) found" : "no problems found");
|
||
updateStatus(getSelectedComps().length, problems
|
||
? "Diagnostics: " + problems + " problem(s) — see the report"
|
||
: "Diagnostics: no problems found");
|
||
showDiagDialog(problems
|
||
? problems + " problem(s) found. Send this report to the pipeline TD."
|
||
: "No problems found — server, paths and template items all check out.", diagReport());
|
||
}
|
||
|
||
function buildPreviews() {
|
||
var comps = getTargetComps();
|
||
var templateComp = findComp("UNG_EXPORT_TEMPLATE");
|
||
var created = 0;
|
||
var skipped = 0;
|
||
var i;
|
||
var shotComp;
|
||
var shotCode;
|
||
var apiData;
|
||
var previewComp;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select/open a shot comp.");
|
||
return;
|
||
}
|
||
|
||
if (!templateComp) {
|
||
updateStatus(comps.length, "UNG_EXPORT_TEMPLATE not found.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Build Preview");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = getShotCodeFromComp(comps[i]);
|
||
shotComp = getMainCompForSelection(comps[i]);
|
||
updateStatus(comps.length, "Building preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
|
||
if (!shotComp) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
apiData = getShotData(shotCode);
|
||
if (!apiData || !apiData.shot) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
previewComp = buildPreviewForShot(shotComp, shotCode, apiData);
|
||
if (previewComp) {
|
||
created += 1;
|
||
} else {
|
||
skipped += 1;
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, created + " preview(s) built. " + skipped + " skipped.");
|
||
}
|
||
|
||
function queueReview(templateName, extension, label) {
|
||
var comps = getTargetComps();
|
||
var queued = 0;
|
||
var i;
|
||
var result = makeBatchResult(comps.length);
|
||
var shotCode;
|
||
var apiData;
|
||
var mainComp;
|
||
var previewComp;
|
||
var outputPath;
|
||
var folderPath;
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Queue " + label);
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = getShotCodeFromComp(comps[i]);
|
||
updateStatus(comps.length, "Preparing " + label + " preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
apiData = getShotData(shotCode);
|
||
if (!apiData || !apiData.shot) {
|
||
addFailure(result, comps[i].name, "Shot lookup failed");
|
||
continue;
|
||
}
|
||
|
||
mainComp = getMainCompForSelection(comps[i]);
|
||
if (!mainComp) {
|
||
addFailure(result, comps[i].name, "Main shot comp not found");
|
||
continue;
|
||
}
|
||
|
||
updateOverlayForComp(mainComp, shotCode, apiData);
|
||
|
||
if (isPreviewComp(comps[i])) {
|
||
previewComp = comps[i];
|
||
} else {
|
||
previewComp = getPreviewCompForShot(shotCode);
|
||
if (!previewComp) {
|
||
previewComp = buildPreviewForShot(mainComp, shotCode, apiData);
|
||
}
|
||
}
|
||
|
||
if (!previewComp) {
|
||
addFailure(result, comps[i].name, "Preview comp not found or could not be created");
|
||
continue;
|
||
}
|
||
|
||
updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
folderPath = getShotExportDir(apiData.shot);
|
||
if (!ensureFolder(folderPath)) {
|
||
addFailure(result, comps[i].name, "Could not create output folder");
|
||
continue;
|
||
}
|
||
|
||
outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension;
|
||
if (queueCompWithOutput(previewComp, templateName, outputPath)) {
|
||
queued += 1;
|
||
result.queued += 1;
|
||
} else {
|
||
addFailure(result, comps[i].name, "Could not queue " + label + " render");
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, queued + " " + label + " renders queued. " + result.skipped + " skipped.");
|
||
}
|
||
|
||
function queueEXRPreview() {
|
||
var comps = getSelectedComps();
|
||
var queued = 0;
|
||
var i;
|
||
var comp;
|
||
var lookupCode;
|
||
var shot;
|
||
var result = makeBatchResult(comps.length);
|
||
var shotCode;
|
||
var folderPath;
|
||
var outputPath;
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Queue EXR Preview");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = comps[i];
|
||
lookupCode = getShotCodeFromComp(comp);
|
||
updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
shot = normalizeShotData(getShotData(lookupCode));
|
||
|
||
if (!shot || !shot.shotCode) {
|
||
addFailure(result, comp.name, "API lookup failed");
|
||
continue;
|
||
}
|
||
|
||
shotCode = shot.shotCode;
|
||
disableLayerByName(comp, "UNG_VFX_OVERLAY");
|
||
disableLayerByName(comp, "_SHOW LUT");
|
||
|
||
folderPath = getShotExportDir(shot);
|
||
if (!ensureFolder(folderPath)) {
|
||
addFailure(result, comp.name, "Could not create output folder");
|
||
continue;
|
||
}
|
||
|
||
outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (shot.shotVersion || "v001") + ".[#####].exr";
|
||
if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
|
||
queued += 1;
|
||
result.queued += 1;
|
||
} else {
|
||
addFailure(result, comp.name, "Could not queue EXR render");
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
|
||
}
|
||
|
||
function queueMP4() {
|
||
queueReview("REVIEW_PREVIEW", "mp4", "MP4");
|
||
}
|
||
|
||
function queueMOV() {
|
||
queueReview("4444 Tri", "mov", "MOV");
|
||
}
|
||
|
||
function importExportedEXR() {
|
||
var comps = getTargetComps();
|
||
var exportsProjectFolder;
|
||
var comp;
|
||
var shotCode;
|
||
var shot;
|
||
var exportFolder;
|
||
var files;
|
||
var importOptions;
|
||
var footage;
|
||
var layer;
|
||
var i;
|
||
var imported = 0;
|
||
var skipped = 0;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Import Exported EXR");
|
||
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = comps[i];
|
||
shotCode = getShotCodeFromComp(comp);
|
||
updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
|
||
shot = normalizeShotData(getShotData(shotCode));
|
||
if (!shot || !shot.shotCode || !shot.exrOutput) {
|
||
updateStatus(comps.length, "API lookup failed or missing EXR output for " + shotCode);
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
exportFolder = new Folder(getShotExportDir(shot));
|
||
if (!exportFolder.exists) {
|
||
updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName);
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
// Prefer the current review-render version naming convention:
|
||
// shotCode_cmp_TT_vXXX.####.exr
|
||
var currentVersion = shot.shotVersion || "v001";
|
||
var reviewPrefix = shot.shotCode + "_cmp_TT_" + currentVersion + ".";
|
||
files = exportFolder.getFiles(function (f) {
|
||
return f instanceof File &&
|
||
/\.exr$/i.test(f.name) &&
|
||
f.name.indexOf(reviewPrefix) === 0;
|
||
});
|
||
|
||
// Fallback to the broader queue EXR naming pattern if needed
|
||
if (!files || files.length < 1) {
|
||
var cmpPrefix = shot.shotCode + "_cmp_TT_";
|
||
files = exportFolder.getFiles(function (f) {
|
||
return f instanceof File &&
|
||
/\.exr$/i.test(f.name) &&
|
||
f.name.indexOf(cmpPrefix) === 0;
|
||
});
|
||
}
|
||
|
||
if (!files || files.length < 1) {
|
||
// Last resort: any EXR in the folder
|
||
files = exportFolder.getFiles("*.exr");
|
||
}
|
||
|
||
if (!files || files.length < 1) {
|
||
updateStatus(comps.length, "No EXR files found in " + exportFolder.fsName);
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
files.sort();
|
||
try {
|
||
importOptions = new ImportOptions(files[0]);
|
||
importOptions.sequence = true;
|
||
footage = app.project.importFile(importOptions);
|
||
footage.mainSource.conformFrameRate = 24;
|
||
} catch (importErr) {
|
||
updateStatus(comps.length, "Import failed for " + shot.shotCode + ": " + importErr.message);
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
exportsProjectFolder = findOrCreateFolder("_EXPORTS", null);
|
||
if (exportsProjectFolder) {
|
||
footage.parentFolder = exportsProjectFolder;
|
||
}
|
||
|
||
layer = comp.layers.add(footage);
|
||
layer.moveToBeginning();
|
||
imported += 1;
|
||
}
|
||
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, imported + " EXR export(s) imported. " + skipped + " skipped.");
|
||
}
|
||
|
||
function importRenders() {
|
||
var comps = getTargetComps();
|
||
var comp;
|
||
var shotCode;
|
||
var rendersRoot;
|
||
var rendersProjectFolder;
|
||
var allFiles;
|
||
var subFolders;
|
||
var importedFootage = [];
|
||
var addedLayers = [];
|
||
var layerIndices = [];
|
||
var footage;
|
||
var layer;
|
||
var i;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
comp = comps[0];
|
||
shotCode = getShotCodeFromComp(comp);
|
||
rendersRoot = new Folder("X:/shared_projects_2026/UNGO_VFX/production/renders/" + shotCode);
|
||
|
||
if (!rendersRoot.exists) {
|
||
updateStatus(getSelectedComps().length, "Renders folder not found: " + rendersRoot.fsName);
|
||
return;
|
||
}
|
||
|
||
rendersProjectFolder = findOrCreateFolder("_RENDERS", null);
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Import Renders");
|
||
|
||
// Try subfolders first (each subfolder may be a separate pass/layer)
|
||
subFolders = rendersRoot.getFiles();
|
||
for (i = 0; i < subFolders.length; i += 1) {
|
||
if (subFolders[i] instanceof Folder) {
|
||
footage = importEXRSequence(subFolders[i]);
|
||
if (footage) {
|
||
footage.name = subFolders[i].name;
|
||
footage.mainSource.conformFrameRate = 24;
|
||
if (rendersProjectFolder) {
|
||
footage.parentFolder = rendersProjectFolder;
|
||
}
|
||
importedFootage.push(footage);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fall back to sequences directly in the root renders folder
|
||
if (importedFootage.length < 1) {
|
||
footage = importEXRSequence(rendersRoot);
|
||
if (footage) {
|
||
footage.name = shotCode + "_render";
|
||
footage.mainSource.conformFrameRate = 24;
|
||
if (rendersProjectFolder) {
|
||
footage.parentFolder = rendersProjectFolder;
|
||
}
|
||
importedFootage.push(footage);
|
||
}
|
||
}
|
||
|
||
if (importedFootage.length < 1) {
|
||
app.endUndoGroup();
|
||
updateStatus(getSelectedComps().length, "No EXR sequences found in renders folder.");
|
||
return;
|
||
}
|
||
|
||
// Add footage layers to comp and apply Extractor effect
|
||
for (i = 0; i < importedFootage.length; i += 1) {
|
||
layer = comp.layers.add(importedFootage[i]);
|
||
try {
|
||
var extractor = layer.property("Effects").addProperty("Extractor");
|
||
try {
|
||
extractor.property("Layers").setValue(1);
|
||
} catch (layersPropError) {
|
||
// Property name may differ — try by index (property 1 is typically "Layers")
|
||
try {
|
||
extractor.property(1).setValue(1);
|
||
} catch (layersIdxError) {
|
||
}
|
||
}
|
||
} catch (fxError) {
|
||
}
|
||
addedLayers.push(layer);
|
||
}
|
||
|
||
// Collect current layer indices for pre-compose
|
||
for (i = 0; i < addedLayers.length; i += 1) {
|
||
layerIndices.push(addedLayers[i].index);
|
||
}
|
||
|
||
// Pre-compose all render layers into {shotCode}_RENDER
|
||
try {
|
||
comp.layers.precompose(layerIndices, shotCode + "_RENDER", true);
|
||
updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported into " + shotCode + "_RENDER.");
|
||
} catch (precompError) {
|
||
updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported. Pre-compose failed: " + precompError.message);
|
||
}
|
||
|
||
app.endUndoGroup();
|
||
}
|
||
|
||
function prepDelivery() {
|
||
var comps = getTargetComps();
|
||
var i;
|
||
var j;
|
||
var comp;
|
||
var shotCode;
|
||
var shot;
|
||
var exrOutputBase;
|
||
var sourceFolder;
|
||
var deliveryFolderPath;
|
||
var exrFiles;
|
||
var expectedBaseName;
|
||
var result = makeBatchResult(comps.length);
|
||
var srcFile;
|
||
var srcBaseName;
|
||
var frameMatch;
|
||
var frameNum;
|
||
var destFileName;
|
||
var destPath;
|
||
var psLines;
|
||
var scriptFile;
|
||
var vbsFile;
|
||
var launched = 0;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = comps[i];
|
||
shotCode = getShotCodeFromComp(comp);
|
||
updateStatus(comps.length, "Preparing delivery for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
|
||
shot = normalizeShotData(getShotData(shotCode));
|
||
if (!shot || !shot.shotCode) {
|
||
addFailure(result, comp.name, "API lookup failed");
|
||
continue;
|
||
}
|
||
|
||
shotCode = shot.shotCode;
|
||
expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001");
|
||
exrOutputBase = shot.exrOutput || "";
|
||
|
||
sourceFolder = new Folder(getShotExportDir(shot));
|
||
if (!sourceFolder.exists) {
|
||
addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName);
|
||
continue;
|
||
}
|
||
|
||
// Prefer the current review-render version naming convention:
|
||
// shotCode_cmp_TT_vXXX.####.exr
|
||
exrFiles = null;
|
||
var currentVersion = shot.shotVersion || "v001";
|
||
var reviewPrefix = shotCode + "_cmp_TT_" + currentVersion + ".";
|
||
exrFiles = sourceFolder.getFiles(function (f) {
|
||
return f instanceof File &&
|
||
/\.exr$/i.test(f.name) &&
|
||
f.name.indexOf(reviewPrefix) === 0;
|
||
});
|
||
|
||
// Fallback to the broader queue EXR naming pattern if needed
|
||
if (!exrFiles || exrFiles.length < 1) {
|
||
var cmpPfx = shotCode + "_cmp_TT_";
|
||
exrFiles = sourceFolder.getFiles(function (f) {
|
||
return f instanceof File &&
|
||
/\.exr$/i.test(f.name) &&
|
||
f.name.indexOf(cmpPfx) === 0;
|
||
});
|
||
}
|
||
|
||
if (!exrFiles || exrFiles.length < 1) {
|
||
// Last resort: any EXR in the folder
|
||
exrFiles = sourceFolder.getFiles("*.exr");
|
||
}
|
||
|
||
if (!exrFiles || exrFiles.length < 1) {
|
||
addFailure(result, comp.name, "No EXR files found in " + sourceFolder.fsName);
|
||
continue;
|
||
}
|
||
|
||
exrFiles.sort();
|
||
|
||
var deliveryDate = getDateString().replace(/\//g, "");
|
||
deliveryFolderPath = getShowExportRoot() + "/DELIVERY" + getDeliveryGroupSegment(shotCode) +
|
||
"/" + deliveryDate + "_Delivery/" + shotCode;
|
||
if (!ensureFolder(deliveryFolderPath)) {
|
||
addFailure(result, comp.name, "Could not create delivery folder");
|
||
continue;
|
||
}
|
||
|
||
// Build a list of PowerShell Copy-Item commands
|
||
psLines = [];
|
||
for (j = 0; j < exrFiles.length; j += 1) {
|
||
srcFile = exrFiles[j];
|
||
srcBaseName = srcFile.name.replace(/\.exr$/i, "");
|
||
|
||
if (srcBaseName.indexOf(expectedBaseName + ".") === 0) {
|
||
destFileName = srcFile.name;
|
||
} else {
|
||
// Extract frame number and rename to delivery convention
|
||
frameMatch = srcFile.name.match(/\.(\d+)\.exr$/i);
|
||
if (!frameMatch) {
|
||
continue;
|
||
}
|
||
frameNum = frameMatch[1];
|
||
destFileName = expectedBaseName + "." + frameNum + ".exr";
|
||
}
|
||
|
||
destPath = (new File(deliveryFolderPath + "/" + destFileName)).fsName;
|
||
// Single-quoted PS strings are fully literal — safe for Windows paths
|
||
psLines.push("Copy-Item -LiteralPath '" + srcFile.fsName + "' -Destination '" + destPath + "'");
|
||
}
|
||
|
||
if (psLines.length < 1) {
|
||
addFailure(result, comp.name, "No valid EXR files to copy");
|
||
continue;
|
||
}
|
||
|
||
// Write PS1 script to temp folder
|
||
scriptFile = new File(Folder.temp.fsName + "/vfxdelivery_" + shotCode + ".ps1");
|
||
try {
|
||
scriptFile.open("w");
|
||
scriptFile.write(psLines.join("\r\n"));
|
||
// Self-delete once all transfers finish
|
||
scriptFile.write("\r\nRemove-Item -LiteralPath '" + scriptFile.fsName + "' -Force -ErrorAction SilentlyContinue");
|
||
scriptFile.close();
|
||
} catch (writeError) {
|
||
addFailure(result, comp.name, "Could not write delivery script: " + writeError.message);
|
||
continue;
|
||
}
|
||
|
||
// VBScript launcher: WScript.Shell.Run with bWaitOnReturn=False creates a fully
|
||
// independent process with no inherited pipe handles, so system.callSystem returns
|
||
// immediately and AE is never blocked regardless of how long the copy takes.
|
||
vbsFile = new File(Folder.temp.fsName + "/vfxdelivery_launch_" + shotCode + ".vbs");
|
||
try {
|
||
vbsFile.open("w");
|
||
vbsFile.write("Set sh = CreateObject(\"WScript.Shell\")\r\n");
|
||
vbsFile.write("sh.Run \"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File \" & Chr(34) & \"" + scriptFile.fsName + "\" & Chr(34), 0, False\r\n");
|
||
vbsFile.write("Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n");
|
||
vbsFile.write("fso.DeleteFile WScript.ScriptFullName\r\n");
|
||
vbsFile.close();
|
||
} catch (vbsError) {
|
||
addFailure(result, comp.name, "Could not write launcher script: " + vbsError.message);
|
||
continue;
|
||
}
|
||
|
||
system.callSystem("wscript.exe /nologo \"" + vbsFile.fsName + "\"");
|
||
launched += 1;
|
||
}
|
||
|
||
var statusMsg;
|
||
if (launched > 0) {
|
||
statusMsg = "Delivery transfer running in background for " + launched + " shot(s).";
|
||
} else {
|
||
statusMsg = "No shots queued for delivery.";
|
||
}
|
||
if (result.skipped > 0) {
|
||
statusMsg += " " + result.skipped + " skipped.";
|
||
}
|
||
updateStatus(comps.length, statusMsg);
|
||
}
|
||
|
||
function pullPictureLock() {
|
||
var comps = getTargetComps();
|
||
var comp;
|
||
var shotCode;
|
||
var episodePrefix;
|
||
var piclocksProjectFolder;
|
||
var piclockFootage;
|
||
var piclocksDir;
|
||
var files;
|
||
var foundFile;
|
||
var importOptions;
|
||
var apiData;
|
||
var shot;
|
||
var fps;
|
||
var handles;
|
||
var piclockStartTC;
|
||
var tcIn;
|
||
var tcOut;
|
||
var inOffset;
|
||
var outOffset;
|
||
var layer;
|
||
var i;
|
||
var item;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
comp = comps[0];
|
||
shotCode = getShotCodeFromComp(comp);
|
||
episodePrefix = getEpisodePrefix(shotCode); // e.g. "UNG_106"
|
||
|
||
// Fetch API data for seq timecodes
|
||
updateStatus(getSelectedComps().length, "Looking up " + shotCode + "...");
|
||
apiData = getShotData(shotCode);
|
||
if (!apiData || !apiData.shot) {
|
||
updateStatus(getSelectedComps().length, "API lookup failed for " + shotCode);
|
||
return;
|
||
}
|
||
|
||
shot = apiData.shot;
|
||
if (!shot.seqTimecodeStart || !shot.seqTimecodeEnd) {
|
||
updateStatus(getSelectedComps().length, "No sequence timecodes for " + shotCode + ". Import picture tracker CSV first.");
|
||
return;
|
||
}
|
||
|
||
// Find or create _PICLOCKS project folder
|
||
piclocksProjectFolder = findOrCreateFolder("_PICLOCKS", null);
|
||
piclockFootage = null;
|
||
|
||
// Search only inside the _PICLOCKS project folder for matching footage
|
||
if (piclocksProjectFolder) {
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof FootageItem &&
|
||
item.parentFolder === piclocksProjectFolder &&
|
||
item.name.indexOf(episodePrefix + "_") === 0) {
|
||
piclockFootage = item;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Not in project — import from disk
|
||
if (!piclockFootage) {
|
||
piclocksDir = new Folder(getShowFootageRoot() + "/_PICLOCKS");
|
||
if (!piclocksDir.exists) {
|
||
updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName);
|
||
return;
|
||
}
|
||
|
||
files = piclocksDir.getFiles(episodePrefix + "_*");
|
||
foundFile = null;
|
||
if (files && files.length > 0) {
|
||
for (i = 0; i < files.length; i += 1) {
|
||
if (files[i] instanceof File) {
|
||
foundFile = files[i];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!foundFile) {
|
||
updateStatus(getSelectedComps().length, "No piclock file matching " + episodePrefix + "_* found in " + piclocksDir.fsName);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
importOptions = new ImportOptions(foundFile);
|
||
piclockFootage = app.project.importFile(importOptions);
|
||
if (piclocksProjectFolder) {
|
||
piclockFootage.parentFolder = piclocksProjectFolder;
|
||
}
|
||
} catch (importError) {
|
||
updateStatus(getSelectedComps().length, "Import failed: " + importError.message);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Calculate layer in/out offsets from seqTimecodes + handles
|
||
fps = comp.frameRate || 24;
|
||
handles = 8;
|
||
|
||
piclockStartTC = 3590;
|
||
try {
|
||
if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") {
|
||
piclockStartTC = piclockFootage.mainSource.startTimecode;
|
||
}
|
||
} catch (tcReadError) {
|
||
}
|
||
|
||
tcIn = parseTimecodeToSeconds(shot.seqTimecodeStart, fps);
|
||
tcOut = parseTimecodeToSeconds(shot.seqTimecodeEnd, fps);
|
||
inOffset = tcIn - piclockStartTC - (handles / fps);
|
||
outOffset = tcOut - piclockStartTC + (handles / fps);
|
||
|
||
if (inOffset < 0) {
|
||
inOffset = 0;
|
||
}
|
||
if (outOffset > piclockFootage.duration) {
|
||
outOffset = piclockFootage.duration;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Pull Picture Lock");
|
||
|
||
layer = comp.layers.add(piclockFootage);
|
||
var scale =
|
||
Math.max(
|
||
comp.width / piclockFootage.width,
|
||
comp.height / piclockFootage.height
|
||
) * 100;
|
||
|
||
layer.property("Scale")
|
||
.setValue([
|
||
scale,
|
||
scale
|
||
]);
|
||
layer.moveToBeginning();
|
||
|
||
// Offset startTime so the correct footage frame plays at comp time 0
|
||
layer.startTime = -inOffset;
|
||
layer.inPoint = 0;
|
||
layer.outPoint = outOffset - inOffset;
|
||
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(
|
||
getSelectedComps().length,
|
||
"Piclock added: " + shot.seqTimecodeStart + " \u2192 " + shot.seqTimecodeEnd + " (+" + handles + "fr handles)."
|
||
);
|
||
}
|
||
|
||
function applyOCIOColorSpace() {
|
||
var comps = getTargetComps();
|
||
var applied = 0;
|
||
var skipped = 0;
|
||
var i;
|
||
var j;
|
||
var comp;
|
||
var layer;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Apply OCIO");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = comps[i];
|
||
for (j = 1; j <= comp.numLayers; j += 1) {
|
||
try {
|
||
layer = comp.layer(j);
|
||
// Only apply to footage layers (not comps, solids, etc.)
|
||
if (layer.source && layer.source instanceof FootageItem && layer.source.mainSource instanceof FileSource) {
|
||
addOCIOToLayer(layer);
|
||
applied += 1;
|
||
}
|
||
} catch (layerError) {
|
||
skipped += 1;
|
||
}
|
||
}
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, "OCIO applied to " + applied + " layer(s). " + skipped + " skipped.");
|
||
}
|
||
|
||
function findOrCreateFolder(name, parentFolder) {
|
||
var i;
|
||
var item;
|
||
|
||
if (!app.project) {
|
||
return null;
|
||
}
|
||
|
||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||
item = app.project.item(i);
|
||
if (item instanceof FolderItem && item.name === name) {
|
||
if (!parentFolder || item.parentFolder === parentFolder) {
|
||
return item;
|
||
}
|
||
}
|
||
}
|
||
|
||
var newFolder = app.project.items.addFolder(name);
|
||
if (parentFolder) {
|
||
newFolder.parentFolder = parentFolder;
|
||
}
|
||
return newFolder;
|
||
}
|
||
|
||
function initialiseWorkspace() {
|
||
var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"];
|
||
var templatePath = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep";
|
||
var shotsFolder;
|
||
var epNum;
|
||
var epCode;
|
||
var templateFile;
|
||
var importOptions;
|
||
var importStatus;
|
||
var i;
|
||
|
||
if (!app.project) {
|
||
updateStatus(0, "No project open.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Initialise Workspace");
|
||
|
||
// Create __SHOTS with UNG_101 – UNG_117 subfolders
|
||
shotsFolder = findOrCreateFolder("__SHOTS", null);
|
||
for (epNum = 101; epNum <= 117; epNum += 1) {
|
||
epCode = "UNG_" + epNum;
|
||
findOrCreateFolder(epCode, shotsFolder);
|
||
}
|
||
|
||
// Create remaining top-level folders
|
||
for (i = 0; i < topFolderNames.length; i += 1) {
|
||
findOrCreateFolder(topFolderNames[i], null);
|
||
}
|
||
|
||
// Import AEP template
|
||
importStatus = "";
|
||
templateFile = new File(templatePath);
|
||
if (templateFile.exists) {
|
||
try {
|
||
importOptions = new ImportOptions(templateFile);
|
||
importOptions.importAs = ImportAsType.PROJECT;
|
||
app.project.importFile(importOptions);
|
||
importStatus = " Template imported.";
|
||
} catch (importError) {
|
||
importStatus = " Template import failed: " + importError.message;
|
||
}
|
||
} else {
|
||
importStatus = " Template file not found.";
|
||
}
|
||
|
||
app.endUndoGroup();
|
||
|
||
// ── Color settings (outside undo group — project-level settings) ──────
|
||
var colorStatus = "";
|
||
|
||
// 32-bit depth
|
||
try {
|
||
app.project.bitsPerChannel = 32;
|
||
colorStatus += " 32-bit depth set.";
|
||
} catch (bpcError) {
|
||
colorStatus += " Could not set 32-bit depth.";
|
||
}
|
||
|
||
// OCIO color management — AE 2022+ scripting API
|
||
try {
|
||
app.project.colorSettings.enabled = true;
|
||
colorStatus += " Color management enabled.";
|
||
} catch (csError) {
|
||
colorStatus += " Color management API unavailable (set manually).";
|
||
}
|
||
|
||
try {
|
||
// workingSpace index for OCIO ACES 1.2 varies by installation;
|
||
// attempt to match by name first, fall back to direct assignment.
|
||
var cs = app.project.colorSettings;
|
||
var found = false;
|
||
var wi;
|
||
if (cs.getWorkingSpaceList) {
|
||
var spaces = cs.getWorkingSpaceList();
|
||
for (wi = 0; wi < spaces.length; wi += 1) {
|
||
if (String(spaces[wi]).indexOf("ACES") !== -1 && String(spaces[wi]).indexOf("1.2") !== -1) {
|
||
cs.workingSpace = spaces[wi];
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!found && cs.workingSpace !== undefined) {
|
||
cs.workingSpace = "ACES - ACES2065-1";
|
||
}
|
||
colorStatus += " OCIO ACES 1.2 applied.";
|
||
} catch (ocioError) {
|
||
colorStatus += " OCIO config must be set manually in Project Settings.";
|
||
}
|
||
|
||
updateStatus(getSelectedComps().length, "Workspace initialised." + importStatus + colorStatus);
|
||
}
|
||
|
||
function clearDropdown(dropdown) {
|
||
while (dropdown.items.length > 0) {
|
||
dropdown.remove(dropdown.items[0]);
|
||
}
|
||
}
|
||
|
||
function populateShotDropdown() {
|
||
var filterValue;
|
||
var shots;
|
||
var i;
|
||
var shotCode;
|
||
var item;
|
||
|
||
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown) {
|
||
return;
|
||
}
|
||
|
||
filterValue = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
|
||
shots = getShotsFromAPI(filterValue);
|
||
|
||
clearDropdown(uiState.shotDropdown);
|
||
for (i = 0; i < shots.length; i += 1) {
|
||
shotCode = getShotCodeValue(shots[i]);
|
||
if (shotCode) {
|
||
item = uiState.shotDropdown.add("item", shotCode);
|
||
item.shotCode = shotCode;
|
||
}
|
||
}
|
||
|
||
if (uiState.shotDropdown.items.length > 0) {
|
||
uiState.shotDropdown.selection = 0;
|
||
updateStatus(getSelectedComps().length, uiState.shotDropdown.items.length + " shot(s) found for " + uiState.episodeDropdown.selection.text);
|
||
} else {
|
||
updateStatus(getSelectedComps().length, "No shots found for " + uiState.episodeDropdown.selection.text);
|
||
}
|
||
}
|
||
|
||
function populateEpisodeOrSceneDropdown() {
|
||
var isEpisodic = isEpisodicProject();
|
||
var i;
|
||
var item;
|
||
|
||
if (!uiState.episodeDropdown || !uiState.shotDropdown) { return; }
|
||
|
||
clearDropdown(uiState.episodeDropdown);
|
||
clearDropdown(uiState.shotDropdown);
|
||
|
||
if (uiState.episodeLabel) {
|
||
uiState.episodeLabel.text = isEpisodic ? "Ep:" : "Scene:";
|
||
}
|
||
|
||
if (isEpisodic) {
|
||
var episodes = getEpisodesFromAPI();
|
||
for (i = 0; i < episodes.length; i += 1) {
|
||
var label = getEpisodeLabel(episodes[i]);
|
||
var episodeValue = getEpisodeValue(episodes[i]);
|
||
if (label && episodeValue) {
|
||
item = uiState.episodeDropdown.add("item", label);
|
||
item.filterValue = episodeValue;
|
||
item.episodeCode = deriveEpisodeCode(label);
|
||
if (!isEpisodeCode(item.episodeCode)) {
|
||
item.episodeCode = deriveEpisodeCode(episodeValue);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
var scenes = getScenesFromAPI();
|
||
for (i = 0; i < scenes.length; i += 1) {
|
||
item = uiState.episodeDropdown.add("item", scenes[i]);
|
||
item.filterValue = scenes[i];
|
||
item.episodeCode = null;
|
||
}
|
||
}
|
||
|
||
if (uiState.episodeDropdown.items.length > 0) {
|
||
uiState.episodeDropdown.selection = 0;
|
||
populateShotDropdown();
|
||
} else {
|
||
updateStatus(getSelectedComps().length, isEpisodic ? "No episodes found." : "No scenes found.");
|
||
}
|
||
}
|
||
|
||
function populateEpisodeDropdown() {
|
||
populateEpisodeOrSceneDropdown();
|
||
}
|
||
|
||
function populateProjectDropdown() {
|
||
var projects;
|
||
var i;
|
||
var p;
|
||
var item;
|
||
var selectIdx = 0;
|
||
|
||
if (!uiState.projectDropdown) { return; }
|
||
|
||
projects = getProjectsFromAPI();
|
||
clearDropdown(uiState.projectDropdown);
|
||
clearDropdown(uiState.episodeDropdown);
|
||
clearDropdown(uiState.shotDropdown);
|
||
|
||
for (i = 0; i < projects.length; i += 1) {
|
||
p = projects[i];
|
||
item = uiState.projectDropdown.add("item", p.code + " — " + p.name);
|
||
item.projectData = p;
|
||
if (p.code === CONFIG.lastProjectCode) { selectIdx = i; }
|
||
}
|
||
|
||
if (uiState.projectDropdown.items.length > 0) {
|
||
uiState.projectDropdown.selection = selectIdx;
|
||
CURRENT_PROJECT = uiState.projectDropdown.items[selectIdx].projectData;
|
||
populateEpisodeOrSceneDropdown();
|
||
} else {
|
||
updateStatus(0, "No projects found — check server URL and token in Settings.");
|
||
}
|
||
}
|
||
|
||
function openSettingsDialog() {
|
||
var dlg = new Window("dialog", "VFXReview Connector — Settings", undefined);
|
||
dlg.orientation = "column";
|
||
dlg.alignChildren = ["fill", "top"];
|
||
dlg.spacing = 6;
|
||
dlg.margins = 16;
|
||
|
||
function addField(parent, lbl, val) {
|
||
var grp = parent.add("group");
|
||
grp.orientation = "row";
|
||
grp.alignChildren = ["fill", "center"];
|
||
var label = grp.add("statictext", undefined, lbl);
|
||
label.preferredSize = [110, 20];
|
||
var field = grp.add("edittext", undefined, val || "");
|
||
field.preferredSize = [280, 22];
|
||
return field;
|
||
}
|
||
|
||
var fUrl = addField(dlg, "Server URL:", CONFIG.baseUrl);
|
||
var fToken = addField(dlg, "API Token:", CONFIG.token);
|
||
var fExport = addField(dlg, "Export Root:", CONFIG.exportRoot);
|
||
var fFootage= addField(dlg, "Footage Root:", CONFIG.footageRoot);
|
||
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";
|
||
btnRow.alignChildren = ["fill", "center"];
|
||
btnRow.spacing = 8;
|
||
// Using named "ok"/"cancel" — the only reliable way to capture dialog result in ScriptUI
|
||
btnRow.add("button", undefined, "Save", { name: "ok" });
|
||
btnRow.add("button", undefined, "Cancel", { name: "cancel" });
|
||
|
||
dlg.layout.layout(true);
|
||
dlg.center();
|
||
|
||
if (dlg.show() === 1) {
|
||
CONFIG.baseUrl = trimText(fUrl.text) || CONFIG.baseUrl;
|
||
CONFIG.token = trimText(fToken.text);
|
||
CONFIG.exportRoot = trimText(fExport.text);
|
||
CONFIG.footageRoot = trimText(fFootage.text);
|
||
CONFIG.bannerImagePath = trimText(fBanner.text);
|
||
CONFIG.artistEmail = trimText(fEmail.text);
|
||
CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8;
|
||
CONFIG.pathMappings = trimText(fMappings.text);
|
||
saveConfig();
|
||
populateProjectDropdown();
|
||
}
|
||
}
|
||
|
||
// ── Shot Version Increment ────────────────────────────────────────────────
|
||
|
||
function incrementVersionString(ver) {
|
||
var match = String(ver).match(/^v(\d+)$/);
|
||
var num;
|
||
var str;
|
||
|
||
if (!match) {
|
||
return "v002";
|
||
}
|
||
|
||
num = parseInt(match[1], 10) + 1;
|
||
str = String(num);
|
||
while (str.length < 3) {
|
||
str = "0" + str;
|
||
}
|
||
return "v" + str;
|
||
}
|
||
|
||
function patchShotVersion(shotId, newVersion) {
|
||
var url = CONFIG.baseUrl + "/api/ext/shots/" + shotId;
|
||
// Inner double-quotes escaped for Windows cmd.exe
|
||
var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}";
|
||
var command;
|
||
var response;
|
||
|
||
try {
|
||
command = "curl -s -X PATCH " +
|
||
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
|
||
"-H \"Content-Type: application/json\" " +
|
||
"-H \"Accept: application/json\" " +
|
||
"-d \"" + body + "\" " +
|
||
"\"" + url + "\"";
|
||
|
||
response = system.callSystem(command);
|
||
} catch (patchError) {
|
||
return null;
|
||
}
|
||
|
||
return parseJSON(response);
|
||
}
|
||
|
||
function incrementShotVersions() {
|
||
var comps = getTargetComps();
|
||
var i;
|
||
var shotCode;
|
||
var data;
|
||
var shot;
|
||
var newVersion;
|
||
var patchResult;
|
||
var versionName;
|
||
var previewComp;
|
||
var slateLayer;
|
||
var essentialProperties;
|
||
var updated = 0;
|
||
var skipped = 0;
|
||
var lastVersion = "";
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Increment Shot Version");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = getShotCodeFromComp(comps[i]);
|
||
updateStatus(comps.length, "Incrementing " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
|
||
data = getShotData(shotCode);
|
||
if (!data || !data.shot) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
shot = data.shot;
|
||
newVersion = incrementVersionString(shot.shotVersion || "v001");
|
||
|
||
patchResult = patchShotVersion(shot.id, newVersion);
|
||
if (!patchResult || !patchResult.success) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
data.shot.shotVersion = newVersion;
|
||
versionName = shotCode + "_cmp_TT_" + newVersion;
|
||
|
||
// 1. Update SHOT NAME on UNG_VFX_OVERLAY inside the selected comp
|
||
updateOverlayForComp(comps[i], shotCode, data);
|
||
|
||
// 2. Update {shotCode}_PREVIEW slate VersionName if the comp exists
|
||
previewComp = findComp(getPreviewCompName(shotCode));
|
||
if (previewComp) {
|
||
try {
|
||
slateLayer = previewComp.layer("NETFLIX_SLATE");
|
||
essentialProperties = slateLayer.property("Essential Properties");
|
||
try {
|
||
essentialProperties.property("VersionName").setValue(versionName);
|
||
} catch (byNameError) {
|
||
essentialProperties.property(3).setValue(versionName);
|
||
}
|
||
} catch (slateError) {
|
||
}
|
||
}
|
||
|
||
lastVersion = newVersion;
|
||
updated += 1;
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
if (updated > 0) {
|
||
updateStatus(comps.length, updated + " shot(s) → " + lastVersion + ". " + skipped + " skipped.");
|
||
} else {
|
||
updateStatus(comps.length, "No shots incremented. " + skipped + " failed.");
|
||
}
|
||
}
|
||
|
||
function setStartFrameFromTimecode() {
|
||
var comps = getTargetComps();
|
||
var i;
|
||
var shotCode;
|
||
var data;
|
||
var shot;
|
||
var fps;
|
||
var frameNumber;
|
||
var updated = 0;
|
||
var skipped = 0;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Please select or open a comp first.");
|
||
return;
|
||
}
|
||
|
||
app.beginUndoGroup("VFXReview Connector - Set Start Timecode");
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
shotCode = getShotCodeFromComp(comps[i]);
|
||
updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
|
||
data = getShotData(shotCode);
|
||
|
||
if (!data || !data.shot || !data.shot.timecodeStart) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
shot = data.shot;
|
||
fps = shot.fps || comps[i].frameRate || 24;
|
||
frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - CONFIG.handleFrames;
|
||
setCompStartFrame(comps[i], frameNumber);
|
||
updated += 1;
|
||
}
|
||
app.endUndoGroup();
|
||
|
||
updateStatus(comps.length, updated + " comp(s) start frame set. " + skipped + " skipped.");
|
||
}
|
||
|
||
// ── Render Pipeline (Queue Export → server render queue) ─────────────────
|
||
// Additive: the manual Queue EXR / MP4 / MOV / Prep Delivery buttons above
|
||
// stay as escape hatches. Queue Export submits a Render Manifest to
|
||
// POST /api/ext/exports; the SERVER increments the shot version and
|
||
// decides output paths, so no local render or Increment Version click is
|
||
// needed on this path.
|
||
|
||
function jsonEscapeString(value) {
|
||
var text = String(value);
|
||
var out = "";
|
||
var i;
|
||
var ch;
|
||
var code;
|
||
|
||
for (i = 0; i < text.length; i += 1) {
|
||
ch = text.charAt(i);
|
||
code = text.charCodeAt(i);
|
||
if (ch === "\"") {
|
||
out += "\\\"";
|
||
} else if (ch === "\\") {
|
||
out += "\\\\";
|
||
} else if (ch === "\n") {
|
||
out += "\\n";
|
||
} else if (ch === "\r") {
|
||
out += "\\r";
|
||
} else if (ch === "\t") {
|
||
out += "\\t";
|
||
} else if (code < 32) {
|
||
out += "\\u" + ("000" + code.toString(16)).slice(-4);
|
||
} else {
|
||
out += ch;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function jsonStringify(value) {
|
||
var parts;
|
||
var key;
|
||
var i;
|
||
|
||
if (value === null || value === undefined) {
|
||
return "null";
|
||
}
|
||
if (typeof value === "number" || typeof value === "boolean") {
|
||
return String(value);
|
||
}
|
||
if (typeof value === "string") {
|
||
return "\"" + jsonEscapeString(value) + "\"";
|
||
}
|
||
if (value instanceof Array) {
|
||
parts = [];
|
||
for (i = 0; i < value.length; i += 1) {
|
||
parts.push(jsonStringify(value[i]));
|
||
}
|
||
return "[" + parts.join(",") + "]";
|
||
}
|
||
parts = [];
|
||
for (key in value) {
|
||
if (value.hasOwnProperty(key) && value[key] !== undefined) {
|
||
parts.push("\"" + jsonEscapeString(key) + "\":" + jsonStringify(value[key]));
|
||
}
|
||
}
|
||
return "{" + parts.join(",") + "}";
|
||
}
|
||
|
||
function getConfigPath() {
|
||
return Folder.userData.fsName + "/VFXReviewConnector/config.json";
|
||
}
|
||
|
||
function loadConfig() {
|
||
try {
|
||
var f = new File(getConfigPath());
|
||
if (f.exists) {
|
||
f.encoding = "UTF-8";
|
||
f.open("r");
|
||
var raw = f.read();
|
||
f.close();
|
||
var parsed = parseJSON(raw);
|
||
if (parsed) {
|
||
for (var k in parsed) {
|
||
if (parsed.hasOwnProperty(k)) CONFIG[k] = parsed[k];
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function saveConfig() {
|
||
try {
|
||
var dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector");
|
||
if (!dir.exists) { dir.create(); }
|
||
var f = new File(getConfigPath());
|
||
f.encoding = "UTF-8";
|
||
f.open("w");
|
||
f.write(jsonStringify(CONFIG));
|
||
f.close();
|
||
} catch (e) {}
|
||
}
|
||
|
||
// POST JSON via a temp file (-d @file) — avoids cmd.exe quote-escaping
|
||
// problems for large bodies. Returns { status: httpCode, data: parsed }.
|
||
function postJSON(url, bodyObject) {
|
||
var bodyFile;
|
||
var command;
|
||
var response = null;
|
||
var markerIndex;
|
||
var statusCode = 0;
|
||
var jsonText;
|
||
|
||
bodyFile = new File(Folder.temp.fsName + "/vfxr_post_body.json");
|
||
try {
|
||
bodyFile.encoding = "UTF-8";
|
||
if (!bodyFile.open("w")) {
|
||
return { status: 0, data: null };
|
||
}
|
||
bodyFile.write(jsonStringify(bodyObject));
|
||
bodyFile.close();
|
||
|
||
command = "curl -s -X POST " +
|
||
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
|
||
"-H \"Content-Type: application/json\" " +
|
||
"-H \"Accept: application/json\" " +
|
||
"-d \"@" + bodyFile.fsName + "\" " +
|
||
"-w \"__HTTP__:%{http_code}\" " +
|
||
"\"" + url + "\"";
|
||
|
||
response = system.callSystem(command);
|
||
} catch (postError) {
|
||
response = null;
|
||
}
|
||
try {
|
||
bodyFile.remove();
|
||
} catch (removeError) {
|
||
}
|
||
|
||
if (!response) {
|
||
return { status: 0, data: null };
|
||
}
|
||
|
||
markerIndex = response.lastIndexOf("__HTTP__:");
|
||
if (markerIndex >= 0) {
|
||
statusCode = parseInt(response.substring(markerIndex + 9), 10) || 0;
|
||
jsonText = response.substring(0, markerIndex);
|
||
} else {
|
||
jsonText = response;
|
||
}
|
||
|
||
return { status: statusCode, data: parseJSON(jsonText) };
|
||
}
|
||
|
||
function getLatestExportData(shotCode) {
|
||
var url = CONFIG.baseUrl + "/api/ext/exports/latest" +
|
||
"?shotCode=" + encodeURIComponent(shotCode) +
|
||
"&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
|
||
return getAPIData(url);
|
||
}
|
||
|
||
function isActiveExportStatus(status) {
|
||
return status === "QUEUED" || status === "RENDERING" ||
|
||
status === "VALIDATING" || status === "GENERATING_PREVIEW";
|
||
}
|
||
|
||
// Mirrors REGENERATABLE_STATUSES on the server: every state where the EXRs
|
||
// are known good, so only the preview needs rebuilding.
|
||
function isRegeneratableExportStatus(status) {
|
||
return status === "READY_FOR_QC" || status === "QC_FAILED" ||
|
||
status === "PREVIEW_FAILED";
|
||
}
|
||
|
||
function formatEtaShort(seconds) {
|
||
var mins;
|
||
var secs;
|
||
|
||
if (!seconds || seconds <= 0) {
|
||
return "";
|
||
}
|
||
mins = Math.floor(seconds / 60);
|
||
secs = Math.round(seconds % 60);
|
||
if (secs < 10) {
|
||
secs = "0" + secs;
|
||
}
|
||
return mins + ":" + secs;
|
||
}
|
||
|
||
function formatExportStatus(shotCode, exportInfo) {
|
||
var text;
|
||
var job;
|
||
var pct;
|
||
var eta;
|
||
|
||
if (!exportInfo) {
|
||
return shotCode + " — no exports yet";
|
||
}
|
||
|
||
text = shotCode + " " + (exportInfo.versionString || "") + " — " +
|
||
String(exportInfo.status || "").replace(/_/g, " ");
|
||
job = exportInfo.renderJob;
|
||
|
||
if (exportInfo.status === "RENDERING" && job) {
|
||
pct = Math.round((job.progress || 0) * 100);
|
||
text += " " + pct + "%";
|
||
eta = formatEtaShort(job.etaSeconds);
|
||
if (eta) {
|
||
text += " (ETA " + eta;
|
||
if (job.machineName) {
|
||
text += " on " + job.machineName;
|
||
}
|
||
text += ")";
|
||
} else if (job.machineName) {
|
||
text += " (" + job.machineName + ")";
|
||
}
|
||
} else if (job && job.errorMessage && /FAILED/.test(String(exportInfo.status))) {
|
||
text += ": " + String(job.errorMessage).substring(0, 60);
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function setPipelineStatus(message) {
|
||
if (uiState.pipelineStatusText) {
|
||
uiState.pipelineStatusText.text = message || "";
|
||
}
|
||
}
|
||
|
||
function stopExportPolling() {
|
||
if (pipelineState.pollTaskId) {
|
||
try {
|
||
app.cancelTask(pipelineState.pollTaskId);
|
||
} catch (cancelError) {
|
||
}
|
||
pipelineState.pollTaskId = 0;
|
||
}
|
||
pipelineState.pollShotCode = null;
|
||
}
|
||
|
||
function pollExportTick() {
|
||
var shotCode = pipelineState.pollShotCode;
|
||
var data;
|
||
var exportInfo;
|
||
|
||
if (!shotCode) {
|
||
stopExportPolling();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
data = getLatestExportData(shotCode);
|
||
if (data && data.export !== undefined) {
|
||
exportInfo = data.export;
|
||
setPipelineStatus(formatExportStatus(shotCode, exportInfo));
|
||
if (!exportInfo || !isActiveExportStatus(exportInfo.status)) {
|
||
stopExportPolling();
|
||
}
|
||
}
|
||
} catch (pollError) {
|
||
}
|
||
}
|
||
|
||
// While an export is active the panel polls every 15 s (spec §12.1); the
|
||
// task cancels itself once the export reaches a non-active state.
|
||
function startExportPolling(shotCode) {
|
||
stopExportPolling();
|
||
pipelineState.pollShotCode = shotCode;
|
||
try {
|
||
pipelineState.pollTaskId = app.scheduleTask("__vfxrExportPollTick()", 15000, true);
|
||
} catch (scheduleError) {
|
||
pipelineState.pollTaskId = 0;
|
||
}
|
||
}
|
||
|
||
function getStatusShotCode() {
|
||
var comps = getTargetComps();
|
||
var mainComp;
|
||
var code;
|
||
|
||
if (comps.length > 0) {
|
||
mainComp = getMainCompForSelection(comps[0]) || comps[0];
|
||
code = extractShotCode(mainComp.name);
|
||
if (code) {
|
||
return code;
|
||
}
|
||
}
|
||
if (uiState.shotDropdown && uiState.shotDropdown.selection) {
|
||
return String(uiState.shotDropdown.selection.text || "") || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Slate fields carry forward: pull the previous submission's values when the
|
||
// selected shot changes, but never clobber edits the artist has just typed
|
||
// for the same shot.
|
||
function prefillSlateFields(shotCode, exportInfo) {
|
||
if (pipelineState.prefilledShotCode === shotCode) {
|
||
return;
|
||
}
|
||
if (uiState.submissionNoteField) {
|
||
uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
|
||
}
|
||
pipelineState.prefilledShotCode = shotCode;
|
||
}
|
||
|
||
function refreshExportStatus() {
|
||
var shotCode = getStatusShotCode();
|
||
var data;
|
||
|
||
if (!shotCode) {
|
||
setPipelineStatus("Select a comp or shot first");
|
||
return;
|
||
}
|
||
setPipelineStatus("Checking " + shotCode + "...");
|
||
data = getLatestExportData(shotCode);
|
||
if (!data || data.export === undefined) {
|
||
setPipelineStatus(shotCode + " — status check failed (server unreachable?)");
|
||
return;
|
||
}
|
||
prefillSlateFields(shotCode, data.export);
|
||
setPipelineStatus(formatExportStatus(shotCode, data.export));
|
||
if (data.export && isActiveExportStatus(data.export.status)) {
|
||
startExportPolling(shotCode);
|
||
}
|
||
}
|
||
|
||
// Every file-backed footage item the comp actually renders from, found by
|
||
// walking its layers rather than re-deriving paths from a convention: what
|
||
// was comped is the only thing the render's timecode can be checked against
|
||
// (RenderPipeline2 §8.1a). Recurses through precomps, so the plates inside
|
||
// {shotCode}_FOOTAGE are found from the shot comp.
|
||
function collectPlateRefs(comp) {
|
||
var plates = [];
|
||
var seen = {};
|
||
|
||
function visit(item, depth) {
|
||
var i;
|
||
var layer;
|
||
var source;
|
||
var file;
|
||
var path;
|
||
var fps;
|
||
|
||
if (!item || depth > 4) {
|
||
return;
|
||
}
|
||
|
||
for (i = 1; i <= item.numLayers; i += 1) {
|
||
source = null;
|
||
try {
|
||
layer = item.layer(i);
|
||
source = layer.source;
|
||
} catch (layerError) {
|
||
continue;
|
||
}
|
||
if (!source) {
|
||
continue;
|
||
}
|
||
|
||
if (source instanceof CompItem) {
|
||
visit(source, depth + 1);
|
||
continue;
|
||
}
|
||
if (!(source instanceof FootageItem)) {
|
||
continue;
|
||
}
|
||
|
||
// Solids, placeholders and missing footage have no file on disk.
|
||
file = null;
|
||
try {
|
||
file = source.file;
|
||
} catch (fileError) {
|
||
file = null;
|
||
}
|
||
if (!file) {
|
||
continue;
|
||
}
|
||
|
||
path = String(file.fsName).replace(/\\/g, "/");
|
||
if (seen[path]) {
|
||
continue;
|
||
}
|
||
seen[path] = true;
|
||
|
||
fps = source.frameRate || item.frameRate || 24;
|
||
plates.push({
|
||
element: source.name,
|
||
path: path,
|
||
frames: Math.round(source.duration * fps),
|
||
duration: source.duration
|
||
});
|
||
}
|
||
}
|
||
|
||
visit(comp, 0);
|
||
return plates;
|
||
}
|
||
|
||
// The element the comp's timing came from: longest duration, first on tie —
|
||
// the same rule buildShotFromCode uses to pick primaryFootage.
|
||
function pickTimingPlate(plates) {
|
||
var best = null;
|
||
var i;
|
||
|
||
for (i = 0; i < plates.length; i += 1) {
|
||
if (!best || plates[i].duration > best.duration) {
|
||
best = plates[i];
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function toManifestPlate(plate, frameStart) {
|
||
var ref = { element: plate.element, path: plate.path };
|
||
var parsed = parseFrameNumberFromPath(plate.path);
|
||
var start = parsed !== null ? parsed : frameStart;
|
||
|
||
ref.frameStart = start;
|
||
ref.frameEnd = start + Math.max(0, plate.frames - 1);
|
||
return ref;
|
||
}
|
||
|
||
// "UNG_102_019_010_BG01_TT_v001.01001.exr" -> 1001
|
||
function parseFrameNumberFromPath(path) {
|
||
var match = String(path).match(/\.(\d{2,9})\.[A-Za-z0-9]+$/);
|
||
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
return parseInt(match[1], 10);
|
||
}
|
||
|
||
function buildRenderManifest(comp, shot) {
|
||
var frameStart;
|
||
var frameEnd;
|
||
var frames;
|
||
var aepPath = app.project.file.fsName.replace(/\\/g, "/");
|
||
var plates;
|
||
var timingPlate;
|
||
var manifest;
|
||
var i;
|
||
|
||
// Prefer the shot record's authoritative range (the server cross-checks
|
||
// it) — but only when it looks real: shots imported without ranges
|
||
// carry 0/0, which must never reach aerender. Fall back to the comp.
|
||
if (shot.frameStart !== null && shot.frameStart !== undefined &&
|
||
shot.frameEnd !== null && shot.frameEnd !== undefined &&
|
||
Number(shot.frameEnd) >= Number(shot.frameStart) &&
|
||
!(Number(shot.frameStart) === 0 && Number(shot.frameEnd) === 0)) {
|
||
frameStart = shot.frameStart;
|
||
frameEnd = shot.frameEnd;
|
||
} else {
|
||
frameStart = Math.round(comp.displayStartTime * comp.frameRate);
|
||
frames = Math.round(comp.duration * comp.frameRate);
|
||
frameEnd = frameStart + frames - 1;
|
||
}
|
||
|
||
manifest = {
|
||
manifestVersion: 2,
|
||
projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "",
|
||
shotCode: shot.shotCode,
|
||
shotId: shot.id,
|
||
aepPath: aepPath,
|
||
compName: comp.name,
|
||
rendererType: "aerender",
|
||
outputDir: "auto",
|
||
outputPattern: "auto",
|
||
outputModuleTemplate: "EXR Sequence",
|
||
frameStart: frameStart,
|
||
frameEnd: frameEnd,
|
||
fps: Math.round(comp.frameRate * 1000) / 1000,
|
||
width: comp.width,
|
||
height: comp.height,
|
||
expected: {
|
||
// The API's editorial in-point. Only ever the worker's last-resort
|
||
// timecode reference, and advisory even then (§8.1a).
|
||
timecodeStart: shot.timecodeStart || undefined
|
||
}
|
||
};
|
||
|
||
plates = collectPlateRefs(comp);
|
||
timingPlate = pickTimingPlate(plates);
|
||
if (timingPlate) {
|
||
manifest.source = {
|
||
timingPlate: toManifestPlate(timingPlate, frameStart),
|
||
plates: [],
|
||
handleFrames: CONFIG.handleFrames
|
||
};
|
||
for (i = 0; i < plates.length; i += 1) {
|
||
manifest.source.plates.push(toManifestPlate(plates[i], frameStart));
|
||
}
|
||
// Only claim a start source when this session actually resolved one
|
||
// for this shot — an AEP opened from disk has no such history, and a
|
||
// guess here would either cry wolf or hide a real problem.
|
||
if (buildState.lastStartShotCode === shot.shotCode && buildState.lastStartSource) {
|
||
manifest.source.startSource = buildState.lastStartSource;
|
||
manifest.source.startFrames = buildState.lastStartFrames;
|
||
}
|
||
diagLog("manifest plates: " + plates.length + ", timing plate " + timingPlate.element);
|
||
} else {
|
||
diagLog("no file-backed footage found in " + comp.name +
|
||
" — the render's timecode cannot be checked against a plate");
|
||
}
|
||
|
||
return manifest;
|
||
}
|
||
|
||
function submitExport(manifest, urgent, force) {
|
||
var body = {
|
||
manifest: manifest,
|
||
force: force ? true : false
|
||
};
|
||
|
||
if (CONFIG.artistEmail) {
|
||
body.submittedByEmail = CONFIG.artistEmail;
|
||
}
|
||
if (urgent) {
|
||
body.priority = 20;
|
||
}
|
||
// submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
|
||
if (uiState.submissionNoteField) {
|
||
var note = trimText(uiState.submissionNoteField.text);
|
||
if (note) {
|
||
body.submissionNote = note;
|
||
}
|
||
}
|
||
return postJSON(CONFIG.baseUrl + "/api/ext/exports", body);
|
||
}
|
||
|
||
function queueExport() {
|
||
var comps = getTargetComps();
|
||
var result = makeBatchResult(comps.length);
|
||
var urgent = uiState.urgentCheckbox ? uiState.urgentCheckbox.value : false;
|
||
var i;
|
||
var comp;
|
||
var lookupCode;
|
||
var data;
|
||
var manifest;
|
||
var response;
|
||
var errorText;
|
||
var lastQueued = null;
|
||
var lastShotCode = null;
|
||
|
||
if (comps.length < 1) {
|
||
updateStatus(0, "Select or open a comp first.");
|
||
return;
|
||
}
|
||
if (!app.project || !app.project.file) {
|
||
updateStatus(comps.length, "Save the project first — the render farm opens the AEP from disk.");
|
||
return;
|
||
}
|
||
|
||
// The worker renders the saved file; unsaved changes would silently
|
||
// render stale comps.
|
||
try {
|
||
app.project.save();
|
||
} catch (saveError) {
|
||
updateStatus(comps.length, "Could not save the project: " + saveError);
|
||
return;
|
||
}
|
||
|
||
for (i = 0; i < comps.length; i += 1) {
|
||
comp = getMainCompForSelection(comps[i]);
|
||
if (!comp) {
|
||
addFailure(result, comps[i].name, "Main comp not found for preview comp");
|
||
continue;
|
||
}
|
||
lookupCode = getShotCodeFromComp(comp);
|
||
updateStatus(comps.length, "Queuing " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")...");
|
||
|
||
data = getShotData(lookupCode);
|
||
if (!data || !data.shot || !data.shot.id) {
|
||
addFailure(result, comp.name, "API lookup failed");
|
||
continue;
|
||
}
|
||
|
||
manifest = buildRenderManifest(comp, data.shot);
|
||
response = submitExport(manifest, urgent, false);
|
||
|
||
if (response.status === 409) {
|
||
errorText = (response.data && response.data.error) ?
|
||
response.data.error : "An active export already exists for this shot.";
|
||
if (confirm(lookupCode + ":\n" + errorText +
|
||
"\n\nForce supersede the active export and queue anyway?")) {
|
||
response = submitExport(manifest, urgent, true);
|
||
} else {
|
||
addFailure(result, comp.name, "Skipped (active export kept)");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (response.status === 201 && response.data && response.data.export) {
|
||
result.queued += 1;
|
||
lastQueued = response.data.export;
|
||
lastShotCode = response.data.export.shotCode || lookupCode;
|
||
} else {
|
||
errorText = (response.data && response.data.error) ?
|
||
response.data.error : ("HTTP " + response.status);
|
||
addFailure(result, comp.name, errorText);
|
||
}
|
||
}
|
||
|
||
if (lastQueued) {
|
||
updateStatus(comps.length, "Queued " + lastQueued.versionString +
|
||
(urgent ? " (urgent)" : "") + " on render farm. " +
|
||
result.queued + " queued, " + result.skipped + " skipped.");
|
||
setPipelineStatus(formatExportStatus(lastShotCode, lastQueued));
|
||
startExportPolling(lastShotCode);
|
||
} else {
|
||
updateStatus(comps.length, "No exports queued. " +
|
||
(result.failures.length > 0 ? result.failures[0] : ""));
|
||
}
|
||
}
|
||
|
||
function retryLatestExport() {
|
||
var shotCode = getStatusShotCode();
|
||
var data;
|
||
var exportInfo;
|
||
var response;
|
||
|
||
if (!shotCode) {
|
||
setPipelineStatus("Select a comp or shot first");
|
||
return;
|
||
}
|
||
data = getLatestExportData(shotCode);
|
||
exportInfo = data ? data.export : null;
|
||
if (!exportInfo) {
|
||
setPipelineStatus(shotCode + " — no export to retry");
|
||
return;
|
||
}
|
||
if (!/FAILED$/.test(String(exportInfo.status))) {
|
||
setPipelineStatus(formatExportStatus(shotCode, exportInfo) + " — nothing to retry");
|
||
return;
|
||
}
|
||
|
||
response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/retry",
|
||
{ note: "Retry from AE panel" });
|
||
if (response.status === 200) {
|
||
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued");
|
||
startExportPolling(shotCode);
|
||
} else {
|
||
setPipelineStatus("Retry failed: " +
|
||
((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
|
||
}
|
||
}
|
||
|
||
// Rebuild the delivered MOV/MP4 after a slate/burn-in mistake: same version,
|
||
// same EXRs, freshly built slate. Slate text (description, scope, note)
|
||
// comes from the shot record, so it is fixed on the web app first — except
|
||
// the panel's Sub. Note field, which still wins as a per-export override.
|
||
function regenerateLatestPreview() {
|
||
var shotCode = getStatusShotCode();
|
||
var data;
|
||
var exportInfo;
|
||
var body = {};
|
||
var note;
|
||
var response;
|
||
|
||
if (!shotCode) {
|
||
setPipelineStatus("Select a comp or shot first");
|
||
return;
|
||
}
|
||
data = getLatestExportData(shotCode);
|
||
exportInfo = data ? data.export : null;
|
||
if (!exportInfo) {
|
||
setPipelineStatus(shotCode + " — no export to regenerate");
|
||
return;
|
||
}
|
||
if (!isRegeneratableExportStatus(String(exportInfo.status))) {
|
||
setPipelineStatus(formatExportStatus(shotCode, exportInfo) +
|
||
" — preview can only be regenerated once the render is done");
|
||
return;
|
||
}
|
||
if (!confirm("Rebuild the preview for " + shotCode + " " + exportInfo.versionString + "?\n\n" +
|
||
"The rendered EXRs are reused and the version number stays the same.\n" +
|
||
"The existing review version's media is replaced.")) {
|
||
return;
|
||
}
|
||
|
||
if (uiState.submissionNoteField) {
|
||
note = trimText(uiState.submissionNoteField.text);
|
||
if (note) {
|
||
body.submissionNote = note;
|
||
}
|
||
}
|
||
|
||
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — regenerating preview...");
|
||
response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/regenerate-preview", body);
|
||
if (response.status === 200) {
|
||
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — preview queued");
|
||
startExportPolling(shotCode);
|
||
} else {
|
||
setPipelineStatus("Regenerate failed: " +
|
||
((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
|
||
}
|
||
}
|
||
|
||
// ── QC (RenderPipeline2 §10) ──────────────────────────────────────────────
|
||
//
|
||
// Technical QC on the *rendered EXRs*, not the H.264 preview: the point is
|
||
// catching mask slips, export glitches and metadata faults at full res, so
|
||
// selecting a queue entry imports the real sequence.
|
||
|
||
function loadQCQueue() {
|
||
var url = CONFIG.baseUrl + "/api/ext/qc/queue";
|
||
var data;
|
||
var entries;
|
||
var i;
|
||
var entry;
|
||
var label;
|
||
|
||
if (!uiState.qcList) {
|
||
return;
|
||
}
|
||
if (CURRENT_PROJECT) {
|
||
url += "?projectCode=" + encodeURIComponent(CURRENT_PROJECT.code);
|
||
}
|
||
|
||
setQCStatus("Loading QC queue...");
|
||
data = getAPIData(url);
|
||
if (!data) {
|
||
setQCStatus("QC queue failed: " + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
|
||
return;
|
||
}
|
||
|
||
entries = getListFromResponse(data, "queue");
|
||
qcState.entries = entries;
|
||
uiState.qcList.removeAll();
|
||
|
||
for (i = 0; i < entries.length; i += 1) {
|
||
entry = entries[i];
|
||
label = entry.shotCode + " " + entry.versionString;
|
||
if (entry.warnings && entry.warnings.length) {
|
||
label += " (" + entry.warnings.length + " warning" + (entry.warnings.length > 1 ? "s" : "") + ")";
|
||
}
|
||
uiState.qcList.add("item", label);
|
||
}
|
||
|
||
if (entries.length) {
|
||
uiState.qcList.selection = 0;
|
||
setQCStatus(entries.length + " export(s) awaiting QC");
|
||
} else {
|
||
setQCStatus("Nothing waiting for QC");
|
||
}
|
||
}
|
||
|
||
function getSelectedQCEntry() {
|
||
var index;
|
||
|
||
if (!uiState.qcList || !uiState.qcList.selection) {
|
||
return null;
|
||
}
|
||
index = uiState.qcList.selection.index;
|
||
if (index < 0 || index >= qcState.entries.length) {
|
||
return null;
|
||
}
|
||
return qcState.entries[index];
|
||
}
|
||
|
||
function describeQCEntry(entry) {
|
||
var text = entry.shotCode + " " + entry.versionString;
|
||
var i;
|
||
|
||
if (entry.readySince) {
|
||
text += " — ready since " + String(entry.readySince).substring(0, 10);
|
||
}
|
||
for (i = 0; entry.warnings && i < entry.warnings.length; i += 1) {
|
||
text += "\n" + entry.warnings[i].status + " " + entry.warnings[i].checkName +
|
||
(entry.warnings[i].message ? ": " + entry.warnings[i].message : "");
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// 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) {
|
||
// 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;
|
||
var footage;
|
||
var comp;
|
||
var compName = "QC_" + entry.shotCode + "_" + entry.versionString;
|
||
var existing = findComp(compName);
|
||
|
||
if (existing) {
|
||
existing.openInViewer();
|
||
setQCStatus(compName + " already open");
|
||
return true;
|
||
}
|
||
|
||
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);
|
||
});
|
||
if (!files || files.length < 1) {
|
||
files = folder.getFiles("*.exr");
|
||
}
|
||
if (!files || files.length < 1) {
|
||
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;
|
||
}
|
||
|
||
files.sort();
|
||
try {
|
||
importOptions = new ImportOptions(files[0]);
|
||
importOptions.sequence = true;
|
||
footage = app.project.importFile(importOptions);
|
||
footage.mainSource.conformFrameRate = entry.fps || 24;
|
||
} catch (importError) {
|
||
setQCStatus("Import failed: " + describeError(importError));
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
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);
|
||
comp.openInViewer();
|
||
} catch (compError) {
|
||
setQCStatus("Could not build QC comp: " + describeError(compError));
|
||
return false;
|
||
}
|
||
|
||
diagLog("QC import: " + files.length + " frames from " + entry.outputDir);
|
||
return true;
|
||
}
|
||
|
||
function openSelectedQCEntry() {
|
||
var entry = getSelectedQCEntry();
|
||
|
||
if (!entry) {
|
||
setQCStatus("Select a queue entry first");
|
||
return;
|
||
}
|
||
|
||
setQCStatus("Importing " + entry.shotCode + " " + entry.versionString + "...");
|
||
app.beginUndoGroup("VFXReview Connector - QC Import");
|
||
if (importQCSequence(entry)) {
|
||
setQCStatus(describeQCEntry(entry));
|
||
}
|
||
app.endUndoGroup();
|
||
}
|
||
|
||
function submitQC(result) {
|
||
var entry = getSelectedQCEntry();
|
||
var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : "";
|
||
var body;
|
||
var response;
|
||
|
||
if (!entry) {
|
||
setQCStatus("Select a queue entry first");
|
||
return;
|
||
}
|
||
|
||
// A bare FAIL leaves the artist guessing at what to fix.
|
||
if (result === "FAIL" && !notes) {
|
||
if (!confirm("Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" +
|
||
"The artist only sees the reason you write here.")) {
|
||
return;
|
||
}
|
||
}
|
||
if (result === "PASS" && !confirm("QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" +
|
||
"This marks the version ready for delivery. Nothing is sent to the client.")) {
|
||
return;
|
||
}
|
||
|
||
body = { result: result };
|
||
if (notes) {
|
||
body.notes = notes;
|
||
}
|
||
if (CONFIG.artistEmail) {
|
||
body.reviewerEmail = CONFIG.artistEmail;
|
||
}
|
||
|
||
setQCStatus("Submitting QC " + result + " for " + entry.shotCode + "...");
|
||
response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + entry.exportId + "/qc", body);
|
||
|
||
if (response.status === 200) {
|
||
if (uiState.qcNotesField) {
|
||
uiState.qcNotesField.text = "";
|
||
}
|
||
setQCStatus(entry.shotCode + " " + entry.versionString + " — QC " + result +
|
||
(result === "FAIL" ? " (comp task set to CHANGES)" : " — ready for delivery"));
|
||
loadQCQueue();
|
||
} else {
|
||
setQCStatus("QC " + result + " failed: " +
|
||
((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
|
||
}
|
||
}
|
||
|
||
function qcPass() {
|
||
submitQC("PASS");
|
||
}
|
||
|
||
function qcFail() {
|
||
submitQC("FAIL");
|
||
}
|
||
|
||
function setQCStatus(message) {
|
||
if (uiState.qcStatusText) {
|
||
uiState.qcStatusText.text = message || "";
|
||
}
|
||
diagLog("QC: " + message);
|
||
}
|
||
|
||
function buildUI(thisObjRef) {
|
||
var win = (thisObjRef instanceof Panel) ? thisObjRef : new Window("palette", "VFXReview Connector", undefined, { resizeable: true });
|
||
var bannerFile;
|
||
var bannerImage;
|
||
var title;
|
||
var shotBuilderPanel;
|
||
var shotBuilderGroup;
|
||
var shotBuilderButton;
|
||
var pipelinePanel;
|
||
var pipelineRow1;
|
||
var pipelineRow2;
|
||
var pipelineRow3;
|
||
var queueExportButton;
|
||
var refreshExportButton;
|
||
var retryExportButton;
|
||
var regeneratePreviewButton;
|
||
var buttonsPanel;
|
||
var btnRow1;
|
||
var btnRow2;
|
||
var btnRow3;
|
||
var btnRow4;
|
||
var btnRow4b;
|
||
var btnRow4c;
|
||
var prepDeliveryButton;
|
||
var refreshButton;
|
||
var fixNamesButton;
|
||
var overlayButton;
|
||
var previewButton;
|
||
var exrButton;
|
||
var exrPreviewButton;
|
||
var importExrButton;
|
||
var mp4Button;
|
||
var movButton;
|
||
var btnRow5;
|
||
var importRendersButton;
|
||
var btnRow6;
|
||
var pictureLockButton;
|
||
var btnRow7;
|
||
var incrementVersionButton;
|
||
var btnRow6b;
|
||
var setStartTCButton;
|
||
var colorSpacePanel;
|
||
var ocioButton;
|
||
var workspacePanel;
|
||
var initWorkspaceButton;
|
||
var statusPanel;
|
||
var statusRow;
|
||
var diagnosticsButton;
|
||
var showLogButton;
|
||
var qcPanel;
|
||
var qcRow1;
|
||
var qcRow2;
|
||
var loadQCButton;
|
||
var openQCButton;
|
||
var qcPassButton;
|
||
var qcFailButton;
|
||
|
||
win.orientation = "column";
|
||
win.alignChildren = ["fill", "fill"];
|
||
win.spacing = 0;
|
||
win.margins = 0;
|
||
|
||
var scrollRow = win.add("group");
|
||
scrollRow.orientation = "row";
|
||
scrollRow.alignChildren = ["fill", "fill"];
|
||
scrollRow.spacing = 0;
|
||
scrollRow.margins = 0;
|
||
|
||
var scrollContent = scrollRow.add("group");
|
||
scrollContent.orientation = "column";
|
||
scrollContent.alignChildren = ["fill", "top"];
|
||
scrollContent.spacing = 8;
|
||
scrollContent.margins = 12;
|
||
|
||
var vScrollbar = scrollRow.add("scrollbar");
|
||
vScrollbar.orientation = "vertical";
|
||
vScrollbar.preferredSize = [14, -1];
|
||
vScrollbar.minvalue = 0;
|
||
vScrollbar.value = 0;
|
||
|
||
bannerFile = new File(CONFIG.bannerImagePath);
|
||
if (bannerFile.exists) {
|
||
try {
|
||
bannerImage = scrollContent.add("image", undefined, bannerFile);
|
||
bannerImage.alignment = ["fit", "top"];
|
||
bannerImage.size = [360, 80];
|
||
} catch (bannerError) {
|
||
}
|
||
}
|
||
|
||
title = scrollContent.add("statictext", undefined, "VFXReview Connector v" + PANEL_VERSION);
|
||
title.alignment = ["fill", "top"];
|
||
|
||
shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder");
|
||
shotBuilderPanel.orientation = "column";
|
||
shotBuilderPanel.alignChildren = ["fill", "top"];
|
||
shotBuilderPanel.margins = 10;
|
||
|
||
shotBuilderGroup = shotBuilderPanel.add("group");
|
||
shotBuilderGroup.orientation = "row";
|
||
shotBuilderGroup.alignChildren = ["fill", "center"];
|
||
shotBuilderGroup.spacing = 6;
|
||
|
||
// Project row
|
||
var projectRow = shotBuilderPanel.add("group");
|
||
projectRow.orientation = "row";
|
||
projectRow.alignChildren = ["fill", "center"];
|
||
projectRow.spacing = 6;
|
||
var projectLabel = projectRow.add("statictext", undefined, "Project:");
|
||
projectLabel.preferredSize = [42, 20];
|
||
uiState.projectDropdown = projectRow.add("dropdownlist", undefined, []);
|
||
uiState.projectDropdown.preferredSize = [220, 24];
|
||
var settingsBtn = projectRow.add("button", undefined, "\u2699");
|
||
settingsBtn.preferredSize = [26, 24];
|
||
settingsBtn.helpTip = "Settings";
|
||
|
||
// Episode / Scene row
|
||
var episodeRow = shotBuilderPanel.add("group");
|
||
episodeRow.orientation = "row";
|
||
episodeRow.alignChildren = ["fill", "center"];
|
||
episodeRow.spacing = 6;
|
||
uiState.episodeLabel = episodeRow.add("statictext", undefined, "Ep:");
|
||
uiState.episodeLabel.preferredSize = [42, 20];
|
||
uiState.episodeDropdown = episodeRow.add("dropdownlist", undefined, []);
|
||
uiState.episodeDropdown.preferredSize = [110, 24];
|
||
uiState.shotDropdown = episodeRow.add("dropdownlist", undefined, []);
|
||
uiState.shotDropdown.preferredSize = [130, 24];
|
||
shotBuilderButton = episodeRow.add("button", undefined, "Build Shot");
|
||
|
||
pipelinePanel = scrollContent.add("panel", undefined, "Render Pipeline");
|
||
pipelinePanel.orientation = "column";
|
||
pipelinePanel.alignChildren = ["fill", "top"];
|
||
pipelinePanel.margins = 8;
|
||
pipelinePanel.spacing = 4;
|
||
|
||
uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked");
|
||
|
||
pipelineRow1 = pipelinePanel.add("group");
|
||
pipelineRow1.orientation = "row";
|
||
pipelineRow1.alignChildren = ["fill", "center"];
|
||
pipelineRow1.spacing = 6;
|
||
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
|
||
uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");
|
||
|
||
var slateNoteGroup = pipelinePanel.add("group");
|
||
slateNoteGroup.orientation = "row";
|
||
slateNoteGroup.alignChildren = ["fill", "top"];
|
||
slateNoteGroup.spacing = 6;
|
||
var slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Sub. Note:");
|
||
slateNoteLabel.preferredSize = [70, 20];
|
||
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: false });
|
||
uiState.submissionNoteField.preferredSize = [240, 22];
|
||
|
||
pipelineRow2 = pipelinePanel.add("group");
|
||
pipelineRow2.orientation = "row";
|
||
pipelineRow2.alignChildren = ["fill", "center"];
|
||
pipelineRow2.spacing = 6;
|
||
refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status");
|
||
retryExportButton = pipelineRow2.add("button", undefined, "Retry Export");
|
||
|
||
pipelineRow3 = pipelinePanel.add("group");
|
||
pipelineRow3.orientation = "row";
|
||
pipelineRow3.alignChildren = ["fill", "center"];
|
||
pipelineRow3.spacing = 6;
|
||
regeneratePreviewButton = pipelineRow3.add("button", undefined, "Regenerate Previews");
|
||
regeneratePreviewButton.helpTip =
|
||
"Rebuild the MOV/MP4 slate and burn-ins for the delivered version.\n" +
|
||
"Reuses the rendered EXRs and keeps the version number. Slate text is\n" +
|
||
"re-read from the shot, so fix it on the web app's Slate tab first.";
|
||
|
||
qcPanel = scrollContent.add("panel", undefined, "QC");
|
||
qcPanel.orientation = "column";
|
||
qcPanel.alignChildren = ["fill", "top"];
|
||
qcPanel.margins = 8;
|
||
qcPanel.spacing = 4;
|
||
|
||
uiState.qcStatusText = qcPanel.add("statictext", undefined, "QC queue: not loaded", { multiline: true });
|
||
uiState.qcStatusText.preferredSize = [340, 34];
|
||
|
||
uiState.qcList = qcPanel.add("listbox", undefined, []);
|
||
uiState.qcList.preferredSize = [340, 90];
|
||
uiState.qcList.helpTip =
|
||
"Exports waiting for technical QC, oldest first.\n" +
|
||
"Double-click (or Open Render) to import the full-res EXRs.";
|
||
|
||
qcRow1 = qcPanel.add("group");
|
||
qcRow1.orientation = "row";
|
||
qcRow1.alignChildren = ["fill", "center"];
|
||
qcRow1.spacing = 6;
|
||
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.";
|
||
|
||
var qcNotesGroup = qcPanel.add("group");
|
||
qcNotesGroup.orientation = "row";
|
||
qcNotesGroup.alignChildren = ["fill", "top"];
|
||
qcNotesGroup.spacing = 6;
|
||
var qcNotesLabel = qcNotesGroup.add("statictext", undefined, "Notes:");
|
||
qcNotesLabel.preferredSize = [70, 20];
|
||
uiState.qcNotesField = qcNotesGroup.add("edittext", undefined, "", { multiline: false });
|
||
uiState.qcNotesField.preferredSize = [240, 22];
|
||
|
||
qcRow2 = qcPanel.add("group");
|
||
qcRow2.orientation = "row";
|
||
qcRow2.alignChildren = ["fill", "center"];
|
||
qcRow2.spacing = 6;
|
||
qcPassButton = qcRow2.add("button", undefined, "QC Pass");
|
||
qcPassButton.helpTip = "Marks the version ready for delivery. Nothing is shared to the client.";
|
||
qcFailButton = qcRow2.add("button", undefined, "QC Fail");
|
||
qcFailButton.helpTip = "Sends the comp task back to CHANGES. Write what is wrong in Notes.";
|
||
|
||
buttonsPanel = scrollContent.add("panel", undefined, "Actions");
|
||
buttonsPanel.orientation = "column";
|
||
buttonsPanel.alignChildren = ["fill", "top"];
|
||
buttonsPanel.margins = 8;
|
||
buttonsPanel.spacing = 4;
|
||
|
||
btnRow1 = buttonsPanel.add("group");
|
||
btnRow1.orientation = "row";
|
||
btnRow1.alignChildren = ["fill", "center"];
|
||
btnRow1.spacing = 6;
|
||
refreshButton = btnRow1.add("button", undefined, "Refresh");
|
||
fixNamesButton = btnRow1.add("button", undefined, "Fix Comp Names");
|
||
|
||
btnRow2 = buttonsPanel.add("group");
|
||
btnRow2.orientation = "row";
|
||
btnRow2.alignChildren = ["fill", "center"];
|
||
btnRow2.spacing = 6;
|
||
overlayButton = btnRow2.add("button", undefined, "Add Overlay");
|
||
previewButton = btnRow2.add("button", undefined, "Build Preview");
|
||
|
||
btnRow3 = buttonsPanel.add("group");
|
||
btnRow3.orientation = "row";
|
||
btnRow3.alignChildren = ["fill", "center"];
|
||
btnRow3.spacing = 6;
|
||
mp4Button = btnRow3.add("button", undefined, "Queue MP4");
|
||
movButton = btnRow3.add("button", undefined, "Queue MOV");
|
||
|
||
btnRow4 = buttonsPanel.add("group");
|
||
btnRow4.orientation = "row";
|
||
btnRow4.alignChildren = ["fill", "center"];
|
||
btnRow4.spacing = 6;
|
||
exrButton = btnRow4.add("button", undefined, "Queue EXR");
|
||
exrPreviewButton = btnRow4.add("button", undefined, "Queue EXR (Review)");
|
||
|
||
btnRow4b = buttonsPanel.add("group");
|
||
btnRow4b.orientation = "row";
|
||
btnRow4b.alignChildren = ["fill", "center"];
|
||
btnRow4b.spacing = 6;
|
||
importExrButton = btnRow4b.add("button", undefined, "Import EXR");
|
||
|
||
btnRow4c = buttonsPanel.add("group");
|
||
btnRow4c.orientation = "row";
|
||
btnRow4c.alignChildren = ["fill", "center"];
|
||
btnRow4c.spacing = 6;
|
||
prepDeliveryButton = btnRow4c.add("button", undefined, "Prep Delivery");
|
||
|
||
btnRow5 = buttonsPanel.add("group");
|
||
btnRow5.orientation = "row";
|
||
btnRow5.alignChildren = ["fill", "center"];
|
||
btnRow5.spacing = 6;
|
||
importRendersButton = btnRow5.add("button", undefined, "Import Renders");
|
||
|
||
btnRow6 = buttonsPanel.add("group");
|
||
btnRow6.orientation = "row";
|
||
btnRow6.alignChildren = ["fill", "center"];
|
||
btnRow6.spacing = 6;
|
||
pictureLockButton = btnRow6.add("button", undefined, "Pull Picture Lock");
|
||
|
||
btnRow6b = buttonsPanel.add("group");
|
||
btnRow6b.orientation = "row";
|
||
btnRow6b.alignChildren = ["fill", "center"];
|
||
btnRow6b.spacing = 6;
|
||
setStartTCButton = btnRow6b.add("button", undefined, "Set Start Timecode");
|
||
|
||
btnRow7 = buttonsPanel.add("group");
|
||
btnRow7.orientation = "row";
|
||
btnRow7.alignChildren = ["fill", "center"];
|
||
btnRow7.spacing = 6;
|
||
incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version");
|
||
|
||
workspacePanel = scrollContent.add("panel", undefined, "Workspace");
|
||
workspacePanel.orientation = "column";
|
||
workspacePanel.alignChildren = ["fill", "top"];
|
||
workspacePanel.margins = 8;
|
||
workspacePanel.spacing = 4;
|
||
initWorkspaceButton = workspacePanel.add("button", undefined, "Initialise Workspace");
|
||
|
||
colorSpacePanel = scrollContent.add("panel", undefined, "Color Space");
|
||
colorSpacePanel.orientation = "column";
|
||
colorSpacePanel.alignChildren = ["fill", "top"];
|
||
colorSpacePanel.margins = 8;
|
||
colorSpacePanel.spacing = 4;
|
||
ocioButton = colorSpacePanel.add("button", undefined, "Add OCIO to Footage Layers");
|
||
|
||
statusPanel = scrollContent.add("panel", undefined, "Status");
|
||
statusPanel.orientation = "column";
|
||
statusPanel.alignChildren = ["fill", "top"];
|
||
statusPanel.margins = 10;
|
||
|
||
uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected");
|
||
uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready");
|
||
|
||
statusRow = statusPanel.add("group");
|
||
statusRow.orientation = "row";
|
||
statusRow.alignChildren = ["fill", "center"];
|
||
statusRow.spacing = 6;
|
||
diagnosticsButton = statusRow.add("button", undefined, "Diagnostics");
|
||
diagnosticsButton.helpTip =
|
||
"Check server, token, footage/export paths and template items on this\n" +
|
||
"machine without building anything, and show a report that can be copied.";
|
||
showLogButton = statusRow.add("button", undefined, "Show Last Report");
|
||
showLogButton.helpTip = "Re-open the trace from the last action, and a link to the log folder.";
|
||
|
||
uiState.projectDropdown.onChange = function () {
|
||
if (uiState.projectDropdown.selection) {
|
||
CURRENT_PROJECT = uiState.projectDropdown.selection.projectData;
|
||
if (CURRENT_PROJECT) {
|
||
CONFIG.lastProjectCode = CURRENT_PROJECT.code;
|
||
saveConfig();
|
||
}
|
||
populateEpisodeOrSceneDropdown();
|
||
}
|
||
};
|
||
uiState.episodeDropdown.onChange = populateShotDropdown;
|
||
// Every handler goes through guarded(): it traces the click to the log
|
||
// and surfaces anything thrown, which ScriptUI would otherwise swallow.
|
||
settingsBtn.onClick = guarded("Settings", openSettingsDialog);
|
||
shotBuilderButton.onClick = guarded("Build Shot", buildShotFromDropdown);
|
||
queueExportButton.onClick = guarded("Queue Export", queueExport);
|
||
refreshExportButton.onClick = guarded("Refresh Status", refreshExportStatus);
|
||
retryExportButton.onClick = guarded("Retry Export", retryLatestExport);
|
||
regeneratePreviewButton.onClick = guarded("Regenerate Previews", regenerateLatestPreview);
|
||
refreshButton.onClick = guarded("Refresh", refreshSelection);
|
||
fixNamesButton.onClick = guarded("Fix Comp Names", fixCompNames);
|
||
overlayButton.onClick = guarded("Add Overlay", addOverlay);
|
||
previewButton.onClick = guarded("Build Preview", buildPreviews);
|
||
exrButton.onClick = guarded("Queue EXR", queueEXR);
|
||
exrPreviewButton.onClick = guarded("Queue EXR (Review)", queueEXRPreview);
|
||
importExrButton.onClick = guarded("Import EXR", importExportedEXR);
|
||
prepDeliveryButton.onClick = guarded("Prep Delivery", prepDelivery);
|
||
mp4Button.onClick = guarded("Queue MP4", queueMP4);
|
||
movButton.onClick = guarded("Queue MOV", queueMOV);
|
||
ocioButton.onClick = guarded("Add OCIO", applyOCIOColorSpace);
|
||
initWorkspaceButton.onClick = guarded("Initialise Workspace", initialiseWorkspace);
|
||
importRendersButton.onClick = guarded("Import Renders", importRenders);
|
||
pictureLockButton.onClick = guarded("Pull Picture Lock", pullPictureLock);
|
||
setStartTCButton.onClick = guarded("Set Start Timecode", setStartFrameFromTimecode);
|
||
incrementVersionButton.onClick = guarded("Increment Shot Version", incrementShotVersions);
|
||
loadQCButton.onClick = guarded("Load QC Queue", loadQCQueue);
|
||
openQCButton.onClick = guarded("Open QC Render", openSelectedQCEntry);
|
||
uiState.qcList.onDoubleClick = guarded("Open QC Render", openSelectedQCEntry);
|
||
uiState.qcList.onChange = function () {
|
||
var entry = getSelectedQCEntry();
|
||
if (entry) {
|
||
setQCStatus(describeQCEntry(entry));
|
||
}
|
||
};
|
||
qcPassButton.onClick = guarded("QC Pass", qcPass);
|
||
qcFailButton.onClick = guarded("QC Fail", qcFail);
|
||
diagnosticsButton.onClick = guarded("Diagnostics", runDiagnostics);
|
||
showLogButton.onClick = function () {
|
||
showDiagDialog("Trace from the last action.", diagReport());
|
||
};
|
||
|
||
win.layout.layout(true);
|
||
win.layout.resize();
|
||
|
||
var _contentH = scrollContent.size[1];
|
||
vScrollbar.maxvalue = Math.max(0, _contentH - scrollRow.size[1]);
|
||
vScrollbar.stepdelta = 20;
|
||
vScrollbar.jumpdelta = 80;
|
||
|
||
vScrollbar.onChanging = function () {
|
||
scrollContent.location.y = -Math.round(vScrollbar.value);
|
||
};
|
||
|
||
win.onResizing = win.onResize = function () {
|
||
this.layout.resize();
|
||
var visible = scrollRow.size[1];
|
||
vScrollbar.maxvalue = Math.max(0, _contentH - visible);
|
||
if (vScrollbar.value > vScrollbar.maxvalue) {
|
||
vScrollbar.value = vScrollbar.maxvalue;
|
||
}
|
||
scrollContent.location.y = -Math.round(vScrollbar.value);
|
||
};
|
||
|
||
return win;
|
||
}
|
||
|
||
// app.scheduleTask evaluates in global scope — expose the poll tick there
|
||
$.global.__vfxrExportPollTick = pollExportTick;
|
||
|
||
loadConfig();
|
||
var panel = buildUI(thisObj);
|
||
refreshSelection();
|
||
populateProjectDropdown();
|
||
diagLog("panel v" + PANEL_VERSION + " loaded on " + getHostName());
|
||
reportPanelVersion();
|
||
|
||
if (panel instanceof Window) {
|
||
panel.center();
|
||
panel.show();
|
||
}
|
||
}(this));
|