991abe072c
Deploy / deploy (push) Successful in 6m12s
UNG_110_030_010 v002 failed with a 461614-frame offset on a render whose timecode was correct. Two causes, both in how the plate reference was recorded. The comp held three same-length elements and the timing plate was picked by duration alone, so a PRERENDER won on layer order. Using a pre-render as the timecode reference is circular anyway: it was rendered from this comp, so it can only ever agree with it — including when both are wrong. Only footage under the Footage Root is now eligible; among those, longest still wins. The offset itself came from the plate's frameStart. AE writes renders as "..._PRERENDER_01001.exr" while delivered plates use "....01001.exr", and the parser only handled the dot form, so the number fell back to the comp's frame range — 462615 against a render numbered from 1001. Both separators now parse, and an unparseable name omits the field rather than filling it with a number from the wrong coordinate system. Verified against the real manifest: the BG01 plate is now chosen over both pre-renders, and all three filenames parse to 1001. Panel 2.2.1, worker 1.2.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4675 lines
166 KiB
React
4675 lines
166 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: "",
|
||
// The number the first frame of every rendered sequence carries, set by the
|
||
// output module template. House convention is 1001, and it is deliberately
|
||
// independent of the comp's start frame — comps are anchored to the plate's
|
||
// timecode, so their frame numbers run into the millions.
|
||
sequenceStart: 1001
|
||
};
|
||
|
||
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.2.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;
|
||
}
|
||
|
||
// The production name, burned into both the slate and the overlay. The
|
||
// slate's fields are title case ("Date", "Scene") and the overlay's are
|
||
// upper ("DATE", "SHOT NAME"), so both spellings are attempted rather than
|
||
// assuming which template uses which. Mirrors setShowField in the worker's
|
||
// vfxr_build_preview.jsx so panel- and farm-built previews match.
|
||
function setShowProperty(layer, label) {
|
||
var value = getShowName();
|
||
var candidates = ["SHOW", "Show"];
|
||
var i;
|
||
|
||
if (!value) {
|
||
return false;
|
||
}
|
||
for (i = 0; i < candidates.length; i += 1) {
|
||
if (setEssentialProperty(layer, candidates[i], value)) {
|
||
return true;
|
||
}
|
||
}
|
||
diagLog(label + ": no Essential Property named SHOW or Show on this layer");
|
||
return false;
|
||
}
|
||
|
||
// The production's own name, not its code: this is read off a slate.
|
||
function getShowName() {
|
||
if (!CURRENT_PROJECT) {
|
||
return "";
|
||
}
|
||
return trimText(CURRENT_PROJECT.name || CURRENT_PROJECT.code || "");
|
||
}
|
||
|
||
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"));
|
||
setShowProperty(overlayLayer, "Overlay SHOW");
|
||
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"));
|
||
setShowProperty(overlayLayer, "Overlay SHOW");
|
||
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);
|
||
setShowProperty(slateLayer, "Slate SHOW");
|
||
|
||
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 fSeqStart = addField(dlg, "Sequence Start:", String(CONFIG.sequenceStart));
|
||
fSeqStart.helpTip =
|
||
"The frame number the first file of every rendered sequence carries,\n" +
|
||
"set by the output module template (house convention: 1001).\n" +
|
||
"Not the comp's start frame — comps are anchored to the plate's timecode.";
|
||
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);
|
||
CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001;
|
||
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.
|
||
//
|
||
// Delivered plates win over anything else in the comp: a shot routinely
|
||
// contains pre-renders and CG passes of exactly the same length, and using
|
||
// one of those as the timecode reference is circular — it was rendered from
|
||
// this comp, so it can only ever agree with it, including when both are
|
||
// wrong. Only footage living under the configured Footage Root is camera
|
||
// material. Among equals, longest duration wins, as buildShotFromCode does
|
||
// when it picks primaryFootage.
|
||
function pickTimingPlate(plates) {
|
||
var root = normalizeSlashes(trimText(CONFIG.footageRoot)).toLowerCase();
|
||
var delivered = [];
|
||
var i;
|
||
|
||
if (root) {
|
||
for (i = 0; i < plates.length; i += 1) {
|
||
if (normalizeSlashes(plates[i].path).toLowerCase().indexOf(root) === 0) {
|
||
delivered.push(plates[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return pickLongest(delivered.length ? delivered : plates);
|
||
}
|
||
|
||
function pickLongest(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) {
|
||
var ref = { element: plate.element, path: plate.path };
|
||
var parsed = parseFrameNumberFromPath(plate.path);
|
||
|
||
// Only ever the plate's *own* file numbering. When the name carries no
|
||
// frame number the field is left out entirely rather than filled with
|
||
// the comp's frame range — those are different coordinate systems, and
|
||
// a wrong number here silently skews the timecode comparison by the
|
||
// difference between them.
|
||
if (parsed !== null) {
|
||
ref.frameStart = parsed;
|
||
ref.frameEnd = parsed + Math.max(0, plate.frames - 1);
|
||
}
|
||
return ref;
|
||
}
|
||
|
||
// "..._BG01_TT_v001.01001.exr" -> 1001, and "..._PRERENDER_01001.exr" -> 1001:
|
||
// renders written by AE separate the frame number with an underscore rather
|
||
// than a dot, and both turn up as layers in a shot comp.
|
||
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,
|
||
// What the rendered files are actually named, which the comp's frame
|
||
// range is not: the comp is anchored to the plate's timecode.
|
||
outputFrameStart: parseInt(CONFIG.sequenceStart, 10) || 1001,
|
||
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),
|
||
plates: [],
|
||
handleFrames: CONFIG.handleFrames
|
||
};
|
||
for (i = 0; i < plates.length; i += 1) {
|
||
manifest.source.plates.push(toManifestPlate(plates[i]));
|
||
}
|
||
// 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 + " (" + timingPlate.path + ")");
|
||
} 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));
|