a18dfc6203
Deploy / deploy (push) Successful in 6m19s
Production fallout after enabling it: the trimmed EXR footage started
after the head handle as intended, but the overlay's burn-in timecode
(hardcoded to start at frame 1001) did not shift with it, so the
delivered burn-in no longer matched the footage. The picture-lock
reference render also never worked correctly in practice. Reverts the
AE panel checkbox, the preview-build script's trim/reference logic, and
the server/worker plumbing around it back to their pre-feature state
(82dfaee) — untrimmed previews, no picture-lock reference, overlay
timecode correct again.
The noPictureLock/piclockRefKey schema columns and their migration are
deliberately left in place: they're already applied in production, and
dropping an applied migration's file risks the next `prisma migrate
deploy`. They sit unused and harmless until this is revisited.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4796 lines
171 KiB
React
4796 lines
171 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.5.0";
|
|
|
|
(function VFXReviewConnector(thisObj) {
|
|
var uiState = {
|
|
projectDropdown: null,
|
|
episodeDropdown: null,
|
|
shotDropdown: null,
|
|
episodeLabel: null,
|
|
selectionText: null,
|
|
lastActionText: null,
|
|
pipelineStatusText: null,
|
|
urgentCheckbox: null,
|
|
skipPreviewCheckbox: 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) : "";
|
|
}
|
|
|
|
// ── Per-project templates ─────────────────────────────────────────────────
|
|
//
|
|
// The server resolves these (convention from the show id, overridden per
|
|
// project where a template departs from it) and hands them over with the
|
|
// project list, so the panel and the headless preview build cannot disagree
|
|
// about which template — or which show LUT — a shot was built with.
|
|
|
|
function getTemplates() {
|
|
return (CURRENT_PROJECT && CURRENT_PROJECT.templates) ? CURRENT_PROJECT.templates : {};
|
|
}
|
|
|
|
function getOverlayCompName() {
|
|
return trimText(getTemplates().overlayComp) || (getShowId() ? getShowId() + "_VFX_OVERLAY" : "VFX_OVERLAY");
|
|
}
|
|
|
|
function getPreviewTemplateCompName() {
|
|
return trimText(getTemplates().previewComp) || (getShowId() ? getShowId() + "_EXPORT_TEMPLATE" : "EXPORT_TEMPLATE");
|
|
}
|
|
|
|
function getLutCompName() {
|
|
return trimText(getTemplates().lutComp) || "_SHOW LUT";
|
|
}
|
|
|
|
function getTemplateAepPath() {
|
|
return trimText(getTemplates().templateAep);
|
|
}
|
|
|
|
function getRendersRoot() {
|
|
return trimText(getTemplates().rendersRoot);
|
|
}
|
|
|
|
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, fps) {
|
|
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);
|
|
// Conform to the show's rate, not a hardcoded 24: conforming a 25 fps
|
|
// plate to 24 changes its duration and every frame-from-timecode sum
|
|
// that follows.
|
|
footage.mainSource.conformFrameRate = resolveFps(fps);
|
|
return footage;
|
|
}
|
|
|
|
/**
|
|
* The frame rate to build at. The shot record is the show's delivery rate
|
|
* and therefore the authority; a plate's own rate is what happens to be on
|
|
* disk, and where they differ the plate is the thing that is wrong.
|
|
*/
|
|
function resolveFps(fps) {
|
|
var value = Number(fps);
|
|
|
|
return (value && value > 0) ? value : 24;
|
|
}
|
|
|
|
function getShotFps(shot) {
|
|
return resolveFps(shot && shot.fps);
|
|
}
|
|
|
|
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 === getOverlayCompName()) {
|
|
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(getOverlayCompName());
|
|
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, getOverlayCompName());
|
|
disableLayerByName(comp, getLutCompName());
|
|
|
|
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(getOverlayCompName());
|
|
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, getOverlayCompName() + " 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(getPreviewTemplateCompName());
|
|
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;
|
|
var fps = 24;
|
|
|
|
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;
|
|
}
|
|
fps = getShotFps(apiData.shot);
|
|
diagLog("shot found: version " + (apiData.shot.shotVersion || "?") +
|
|
", episode " + (apiData.shot.episode || "-") + ", scene " + (apiData.shot.scene || "-") +
|
|
", tc " + (apiData.shot.timecodeStart || "-") + ", " + fps + " fps");
|
|
|
|
if (existingShotComp) {
|
|
diagLog("comp " + shotCode + " already exists — refreshing its overlay only");
|
|
if (!updateOverlayForComp(existingShotComp, shotCode, apiData)) {
|
|
diagLog("overlay refresh failed (non-fatal): " + getOverlayCompName() + " 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], fps);
|
|
} 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 * fps) + " frames at " + fps + " fps)");
|
|
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, fps);
|
|
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, fps);
|
|
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, fps);
|
|
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(getLutCompName());
|
|
if (showLut) {
|
|
lutLayer = shotComp.layers.add(showLut);
|
|
lutLayer.collapseTransformation = true;
|
|
} else {
|
|
diagLog("no " + getLutCompName() + " comp in this project — shot built without it");
|
|
}
|
|
|
|
if (!updateOverlayForComp(shotComp, shotCode, apiData)) {
|
|
diagLog("overlay not applied (non-fatal): " + getOverlayCompName() + " comp missing, or its DATE/SHOT NAME Essential Properties are not exposed");
|
|
}
|
|
diagLog("built comp " + shotComp.name + " (" + width + "x" + height + " @ " + fps + " fps, " +
|
|
Math.round(maxDuration * fps) + " 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 = [getOverlayCompName(), getPreviewTemplateCompName(), getLutCompName()];
|
|
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(getPreviewTemplateCompName());
|
|
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, getPreviewTemplateCompName() + " 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, getOverlayCompName());
|
|
disableLayerByName(comp, getLutCompName());
|
|
|
|
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 = getShotFps(shot);
|
|
} 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;
|
|
var renderFps;
|
|
|
|
if (comps.length < 1) {
|
|
updateStatus(0, "Please select or open a comp first.");
|
|
return;
|
|
}
|
|
|
|
comp = comps[0];
|
|
shotCode = getShotCodeFromComp(comp);
|
|
// The comp being imported into is already at the show's rate; conforming
|
|
// renders to anything else would resample them.
|
|
renderFps = resolveFps(comp.frameRate);
|
|
|
|
if (!getRendersRoot()) {
|
|
updateStatus(getSelectedComps().length,
|
|
"No renders root configured for " + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "this project") +
|
|
" — set it on the project's Pipeline settings in the web app.");
|
|
return;
|
|
}
|
|
rendersRoot = new Folder(getRendersRoot().replace(/\/+$/, "") + "/" + 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], renderFps);
|
|
if (footage) {
|
|
footage.name = subFolders[i].name;
|
|
footage.mainSource.conformFrameRate = renderFps;
|
|
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, renderFps);
|
|
if (footage) {
|
|
footage.name = shotCode + "_render";
|
|
footage.mainSource.conformFrameRate = renderFps;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Episode folder names for __SHOTS. The API is the authority; the footage
|
|
* root is the fallback for a show whose episodes have not been entered yet,
|
|
* so Initialise Workspace still produces something useful on day one.
|
|
*/
|
|
function getEpisodeCodesForWorkspace() {
|
|
var codes = [];
|
|
var episodes = getEpisodesFromAPI();
|
|
var label;
|
|
var i;
|
|
|
|
for (i = 0; i < episodes.length; i += 1) {
|
|
label = trimText(getEpisodeLabel(episodes[i]));
|
|
if (label) {
|
|
addUniqueText(codes, deriveEpisodeCode(label));
|
|
}
|
|
}
|
|
|
|
if (codes.length < 1) {
|
|
codes = getEpisodeCodesFromDisk();
|
|
diagLog("no episodes from the API — using " + codes.length + " folder(s) found under the footage root");
|
|
}
|
|
|
|
codes.sort();
|
|
return codes;
|
|
}
|
|
|
|
function initialiseWorkspace() {
|
|
var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"];
|
|
var templatePath = getTemplateAepPath();
|
|
var shotsFolder;
|
|
var episodes;
|
|
var templateFile;
|
|
var importOptions;
|
|
var importStatus;
|
|
var i;
|
|
|
|
if (!app.project) {
|
|
updateStatus(0, "No project open.");
|
|
return;
|
|
}
|
|
if (!CURRENT_PROJECT) {
|
|
updateStatus(0, "Select a project first — the workspace is built from its episodes and template.");
|
|
return;
|
|
}
|
|
|
|
app.beginUndoGroup("VFXReview Connector - Initialise Workspace");
|
|
|
|
// __SHOTS holds one folder per episode, taken from the project itself
|
|
// rather than a hardcoded range: a show's episode list is the server's
|
|
// to know, and standard (non-episodic) shows have none at all.
|
|
shotsFolder = findOrCreateFolder("__SHOTS", null);
|
|
episodes = isEpisodicProject() ? getEpisodeCodesForWorkspace() : [];
|
|
for (i = 0; i < episodes.length; i += 1) {
|
|
findOrCreateFolder(episodes[i], shotsFolder);
|
|
}
|
|
|
|
// Create remaining top-level folders
|
|
for (i = 0; i < topFolderNames.length; i += 1) {
|
|
findOrCreateFolder(topFolderNames[i], null);
|
|
}
|
|
|
|
// Import the show's own template AEP — its overlay, slate template and
|
|
// show LUT. Another show's would be quietly the wrong colour.
|
|
importStatus = "";
|
|
if (!templatePath) {
|
|
importStatus = " No template AEP configured for " + CURRENT_PROJECT.code +
|
|
" — set it on the project's Pipeline settings in the web app.";
|
|
templateFile = null;
|
|
} else {
|
|
templateFile = new File(templatePath);
|
|
}
|
|
if (templateFile && 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 for " + CURRENT_PROJECT.code +
|
|
(episodes.length ? " (" + episodes.length + " episode folder(s))" : "") +
|
|
"." + 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 the show 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
|
|
};
|
|
|
|
// Renders the EXRs and inherits the previous export's review media
|
|
// rather than rebuilding a preview from frames that are not the review
|
|
// framing (unscaled / un-punched-in deliverables).
|
|
if (uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) {
|
|
body.skipPreview = true;
|
|
}
|
|
|
|
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)" : "") +
|
|
((uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) ? " (no preview)" : "") +
|
|
" 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");
|
|
uiState.skipPreviewCheckbox = pipelineRow1.add("checkbox", undefined, "No preview");
|
|
uiState.skipPreviewCheckbox.helpTip =
|
|
"Render the EXRs but build no preview, reusing the previous export's\n" +
|
|
"MOV and review media.\n\n" +
|
|
"For deliverables framed differently from the review — unscaled or\n" +
|
|
"un-punched-in EXRs — where a preview rebuilt from these frames\n" +
|
|
"would be the wrong picture.";
|
|
|
|
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));
|