a3859aa61b
Build QC Review Session (QC section) makes a QC_{shot}_{version} comp
for every shot in the project's QC queue. The export sits over its
earlier versions and plates, and only the export and the main plate are
switched on. Each scene also gets a continuous edit, cut to the edit
with the handles trimmed, and a contact sheet. QC Pass/Fail now act on
the shot comp in the viewer, the selected shot in a scene comp, or the
shot under the playhead in a scene edit, and label the result
green/red. The QC queue now also returns seqTimecodeStart, handleHead
and handleTail.
Compare Versions now lines versions up by EXR timecode, then by file
frame number. It used to use Export.frameStart, which is
timecode-anchored, not 1001-based.
Also includes Keep AEP (panel 2.9.0): Queue Export saves a reduced
.aep copy in each export's version folder.
Panel 2.10.0. Not yet run in After Effects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6299 lines
229 KiB
React
6299 lines
229 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,
|
|
// Queue Export saves a reduced copy of the project next to each export's
|
|
// EXRs. Off skips it (and the project reopen it costs).
|
|
saveExportAep: true
|
|
};
|
|
|
|
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.10.0";
|
|
|
|
(function VFXReviewConnector(thisObj) {
|
|
var uiState = {
|
|
projectDropdown: null,
|
|
episodeDropdown: null,
|
|
shotDropdown: null,
|
|
episodeLabel: null,
|
|
selectionText: null,
|
|
lastActionText: null,
|
|
pipelineStatusText: null,
|
|
urgentCheckbox: null,
|
|
skipPreviewCheckbox: null,
|
|
saveAepCheckbox: null,
|
|
handlesCheckbox: null,
|
|
handleFramesField: 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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Assigning displayStartFrame on a version of AE that does not have the
|
|
// property is ignored silently rather than throwing, which is one of the
|
|
// ways a comp ends up back at 00:00:00:00. Read the value back and fall
|
|
// through to displayStartTime when it did not take, and tell the caller
|
|
// whether anything stuck.
|
|
function setCompStartFrame(comp, frameNumber) {
|
|
var wanted = Math.round(frameNumber);
|
|
|
|
try {
|
|
comp.displayStartFrame = frameNumber;
|
|
if (Math.round(comp.displayStartFrame) === wanted) {
|
|
return true;
|
|
}
|
|
} catch (frameError) {
|
|
}
|
|
|
|
try {
|
|
comp.displayStartTime = frameNumber / comp.frameRate;
|
|
return Math.round(comp.displayStartTime * comp.frameRate) === wanted;
|
|
} catch (timeError) {
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 first file of an imported sequence: the frame whose header carries the
|
|
// plate's start timecode. importEXRSequence sorts before importing, so this
|
|
// is the sequence's first frame and not an arbitrary one.
|
|
function getFootageFirstFile(footageItem) {
|
|
try {
|
|
if (footageItem && footageItem.mainSource && footageItem.mainSource.file) {
|
|
return footageItem.mainSource.file;
|
|
}
|
|
} catch (fileError) {
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readUInt32LE(buf, offset) {
|
|
return (buf.charCodeAt(offset) & 0xFF) +
|
|
(buf.charCodeAt(offset + 1) & 0xFF) * 256 +
|
|
(buf.charCodeAt(offset + 2) & 0xFF) * 65536 +
|
|
(buf.charCodeAt(offset + 3) & 0xFF) * 16777216;
|
|
}
|
|
|
|
function readInt32LE(buf, offset) {
|
|
var value = readUInt32LE(buf, offset);
|
|
|
|
return value >= 2147483648 ? value - 4294967296 : value;
|
|
}
|
|
|
|
// A null-terminated header string. Returns null when the terminator is not
|
|
// found within a sane distance, which means the bytes are not a header we
|
|
// understand and parsing should stop rather than guess.
|
|
function readExrString(buf, offset) {
|
|
var end = offset;
|
|
var limit = Math.min(buf.length, offset + 256);
|
|
|
|
while (end < limit && buf.charCodeAt(end) !== 0) {
|
|
end += 1;
|
|
}
|
|
|
|
if (end >= limit) {
|
|
return null;
|
|
}
|
|
|
|
return buf.substring(offset, end);
|
|
}
|
|
|
|
function pad2(value) {
|
|
return (value < 10 ? "0" : "") + value;
|
|
}
|
|
|
|
function makeTimecode(hours, minutes, seconds, frames, dropFrame) {
|
|
if (hours > 23 || minutes > 59 || seconds > 59) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
hours: hours,
|
|
minutes: minutes,
|
|
seconds: seconds,
|
|
frames: frames,
|
|
dropFrame: dropFrame,
|
|
text: pad2(hours) + ":" + pad2(minutes) + ":" + pad2(seconds) +
|
|
(dropFrame ? ";" : ":") + pad2(frames)
|
|
};
|
|
}
|
|
|
|
// SMPTE 12M as stored in the EXR timeCode attribute: BCD-packed, which is
|
|
// why the raw value reads back as its own hex digits (0x18585712 is
|
|
// 18:58:57:12). Every field sits inside a single byte, so the nibbles can be
|
|
// picked off per byte and never touch the sign bit.
|
|
function decodePackedTimecode(buf, offset) {
|
|
var b0 = buf.charCodeAt(offset) & 0xFF; // frames, drop-frame flag
|
|
var b1 = buf.charCodeAt(offset + 1) & 0xFF; // seconds
|
|
var b2 = buf.charCodeAt(offset + 2) & 0xFF; // minutes
|
|
var b3 = buf.charCodeAt(offset + 3) & 0xFF; // hours
|
|
|
|
return makeTimecode(
|
|
(b3 & 0x0F) + ((b3 >> 4) & 0x03) * 10,
|
|
(b2 & 0x0F) + ((b2 >> 4) & 0x07) * 10,
|
|
(b1 & 0x0F) + ((b1 >> 4) & 0x07) * 10,
|
|
(b0 & 0x0F) + ((b0 >> 4) & 0x03) * 10,
|
|
((b0 >> 6) & 0x01) === 1);
|
|
}
|
|
|
|
function parseTimecodeText(text) {
|
|
var parts = String(text).split(/[:;]/);
|
|
|
|
if (parts.length < 4) {
|
|
return null;
|
|
}
|
|
|
|
return makeTimecode(
|
|
parseInt(parts[0], 10) || 0,
|
|
parseInt(parts[1], 10) || 0,
|
|
parseInt(parts[2], 10) || 0,
|
|
parseInt(parts[3], 10) || 0,
|
|
String(text).indexOf(";") !== -1);
|
|
}
|
|
|
|
// Reads the plate's timecode straight out of the OpenEXR header, the way the
|
|
// worker's ExrHeader.cs does, so the panel anchors comps on exactly the
|
|
// value validation later checks them against. Header layout is
|
|
// magic(4) version(4) then attributes as `name\0 type\0 int32 size data`,
|
|
// terminated by an empty name — one short read, no pixel decode.
|
|
//
|
|
// Returns null when the file is unreadable or carries no timecode at all.
|
|
function readExrTimecode(file) {
|
|
var EXR_HEADER_BYTES = 65536;
|
|
var EXR_MAGIC = 20000630;
|
|
var buf = null;
|
|
var opened = false;
|
|
var pos = 8;
|
|
var packed = null;
|
|
var text = null;
|
|
var name;
|
|
var type;
|
|
var size;
|
|
|
|
if (!file || !file.exists) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
file.encoding = "BINARY";
|
|
opened = file.open("r");
|
|
if (opened) {
|
|
buf = file.read(EXR_HEADER_BYTES);
|
|
}
|
|
} catch (readError) {
|
|
buf = null;
|
|
}
|
|
|
|
try {
|
|
if (opened) {
|
|
file.close();
|
|
}
|
|
} catch (closeError) {
|
|
}
|
|
|
|
if (!buf || buf.length < 16 || readUInt32LE(buf, 0) !== EXR_MAGIC) {
|
|
return null;
|
|
}
|
|
|
|
while (pos < buf.length) {
|
|
name = readExrString(buf, pos);
|
|
if (name === null) {
|
|
break;
|
|
}
|
|
pos += name.length + 1;
|
|
if (name.length === 0) {
|
|
break; // an empty name ends the header
|
|
}
|
|
|
|
type = readExrString(buf, pos);
|
|
if (type === null) {
|
|
break;
|
|
}
|
|
pos += type.length + 1;
|
|
|
|
if (pos + 4 > buf.length) {
|
|
break;
|
|
}
|
|
size = readInt32LE(buf, pos);
|
|
pos += 4;
|
|
if (size < 0 || pos + size > buf.length) {
|
|
break;
|
|
}
|
|
|
|
if (name === "timeCode" && size >= 8) {
|
|
packed = decodePackedTimecode(buf, pos);
|
|
} else if (name === "timeCodeString" && size >= 11) {
|
|
text = parseTimecodeText(buf.substr(pos, size).replace(/\0+$/, ""));
|
|
}
|
|
|
|
pos += size;
|
|
}
|
|
|
|
// The packed attribute is the authoritative one; timeCodeString is a
|
|
// convenience copy that some writers emit and others omit.
|
|
return packed || text;
|
|
}
|
|
|
|
// Non-drop frame count, mirroring Timecode.ToFrames in the worker. The show
|
|
// rate is the authority — a plate's own rate is whatever happens to be on
|
|
// disk, and plates are conformed to the show rate on import.
|
|
function timecodeToFrames(tc, fps) {
|
|
var rate = Math.round(resolveFps(fps));
|
|
|
|
return ((tc.hours * 60 + tc.minutes) * 60 + tc.seconds) * rate + tc.frames;
|
|
}
|
|
|
|
// The plate's own start timecode, ranked the way validation ranks it
|
|
// (RenderPipeline2 §8.1a): the EXR header's embedded timecode first, then
|
|
// AE's own read of the footage metadata, and only then the API's source clip
|
|
// timecode less the handle frames — the convention the Set Start Timecode
|
|
// button uses. The API value is the editorial in-point and sits a
|
|
// handle-length ahead of the plate by design, so it is the last resort
|
|
// rather than the first.
|
|
function getFootageStartFrames(footageItem, apiShot, fps) {
|
|
var exrTc = readExrTimecode(getFootageFirstFile(footageItem));
|
|
var probed;
|
|
var frames;
|
|
|
|
if (exrTc) {
|
|
frames = timecodeToFrames(exrTc, fps);
|
|
if (frames > 0) {
|
|
return {
|
|
frames: frames,
|
|
source: "EXR timecode " + exrTc.text +
|
|
(exrTc.dropFrame ? " (drop-frame flag set on the plate)" : "")
|
|
};
|
|
}
|
|
}
|
|
|
|
probed = probeFootageStartFrames(footageItem, fps);
|
|
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: (exrTc || probed === 0) ? "the plate 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);
|
|
if (!setCompStartFrame(footageComp, startInfo.frames)) {
|
|
diagLog("could not set the start frame on " + footageComp.name + " - it will read 00:00:00:00");
|
|
}
|
|
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);
|
|
if (!setCompStartFrame(shotComp, startInfo.frames)) {
|
|
diagLog("could not set the start frame on " + shotComp.name + " - it will read 00:00:00:00");
|
|
}
|
|
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;
|
|
if (uiState.handleFramesField) {
|
|
uiState.handleFramesField.text = String(CONFIG.handleFrames);
|
|
}
|
|
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 || "";
|
|
}
|
|
}
|
|
|
|
// A scheduled task outlives the panel instance that armed it. Held only in
|
|
// this closure, the id of a repeating task goes with the instance when the
|
|
// panel is closed or reloaded, and nothing can cancel it again: it fires
|
|
// every 15 s for the rest of the AE session, and every tick that lands
|
|
// while a modal dialog is open raises "Cannot run a script while a modal
|
|
// dialog is waiting for response." The id therefore lives beside the tick
|
|
// in global scope, where the next instance can still reach it.
|
|
var POLL_TASK_ID_KEY = "__vfxrExportPollTaskId";
|
|
|
|
function getPollTaskId() {
|
|
var taskId = $.global[POLL_TASK_ID_KEY];
|
|
|
|
return (typeof taskId === "number" && taskId > 0) ? taskId : 0;
|
|
}
|
|
|
|
function setPollTaskId(taskId) {
|
|
$.global[POLL_TASK_ID_KEY] = taskId || 0;
|
|
}
|
|
|
|
function stopExportPolling() {
|
|
var taskId = getPollTaskId();
|
|
|
|
if (taskId) {
|
|
try {
|
|
app.cancelTask(taskId);
|
|
} catch (cancelError) {
|
|
}
|
|
setPollTaskId(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 {
|
|
setPollTaskId(app.scheduleTask("__vfxrExportPollTick()", 15000, true));
|
|
} catch (scheduleError) {
|
|
setPollTaskId(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;
|
|
}
|
|
|
|
// Handles the render carries past the cut. Review metadata only: the web
|
|
// player marks these frames as "not in edit". Nothing in the comp or the
|
|
// render changes — unticked just means the frames are all in the edit.
|
|
function reviewHandles() {
|
|
var frames;
|
|
if (!uiState.handlesCheckbox || !uiState.handlesCheckbox.value) {
|
|
return { handleHead: 0, handleTail: 0 };
|
|
}
|
|
frames = parseInt(uiState.handleFramesField ? uiState.handleFramesField.text : "", 10);
|
|
if (isNaN(frames) || frames < 0) {
|
|
frames = CONFIG.handleFrames;
|
|
}
|
|
return { handleHead: frames, handleTail: frames };
|
|
}
|
|
|
|
function submitExport(manifest, urgent, force) {
|
|
manifest.review = reviewHandles();
|
|
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);
|
|
}
|
|
|
|
// ── Export AEP snapshots ──────────────────────────────────────────────────
|
|
//
|
|
// Each export keeps a copy of the project it was queued from, so the exact
|
|
// comp behind a render can be reopened later. The copy is reduced to the
|
|
// one comp and what it uses. Footage stays where it is: plates and EXRs are
|
|
// referenced by path, never copied, so the file is a few MB at most.
|
|
//
|
|
// It sits in the export's version folder as {render base}.aep. The worker
|
|
// only touches *.exr files there, so it is never deleted or counted.
|
|
|
|
// The version folder on this machine. Usually the worker has not created it
|
|
// yet, so this falls back to the shot folder and then to the panel's own
|
|
// export layout.
|
|
function exportSnapshotFolder(exportInfo, shot) {
|
|
var serverDir = normalizeSlashes(exportInfo.outputDir || "").replace(/\/+$/, "");
|
|
var resolved;
|
|
var parentDir;
|
|
|
|
if (serverDir) {
|
|
resolved = resolveServerPath(serverDir);
|
|
if (resolved.path) {
|
|
return resolved.path;
|
|
}
|
|
parentDir = serverDir.substring(0, serverDir.lastIndexOf("/"));
|
|
if (parentDir) {
|
|
resolved = resolveServerPath(parentDir);
|
|
if (resolved.path) {
|
|
return resolved.path + "/" + exportInfo.versionString;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!trimText(CONFIG.exportRoot)) {
|
|
return null;
|
|
}
|
|
return getShotExportDir({
|
|
shotCode: shot.shotCode,
|
|
episode: shot.episode,
|
|
shotVersion: exportInfo.versionString
|
|
});
|
|
}
|
|
|
|
function exportSnapshotFile(exportInfo, shot) {
|
|
var folder = exportSnapshotFolder(exportInfo, shot);
|
|
var base = String(exportInfo.outputPattern || "").replace(/\.\[#+\]\.[A-Za-z0-9]+$/, "");
|
|
|
|
if (!folder || !ensureFolder(folder)) {
|
|
return null;
|
|
}
|
|
if (!base || base === exportInfo.outputPattern) {
|
|
base = shot.shotCode + "_" + exportInfo.versionString;
|
|
}
|
|
return new File(folder + "/" + base + ".aep");
|
|
}
|
|
|
|
function sameFile(a, b) {
|
|
return !!(a && b && normalizeSlashes(a.fsName).toLowerCase() === normalizeSlashes(b.fsName).toLowerCase());
|
|
}
|
|
|
|
// Saves a reduced copy of the project for each queued export, then reopens
|
|
// the original. The project must already be saved: the original file is
|
|
// never written here. AE can only reduce the open project, so each copy is
|
|
// made by Save As, Reduce Project and Save, which leaves the copy open.
|
|
// That is why the original is reopened at the end, which also clears undo.
|
|
//
|
|
// snapshots: [{ compId, compName, shot, exportInfo }]. Returns
|
|
// { saved: [paths], failed: ["v004: reason"] }.
|
|
function saveExportSnapshots(snapshots) {
|
|
var original = app.project.file;
|
|
var result = { saved: [], failed: [] };
|
|
var i;
|
|
var job;
|
|
var target;
|
|
var comp;
|
|
|
|
function backToOriginal() {
|
|
if (sameFile(app.project.file, original)) {
|
|
return;
|
|
}
|
|
app.project.close(CloseOptions.DO_NOT_SAVE_CHANGES);
|
|
app.open(original);
|
|
}
|
|
|
|
if (!snapshots.length || !original) {
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
for (i = 0; i < snapshots.length; i += 1) {
|
|
job = snapshots[i];
|
|
target = exportSnapshotFile(job.exportInfo, job.shot);
|
|
if (!target) {
|
|
result.failed.push(job.exportInfo.versionString + ": no folder for the AEP (check Export Root)");
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
backToOriginal();
|
|
updateStatus(snapshots.length, "Saving AEP for " + job.shot.shotCode + " " +
|
|
job.exportInfo.versionString + "...");
|
|
app.project.save(target);
|
|
if (!sameFile(app.project.file, target)) {
|
|
throw new Error("Save As did not take");
|
|
}
|
|
|
|
comp = null;
|
|
try {
|
|
comp = app.project.itemByID(job.compId);
|
|
} catch (idError) {
|
|
comp = null;
|
|
}
|
|
if (!(comp instanceof CompItem) || comp.name !== job.compName) {
|
|
comp = findComp(job.compName);
|
|
}
|
|
if (!comp) {
|
|
throw new Error("comp " + job.compName + " not found in the copy");
|
|
}
|
|
|
|
app.project.reduceProject([comp]);
|
|
app.project.save();
|
|
result.saved.push(normalizeSlashes(target.fsName));
|
|
diagLog("export AEP saved: " + target.fsName);
|
|
} catch (snapshotError) {
|
|
result.failed.push(job.exportInfo.versionString + ": " + describeError(snapshotError));
|
|
diagLog("export AEP failed for " + job.compName + ": " + describeError(snapshotError));
|
|
}
|
|
}
|
|
} finally {
|
|
backToOriginal();
|
|
}
|
|
|
|
comp = findComp(snapshots[0].compName);
|
|
if (comp) {
|
|
comp.openInViewer();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
var snapshots = [];
|
|
var snapshotResult = null;
|
|
var snapshotText = "";
|
|
|
|
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 rendered EXRs must be clean, linear and free of burn-in — the
|
|
// show LUT and overlay are reference-only, for the artist's own
|
|
// viewing while compositing. The legacy manual "Queue EXR" button has
|
|
// always disabled them before rendering (see queueEXR()); Queue Export
|
|
// does the same here so the pipeline's EXR output matches.
|
|
app.beginUndoGroup("VFXReview Connector - Queue Export (disable LUT/overlay)");
|
|
try {
|
|
for (i = 0; i < comps.length; i += 1) {
|
|
comp = getMainCompForSelection(comps[i]);
|
|
if (!comp) {
|
|
continue;
|
|
}
|
|
disableLayerByName(comp, getOverlayCompName());
|
|
disableLayerByName(comp, getLutCompName());
|
|
}
|
|
} finally {
|
|
app.endUndoGroup();
|
|
}
|
|
|
|
// 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;
|
|
snapshots.push({
|
|
compId: comp.id,
|
|
compName: comp.name,
|
|
shot: data.shot,
|
|
exportInfo: response.data.export
|
|
});
|
|
} else {
|
|
errorText = (response.data && response.data.error) ?
|
|
response.data.error : ("HTTP " + response.status);
|
|
addFailure(result, comp.name, errorText);
|
|
}
|
|
}
|
|
|
|
// After every submit, because each copy reopens the project and the
|
|
// comps above stop being valid.
|
|
if (snapshots.length && CONFIG.saveExportAep !== false) {
|
|
try {
|
|
snapshotResult = saveExportSnapshots(snapshots);
|
|
} catch (snapshotError) {
|
|
snapshotResult = { saved: [], failed: [describeError(snapshotError)] };
|
|
}
|
|
if (snapshotResult.failed.length) {
|
|
snapshotText = " AEP not saved: " + snapshotResult.failed.join("; ") + ".";
|
|
} else if (snapshotResult.saved.length) {
|
|
snapshotText = " AEP saved.";
|
|
}
|
|
}
|
|
|
|
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." + snapshotText);
|
|
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.
|
|
|
|
// The queue endpoint defaults to 50 rows and refuses anything above 200, so
|
|
// ask for the ceiling: a busy project can have well over 50 exports sitting
|
|
// in READY_FOR_QC, and the panel silently showed only the oldest 50.
|
|
var QC_QUEUE_LIMIT = 200;
|
|
|
|
function loadQCQueue() {
|
|
var url = CONFIG.baseUrl + "/api/ext/qc/queue?limit=" + QC_QUEUE_LIMIT;
|
|
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" +
|
|
(entries.length >= QC_QUEUE_LIMIT ? " (list is capped — QC some and reload)" : ""));
|
|
} 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. quiet builds the
|
|
// comp without opening it, for the QC session's batch.
|
|
function importQCSequence(entry, quiet) {
|
|
// 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) {
|
|
if (!parseQCTag(existing.comment)) {
|
|
existing.comment = qcTag(entry);
|
|
}
|
|
if (!quiet) {
|
|
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);
|
|
// QC Pass/Fail read the export from here when this comp is open.
|
|
comp.comment = qcTag(entry);
|
|
// Named by version so Compare Versions can stack earlier ones under it.
|
|
comp.layers.add(footage).name = entry.versionString;
|
|
if (!quiet) {
|
|
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();
|
|
}
|
|
|
|
// ── QC: compare with earlier versions ─────────────────────────────────────
|
|
//
|
|
// Earlier exports of the shot are found on disk around this one, in either
|
|
// layout: farm version folders ({shot}/{ver}/*.exr) or the older flat
|
|
// folder ({shot}/{shot}_cmp_TT_{ver}.#####.exr). Each is stacked under the
|
|
// current version in the QC comp, lined up by frame number, so soloing a
|
|
// layer or setting the top one to Difference compares them directly.
|
|
|
|
// 3 from "v003", "UNG_108_001_010_cmp_TT_v003." or a "v003" folder.
|
|
function qcVersionNumber(text) {
|
|
var match = /(?:^|[^A-Za-z0-9])v(\d{1,4})(?![0-9])/i.exec(String(text || ""));
|
|
|
|
return match ? parseInt(match[1], 10) : null;
|
|
}
|
|
|
|
// EXR sequences in a folder, grouped by name prefix (the part before the
|
|
// frame number). first is the lowest frame number, or null.
|
|
function qcSequencesIn(folder) {
|
|
var files = folder.getFiles("*.exr");
|
|
var groups = {};
|
|
var list = [];
|
|
var i;
|
|
var name;
|
|
var match;
|
|
var key;
|
|
var frame;
|
|
|
|
for (i = 0; files && i < files.length; i += 1) {
|
|
if (!(files[i] instanceof File)) {
|
|
continue;
|
|
}
|
|
name = decodeURI(files[i].name);
|
|
match = /^(.*?)(\d+)\.exr$/i.exec(name);
|
|
key = match ? match[1] : name;
|
|
if (!groups[key]) {
|
|
groups[key] = { prefix: key, folder: folder, files: [], first: null };
|
|
list.push(groups[key]);
|
|
}
|
|
groups[key].files.push(files[i]);
|
|
if (match) {
|
|
frame = parseInt(match[2], 10);
|
|
if (groups[key].first === null || frame < groups[key].first) {
|
|
groups[key].first = frame;
|
|
}
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function findPreviousQCVersions(entry, resolvedPath) {
|
|
var current = qcVersionNumber(entry.versionString);
|
|
var here = new Folder(resolvedPath);
|
|
var shotFolder = /^v\d+$/i.test(decodeURI(here.name)) ? here.parent : here;
|
|
var byVersion = {};
|
|
var found = [];
|
|
var subFolders;
|
|
var seqs;
|
|
var version;
|
|
var i;
|
|
var j;
|
|
var key;
|
|
|
|
function consider(seq, number) {
|
|
var best;
|
|
|
|
if (number === null || number >= current) {
|
|
return;
|
|
}
|
|
best = byVersion[number];
|
|
// Several sequences for one version (passes, a stray re-render):
|
|
// prefer the one named after the shot, then the longest.
|
|
if (!best ||
|
|
(seq.prefix.indexOf(entry.shotCode) === 0) > (best.prefix.indexOf(entry.shotCode) === 0) ||
|
|
((seq.prefix.indexOf(entry.shotCode) === 0) === (best.prefix.indexOf(entry.shotCode) === 0) &&
|
|
seq.files.length > best.files.length)) {
|
|
seq.version = number;
|
|
byVersion[number] = seq;
|
|
}
|
|
}
|
|
|
|
if (current === null) {
|
|
return [];
|
|
}
|
|
|
|
// Flat layout: every version side by side in the shot folder.
|
|
seqs = qcSequencesIn(shotFolder);
|
|
for (i = 0; i < seqs.length; i += 1) {
|
|
consider(seqs[i], qcVersionNumber(seqs[i].prefix));
|
|
}
|
|
|
|
// Farm layout: one folder per version.
|
|
subFolders = shotFolder.getFiles(function (f) {
|
|
return f instanceof Folder && /^v\d+$/i.test(decodeURI(f.name));
|
|
});
|
|
for (i = 0; subFolders && i < subFolders.length; i += 1) {
|
|
version = qcVersionNumber(decodeURI(subFolders[i].name));
|
|
seqs = qcSequencesIn(subFolders[i]);
|
|
for (j = 0; j < seqs.length; j += 1) {
|
|
consider(seqs[j], version);
|
|
}
|
|
}
|
|
|
|
for (key in byVersion) {
|
|
if (byVersion.hasOwnProperty(key)) {
|
|
found.push(byVersion[key]);
|
|
}
|
|
}
|
|
found.sort(function (a, b) { return b.version - a.version; });
|
|
return found;
|
|
}
|
|
|
|
function qcVersionLabel(seq) {
|
|
var text = String(seq.version);
|
|
|
|
while (text.length < 3) {
|
|
text = "0" + text;
|
|
}
|
|
return "v" + text;
|
|
}
|
|
|
|
// Lets the user untick versions they don't want. Newest first, all ticked.
|
|
function chooseQCVersions(entry, found) {
|
|
var dialog = new Window("dialog", "Compare " + entry.shotCode + " " + entry.versionString);
|
|
var boxes = [];
|
|
var buttons;
|
|
var chosen = [];
|
|
var i;
|
|
|
|
dialog.orientation = "column";
|
|
dialog.alignChildren = ["fill", "top"];
|
|
dialog.add("statictext", undefined, "Stack these earlier versions under " + entry.versionString + ":");
|
|
for (i = 0; i < found.length; i += 1) {
|
|
boxes.push(dialog.add("checkbox", undefined, qcVersionLabel(found[i]) + " " +
|
|
found[i].files.length + " frames " + decodeURI(found[i].folder.name)));
|
|
boxes[i].value = true;
|
|
}
|
|
buttons = dialog.add("group");
|
|
buttons.alignment = ["right", "top"];
|
|
buttons.add("button", undefined, "Import", { name: "ok" });
|
|
buttons.add("button", undefined, "Cancel", { name: "cancel" });
|
|
|
|
if (dialog.show() !== 1) {
|
|
return null;
|
|
}
|
|
for (i = 0; i < found.length; i += 1) {
|
|
if (boxes[i].value) {
|
|
chosen.push(found[i]);
|
|
}
|
|
}
|
|
return chosen;
|
|
}
|
|
|
|
function findLayerByName(comp, name) {
|
|
var i;
|
|
|
|
for (i = 1; i <= comp.numLayers; i += 1) {
|
|
if (comp.layer(i).name === name) {
|
|
return comp.layer(i);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compareQCVersions() {
|
|
var entry = getSelectedQCEntry();
|
|
var compName;
|
|
var comp;
|
|
var resolved;
|
|
var found;
|
|
var chosen;
|
|
var fps;
|
|
var added = [];
|
|
var skipped = [];
|
|
var i;
|
|
var label;
|
|
var importOptions;
|
|
var footage;
|
|
var layer;
|
|
|
|
if (!entry) {
|
|
setQCStatus("Select a queue entry first");
|
|
return;
|
|
}
|
|
if (qcVersionNumber(entry.versionString) === null) {
|
|
setQCStatus("Can't tell which version " + entry.versionString + " is, so can't find earlier ones");
|
|
return;
|
|
}
|
|
|
|
compName = "QC_" + entry.shotCode + "_" + entry.versionString;
|
|
fps = entry.fps || 24;
|
|
|
|
setQCStatus("Looking for earlier versions of " + entry.shotCode + "...");
|
|
app.beginUndoGroup("VFXReview Connector - QC Compare Versions");
|
|
try {
|
|
// Opens the QC comp, building it first if needed.
|
|
if (!importQCSequence(entry)) {
|
|
return;
|
|
}
|
|
comp = findComp(compName);
|
|
resolved = resolveServerPath(entry.outputDir);
|
|
if (!comp || !resolved.path) {
|
|
setQCStatus("Render folder not found for " + entry.shotCode + " — see the report");
|
|
diagFail("QC compare", "could not resolve " + entry.outputDir + " on this machine", resolved.tried);
|
|
return;
|
|
}
|
|
|
|
found = findPreviousQCVersions(entry, resolved.path);
|
|
if (!found.length) {
|
|
setQCStatus("No earlier versions of " + entry.shotCode + " found next to " + resolved.path);
|
|
diagLog("QC compare: looked for EXR sequences older than " + entry.versionString +
|
|
" in " + resolved.path + ", its parent, and the parent's v### folders");
|
|
return;
|
|
}
|
|
|
|
chosen = found.length > 1 ? chooseQCVersions(entry, found) : found;
|
|
if (!chosen || !chosen.length) {
|
|
setQCStatus(describeQCEntry(entry));
|
|
return;
|
|
}
|
|
|
|
// Newest first, so after moveToEnd the stack reads current, v-1, v-2…
|
|
for (i = 0; i < chosen.length; i += 1) {
|
|
label = qcVersionLabel(chosen[i]);
|
|
if (findLayerByName(comp, label)) {
|
|
skipped.push(label);
|
|
continue;
|
|
}
|
|
setQCStatus("Importing " + entry.shotCode + " " + label + "...");
|
|
chosen[i].files.sort();
|
|
importOptions = new ImportOptions(chosen[i].files[0]);
|
|
importOptions.sequence = true;
|
|
footage = app.project.importFile(importOptions);
|
|
footage.mainSource.conformFrameRate = fps;
|
|
layer = comp.layers.add(footage);
|
|
layer.name = label;
|
|
layer.moveToEnd();
|
|
alignQCLayer(layer, comp, entry.versionString, fps);
|
|
added.push(label);
|
|
diagLog("QC compare: " + label + " — " + chosen[i].files.length + " frames from " +
|
|
chosen[i].folder.fsName + " (first frame " + chosen[i].first + ")");
|
|
}
|
|
|
|
comp.openInViewer();
|
|
setQCStatus((added.length ? "Stacked " + added.join(", ") + " under " + entry.versionString + "." : "") +
|
|
(skipped.length ? " Already in the comp: " + skipped.join(", ") + "." : "") +
|
|
"\nSolo a layer to view that version, or set the top layer to Difference.");
|
|
} finally {
|
|
app.endUndoGroup();
|
|
}
|
|
}
|
|
|
|
// ── QC: review session ────────────────────────────────────────────────────
|
|
//
|
|
// One button lays out a whole project's QC pass. Every shot waiting in the
|
|
// queue gets its QC_{shot}_{version} comp: the export on top, its earlier
|
|
// versions under it, then the shot's plates. Only the export and the main
|
|
// plate are switched on. Each scene also gets a continuous edit of those
|
|
// comps, cut to the edit with the handles trimmed, and a contact sheet.
|
|
//
|
|
// Each shot comp carries its export id in its comment. QC Pass/Fail act on
|
|
// the shot comp in the viewer, the shot layer selected in a scene comp, or
|
|
// the shot under the playhead in a scene edit, without finding it in the list.
|
|
|
|
var QC_TAG = "VFXR-QC";
|
|
var QC_EDIT_TAG = "VFXR-QC-EDIT";
|
|
var QC_CONTACT_TAG = "VFXR-QC-CONTACT";
|
|
// Indexes into AE's label set: Green and Red in the default colours.
|
|
var QC_LABEL_PASS = 9;
|
|
var QC_LABEL_FAIL = 1;
|
|
|
|
function qcTag(entry) {
|
|
return QC_TAG + " exportId=" + entry.exportId + " shot=" + entry.shotCode +
|
|
" version=" + entry.versionString;
|
|
}
|
|
|
|
function parseQCTag(text) {
|
|
var match = /VFXR-QC exportId=(\S+) shot=(\S+) version=(\S+)/.exec(String(text || ""));
|
|
|
|
return match ? { exportId: match[1], shotCode: match[2], versionString: match[3] } : null;
|
|
}
|
|
|
|
function qcFirstFrameNumber(footage) {
|
|
var file = getFootageFirstFile(footage);
|
|
|
|
return file ? parseFrameNumberFromPath(file.fsName) : null;
|
|
}
|
|
|
|
// Frames from reference's first frame to footage's first frame. EXR
|
|
// timecode is tried first, because a shipped export's timecode was checked
|
|
// against its plate. File frame numbers come next, and 0 is the last resort.
|
|
// Either answer is dropped when it is over limit. A shot's elements can
|
|
// carry unrelated camera timecodes (BG01 vs FG01). Plates may also be
|
|
// numbered from their timecode while renders start at 1001.
|
|
function qcFrameOffset(footage, reference, fps, limit) {
|
|
var a = readExrTimecode(getFootageFirstFile(footage));
|
|
var b = readExrTimecode(getFootageFirstFile(reference));
|
|
var offset;
|
|
|
|
if (a && b) {
|
|
offset = timecodeToFrames(a, fps) - timecodeToFrames(b, fps);
|
|
if (Math.abs(offset) <= limit) {
|
|
return offset;
|
|
}
|
|
}
|
|
a = qcFirstFrameNumber(footage);
|
|
b = qcFirstFrameNumber(reference);
|
|
if (a !== null && b !== null && Math.abs(a - b) <= limit) {
|
|
return a - b;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Lines a layer up in time with the export layer of a QC comp.
|
|
function alignQCLayer(layer, comp, exportLayerName, fps) {
|
|
var exportLayer = findLayerByName(comp, exportLayerName);
|
|
|
|
if (!exportLayer || exportLayer.index === layer.index) {
|
|
return;
|
|
}
|
|
layer.startTime = exportLayer.startTime +
|
|
qcFrameOffset(layer.source, exportLayer.source, fps, Math.round(comp.duration * fps)) / fps;
|
|
}
|
|
|
|
// Path -> FootageItem for everything already in the project, so a plate
|
|
// the artist's project already holds is reused rather than imported again.
|
|
function buildQCFootageIndex() {
|
|
var index = {};
|
|
var i;
|
|
var item;
|
|
var file;
|
|
|
|
for (i = 1; i <= app.project.numItems; i += 1) {
|
|
item = app.project.item(i);
|
|
if (!(item instanceof FootageItem)) {
|
|
continue;
|
|
}
|
|
file = getFootageFirstFile(item);
|
|
if (file) {
|
|
index[normalizeSlashes(file.fsName).toLowerCase()] = item;
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
// { footage, imported } for the sequence starting at firstFile.
|
|
function importQCFootage(firstFile, fps, index) {
|
|
var key = normalizeSlashes(firstFile.fsName).toLowerCase();
|
|
var importOptions;
|
|
var footage;
|
|
|
|
if (index[key]) {
|
|
return { footage: index[key], imported: false };
|
|
}
|
|
importOptions = new ImportOptions(firstFile);
|
|
importOptions.sequence = true;
|
|
footage = app.project.importFile(importOptions);
|
|
footage.mainSource.conformFrameRate = fps;
|
|
index[key] = footage;
|
|
return { footage: footage, imported: true };
|
|
}
|
|
|
|
// The scene comes from the shot record, which does not always match the
|
|
// scene in the shot code (UNG_109_022_010 is in scene 013). The code is
|
|
// used only when the record has no scene.
|
|
function qcSceneOf(entry) {
|
|
var scene = trimText(entry.scene);
|
|
var match;
|
|
|
|
if (scene) {
|
|
return scene;
|
|
}
|
|
match = /_(\d{3})_\d{3}$/.exec(String(entry.shotCode));
|
|
return match ? match[1] : "000";
|
|
}
|
|
|
|
function qcEpisodeCode(entry) {
|
|
return isEpisodicProject()
|
|
? deriveEpisodeCode(trimText(entry.episode) || deriveEpisodeFromShotCode(entry.shotCode))
|
|
: "";
|
|
}
|
|
|
|
function qcGroupName(entry) {
|
|
var prefix = isEpisodicProject() ? qcEpisodeCode(entry) : getShowId();
|
|
|
|
return (prefix ? prefix + "_" : "") + "SC" + qcSceneOf(entry);
|
|
}
|
|
|
|
// One entry per shot, the highest version, when a shot has several
|
|
// versions queued. Older queued versions are still found on disk.
|
|
function latestQCEntries(entries) {
|
|
var byShot = {};
|
|
var list = [];
|
|
var i;
|
|
var entry;
|
|
var best;
|
|
|
|
for (i = 0; i < entries.length; i += 1) {
|
|
entry = entries[i];
|
|
best = byShot[entry.shotCode];
|
|
if (!best) {
|
|
byShot[entry.shotCode] = entry;
|
|
list.push(entry.shotCode);
|
|
} else if ((qcVersionNumber(entry.versionString) || 0) > (qcVersionNumber(best.versionString) || 0)) {
|
|
byShot[entry.shotCode] = entry;
|
|
}
|
|
}
|
|
for (i = 0; i < list.length; i += 1) {
|
|
list[i] = byShot[list[i]];
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// Edit order: the sequence timecode when every shot in the scene has one,
|
|
// otherwise shot code order.
|
|
function sortQCGroup(entries) {
|
|
var timed = true;
|
|
var i;
|
|
|
|
for (i = 0; i < entries.length; i += 1) {
|
|
if (!trimText(entries[i].seqTimecodeStart)) {
|
|
timed = false;
|
|
}
|
|
}
|
|
entries.sort(function (a, b) {
|
|
var diff = 0;
|
|
|
|
if (timed) {
|
|
diff = parseTimecodeToFrames(a.seqTimecodeStart, a.fps || 24) -
|
|
parseTimecodeToFrames(b.seqTimecodeStart, b.fps || 24);
|
|
}
|
|
if (diff) {
|
|
return diff;
|
|
}
|
|
return a.shotCode < b.shotCode ? -1 : (a.shotCode > b.shotCode ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
function groupQCEntries(entries) {
|
|
var byName = {};
|
|
var groups = [];
|
|
var i;
|
|
var name;
|
|
|
|
for (i = 0; i < entries.length; i += 1) {
|
|
name = qcGroupName(entries[i]);
|
|
if (!byName[name]) {
|
|
byName[name] = { name: name, entries: [] };
|
|
groups.push(byName[name]);
|
|
}
|
|
byName[name].entries.push(entries[i]);
|
|
}
|
|
groups.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
|
for (i = 0; i < groups.length; i += 1) {
|
|
sortQCGroup(groups[i].entries);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function chooseQCSessionGroups(groups) {
|
|
var dialog = new Window("dialog", "Build QC Session: " + CURRENT_PROJECT.code);
|
|
var list;
|
|
var earlier;
|
|
var buttons;
|
|
var chosen = [];
|
|
var i;
|
|
|
|
dialog.orientation = "column";
|
|
dialog.alignChildren = ["fill", "top"];
|
|
dialog.add("statictext", undefined,
|
|
"A QC comp per shot, plus an edit and a contact sheet per scene. Ctrl-click to leave a scene out.",
|
|
{ multiline: true }).preferredSize = [380, 34];
|
|
list = dialog.add("listbox", undefined, [], { multiselect: true });
|
|
list.preferredSize = [380, Math.min(320, 24 + groups.length * 20)];
|
|
for (i = 0; i < groups.length; i += 1) {
|
|
list.add("item", groups[i].name + " " + groups[i].entries.length + " shot" +
|
|
(groups[i].entries.length > 1 ? "s" : "")).selected = true;
|
|
}
|
|
earlier = dialog.add("checkbox", undefined, "Also import earlier versions (switched off)");
|
|
earlier.value = true;
|
|
buttons = dialog.add("group");
|
|
buttons.alignment = ["right", "top"];
|
|
buttons.add("button", undefined, "Build", { name: "ok" });
|
|
buttons.add("button", undefined, "Cancel", { name: "cancel" });
|
|
|
|
if (dialog.show() !== 1) {
|
|
return null;
|
|
}
|
|
for (i = 0; list.selection && i < list.selection.length; i += 1) {
|
|
chosen.push(groups[list.selection[i].index]);
|
|
}
|
|
chosen.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
|
return { groups: chosen, includeEarlier: earlier.value };
|
|
}
|
|
|
|
// The part of the export that is in the edit, in shot comp frames: the
|
|
// export without the handles it was rendered with. Exports from before
|
|
// handles were recorded play in full.
|
|
function qcCutRange(entry, exportLayer, fps) {
|
|
var total = Math.round(exportLayer.source.duration * fps);
|
|
var head = Math.max(0, parseInt(entry.handleHead, 10) || 0);
|
|
var tail = Math.max(0, parseInt(entry.handleTail, 10) || 0);
|
|
|
|
if (head + tail >= total) {
|
|
head = 0;
|
|
tail = 0;
|
|
}
|
|
return {
|
|
inFrame: Math.round(exportLayer.startTime * fps) + head,
|
|
frames: total - head - tail,
|
|
trimmed: head + tail > 0
|
|
};
|
|
}
|
|
|
|
function buildQCSessionShot(entry, includeEarlier, bins, index) {
|
|
var fps = entry.fps || 24;
|
|
var compName = "QC_" + entry.shotCode + "_" + entry.versionString;
|
|
var result = { entry: entry, fps: fps, comp: null, exportLayer: null, notes: [], earlier: 0, plates: 0 };
|
|
var comp;
|
|
var exportLayer;
|
|
var resolved;
|
|
var found = [];
|
|
var plateFolders;
|
|
var plateLayers = [];
|
|
var mainPlateName = null;
|
|
var mainPlateDuration = -1;
|
|
var files;
|
|
var name;
|
|
var label;
|
|
var got;
|
|
var layer;
|
|
var i;
|
|
|
|
if (!importQCSequence(entry, true)) {
|
|
result.notes.push(DIAG.lastFailure ? DIAG.lastFailure.detail : "the export could not be imported");
|
|
return result;
|
|
}
|
|
comp = findComp(compName);
|
|
exportLayer = comp ? findLayerByName(comp, entry.versionString) : null;
|
|
if (!exportLayer) {
|
|
result.notes.push("no " + entry.versionString + " layer in " + compName);
|
|
return result;
|
|
}
|
|
comp.parentFolder = bins.shots;
|
|
exportLayer.source.parentFolder = bins.footage;
|
|
|
|
// Earlier versions, newest first, under the export.
|
|
if (includeEarlier) {
|
|
resolved = resolveServerPath(entry.outputDir);
|
|
if (resolved.path && qcVersionNumber(entry.versionString) !== null) {
|
|
found = findPreviousQCVersions(entry, resolved.path);
|
|
}
|
|
for (i = 0; i < found.length; i += 1) {
|
|
label = qcVersionLabel(found[i]);
|
|
if (findLayerByName(comp, label)) {
|
|
continue;
|
|
}
|
|
found[i].files.sort();
|
|
got = importQCFootage(found[i].files[0], fps, index);
|
|
if (got.imported) {
|
|
got.footage.parentFolder = bins.footage;
|
|
}
|
|
layer = comp.layers.add(got.footage);
|
|
layer.name = label;
|
|
layer.moveToEnd();
|
|
alignQCLayer(layer, comp, entry.versionString, fps);
|
|
result.earlier += 1;
|
|
}
|
|
}
|
|
|
|
// Plates, found as Build Shot finds them. The longest one is the main
|
|
// plate, as in Build Shot.
|
|
plateFolders = findSequenceFolders(entry.shotCode, qcEpisodeCode(entry));
|
|
for (i = 0; i < plateFolders.length; i += 1) {
|
|
files = plateFolders[i].getFiles("*.exr");
|
|
if (!files || files.length < 1) {
|
|
continue;
|
|
}
|
|
files.sort();
|
|
name = decodeURI(plateFolders[i].name);
|
|
layer = findLayerByName(comp, name);
|
|
if (!layer) {
|
|
got = importQCFootage(files[0], fps, index);
|
|
if (got.imported) {
|
|
got.footage.name = name;
|
|
got.footage.parentFolder = bins.footage;
|
|
}
|
|
layer = comp.layers.add(got.footage);
|
|
layer.name = name;
|
|
alignQCLayer(layer, comp, entry.versionString, fps);
|
|
}
|
|
plateLayers.push(name);
|
|
if (layer.source.duration > mainPlateDuration) {
|
|
mainPlateDuration = layer.source.duration;
|
|
mainPlateName = name;
|
|
}
|
|
}
|
|
if (!plateLayers.length) {
|
|
result.notes.push("no plates found for " + entry.shotCode + " under " +
|
|
getFootageGroupFolder(qcEpisodeCode(entry)).fsName);
|
|
}
|
|
// Plates go to the bottom, also when an earlier run already added them.
|
|
for (i = 0; i < plateLayers.length; i += 1) {
|
|
findLayerByName(comp, plateLayers[i]).moveToEnd();
|
|
}
|
|
|
|
for (i = 1; i <= comp.numLayers; i += 1) {
|
|
layer = comp.layer(i);
|
|
layer.enabled = layer.name === entry.versionString || layer.name === mainPlateName;
|
|
}
|
|
|
|
result.comp = comp;
|
|
result.exportLayer = findLayerByName(comp, entry.versionString);
|
|
result.plates = plateLayers.length;
|
|
result.cut = qcCutRange(entry, result.exportLayer, fps);
|
|
return result;
|
|
}
|
|
|
|
// Scene comps are generated. A rebuild empties and refills them.
|
|
function resetQCComp(name, width, height, duration, fps, folder) {
|
|
var comp = findComp(name);
|
|
|
|
if (comp) {
|
|
while (comp.numLayers > 0) {
|
|
comp.layer(1).locked = false;
|
|
comp.layer(1).remove();
|
|
}
|
|
comp.width = width;
|
|
comp.height = height;
|
|
comp.frameRate = fps;
|
|
comp.duration = duration;
|
|
} else {
|
|
comp = app.project.items.addComp(name, width, height, 1, duration, fps);
|
|
}
|
|
comp.parentFolder = folder;
|
|
return comp;
|
|
}
|
|
|
|
function fitQCLayer(layer, width, height, centre) {
|
|
var scale = Math.min(width / layer.source.width, height / layer.source.height) * 100;
|
|
var transform = layer.property("ADBE Transform Group");
|
|
|
|
transform.property("ADBE Scale").setValue([scale, scale]);
|
|
transform.property("ADBE Position").setValue(centre);
|
|
}
|
|
|
|
// The scene's shots butted together in edit order, each cut to its edit
|
|
// range. A layer marker names each shot.
|
|
function buildQCSceneEdit(group, shots, folder) {
|
|
var first = shots[0].comp;
|
|
var total = 0;
|
|
var cursor = 0;
|
|
var comp;
|
|
var layer;
|
|
var shot;
|
|
var i;
|
|
|
|
for (i = 0; i < shots.length; i += 1) {
|
|
total += shots[i].cut.frames / shots[i].fps;
|
|
}
|
|
comp = resetQCComp("QC_EDIT_" + group.name, first.width, first.height,
|
|
Math.max(total, 1 / first.frameRate), first.frameRate, folder);
|
|
comp.comment = QC_EDIT_TAG + " " + group.name;
|
|
|
|
for (i = 0; i < shots.length; i += 1) {
|
|
shot = shots[i];
|
|
layer = comp.layers.add(shot.comp);
|
|
layer.moveToEnd();
|
|
fitQCLayer(layer, comp.width, comp.height, [comp.width / 2, comp.height / 2]);
|
|
layer.startTime = cursor - shot.cut.inFrame / shot.fps;
|
|
layer.inPoint = cursor;
|
|
layer.outPoint = cursor + shot.cut.frames / shot.fps;
|
|
layer.property("ADBE Marker").setValueAtTime(cursor,
|
|
new MarkerValue(shot.entry.shotCode + " " + shot.entry.versionString));
|
|
cursor += shot.cut.frames / shot.fps;
|
|
}
|
|
return comp;
|
|
}
|
|
|
|
// Every shot of the scene side by side, each starting at its cut in, with a
|
|
// label under it. The label carries the shot's tag too, so selecting either
|
|
// the picture or its label picks the shot for QC Pass.
|
|
function buildQCContactSheet(group, shots, folder) {
|
|
var first = shots[0].comp;
|
|
var cols = Math.ceil(Math.sqrt(shots.length));
|
|
var rows = Math.ceil(shots.length / cols);
|
|
var cellW = Math.floor(first.width / cols);
|
|
var cellH = Math.round(cellW * first.height / first.width);
|
|
var labelH = Math.max(36, Math.round(cellH * 0.14));
|
|
var longest = 1 / first.frameRate;
|
|
var comp;
|
|
var shot;
|
|
var layer;
|
|
var text;
|
|
var textProp;
|
|
var textDoc;
|
|
var x;
|
|
var top;
|
|
var i;
|
|
|
|
for (i = 0; i < shots.length; i += 1) {
|
|
longest = Math.max(longest, shots[i].cut.frames / shots[i].fps);
|
|
}
|
|
comp = resetQCComp("QC_CONTACT_" + group.name, cols * cellW, rows * (cellH + labelH),
|
|
longest, first.frameRate, folder);
|
|
comp.comment = QC_CONTACT_TAG + " " + group.name;
|
|
|
|
for (i = 0; i < shots.length; i += 1) {
|
|
shot = shots[i];
|
|
x = (i % cols) * cellW + cellW / 2;
|
|
top = Math.floor(i / cols) * (cellH + labelH);
|
|
|
|
layer = comp.layers.add(shot.comp);
|
|
fitQCLayer(layer, cellW, cellH, [x, top + cellH / 2]);
|
|
layer.startTime = -shot.cut.inFrame / shot.fps;
|
|
layer.inPoint = 0;
|
|
layer.outPoint = shot.cut.frames / shot.fps;
|
|
|
|
text = comp.layers.addText(shot.entry.shotCode + " " + shot.entry.versionString);
|
|
textProp = text.property("ADBE Text Properties").property("ADBE Text Document");
|
|
textDoc = textProp.value;
|
|
textDoc.fontSize = Math.round(labelH * 0.5);
|
|
textDoc.applyFill = true;
|
|
textDoc.fillColor = [1, 1, 1];
|
|
textDoc.applyStroke = false;
|
|
textDoc.justification = ParagraphJustification.CENTER_JUSTIFY;
|
|
textProp.setValue(textDoc);
|
|
text.property("ADBE Transform Group").property("ADBE Position")
|
|
.setValue([x, top + cellH + labelH * 0.68]);
|
|
text.name = "label " + shot.entry.shotCode;
|
|
text.comment = qcTag(shot.entry);
|
|
}
|
|
return comp;
|
|
}
|
|
|
|
// ScriptUI doesn't repaint a docked panel during a long loop unless asked.
|
|
function qcProgress(message) {
|
|
setQCStatus(message);
|
|
try {
|
|
uiState.qcStatusText.window.update();
|
|
} catch (updateError) {
|
|
}
|
|
}
|
|
|
|
function buildQCSession() {
|
|
var groups;
|
|
var choice;
|
|
var index;
|
|
var sessionBin;
|
|
var footageBin;
|
|
var groupBin;
|
|
var shotsBin;
|
|
var shots;
|
|
var info;
|
|
var firstEdit = null;
|
|
var problems = [];
|
|
var total = 0;
|
|
var done = 0;
|
|
var built = 0;
|
|
var untrimmed = 0;
|
|
var scenes = 0;
|
|
var entry;
|
|
var g;
|
|
var i;
|
|
|
|
if (!app.project) {
|
|
setQCStatus("Open a project first");
|
|
return;
|
|
}
|
|
if (!CURRENT_PROJECT) {
|
|
setQCStatus("Pick a project first");
|
|
return;
|
|
}
|
|
|
|
// A failed load leaves the old entries behind, so start from none.
|
|
qcState.entries = [];
|
|
loadQCQueue();
|
|
if (!qcState.entries.length) {
|
|
return;
|
|
}
|
|
groups = groupQCEntries(latestQCEntries(qcState.entries));
|
|
choice = chooseQCSessionGroups(groups);
|
|
if (!choice || !choice.groups.length) {
|
|
setQCStatus("QC session cancelled");
|
|
return;
|
|
}
|
|
for (g = 0; g < choice.groups.length; g += 1) {
|
|
total += choice.groups[g].entries.length;
|
|
}
|
|
|
|
app.beginUndoGroup("VFXReview Connector - Build QC Session");
|
|
try {
|
|
index = buildQCFootageIndex();
|
|
sessionBin = findOrCreateFolder("QC_SESSION_" + CURRENT_PROJECT.code, app.project.rootFolder);
|
|
footageBin = findOrCreateFolder("_footage", sessionBin);
|
|
|
|
for (g = 0; g < choice.groups.length; g += 1) {
|
|
groupBin = findOrCreateFolder(choice.groups[g].name, sessionBin);
|
|
shotsBin = findOrCreateFolder("shots", groupBin);
|
|
shots = [];
|
|
|
|
for (i = 0; i < choice.groups[g].entries.length; i += 1) {
|
|
entry = choice.groups[g].entries[i];
|
|
done += 1;
|
|
qcProgress("Building " + entry.shotCode + " " + entry.versionString +
|
|
" (" + done + "/" + total + ")...");
|
|
try {
|
|
info = buildQCSessionShot(entry, choice.includeEarlier,
|
|
{ shots: shotsBin, footage: footageBin }, index);
|
|
} catch (shotError) {
|
|
info = { comp: null, notes: [describeError(shotError)] };
|
|
}
|
|
if (info.notes.length) {
|
|
problems.push(entry.shotCode + " " + entry.versionString + ": " + info.notes.join("; "));
|
|
}
|
|
if (info.comp && info.exportLayer) {
|
|
shots.push(info);
|
|
built += 1;
|
|
if (!info.cut.trimmed) {
|
|
untrimmed += 1;
|
|
}
|
|
diagLog("QC session: " + info.comp.name + " — " + info.earlier + " earlier version(s), " +
|
|
info.plates + " plate(s), cut " + info.cut.frames + "f from comp frame " + info.cut.inFrame);
|
|
}
|
|
}
|
|
|
|
if (shots.length) {
|
|
qcProgress("Cutting " + choice.groups[g].name + "...");
|
|
try {
|
|
info = buildQCSceneEdit(choice.groups[g], shots, groupBin);
|
|
firstEdit = firstEdit || info;
|
|
buildQCContactSheet(choice.groups[g], shots, groupBin);
|
|
scenes += 1;
|
|
} catch (sceneError) {
|
|
problems.push(choice.groups[g].name + " edit/contact sheet: " + describeError(sceneError));
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
app.endUndoGroup();
|
|
}
|
|
|
|
if (firstEdit) {
|
|
firstEdit.openInViewer();
|
|
}
|
|
setQCStatus("QC session: " + built + " of " + total + " shot(s), " + scenes + " scene(s)." +
|
|
(untrimmed ? " " + untrimmed + " export(s) have no handle record and play in full in the edit." : "") +
|
|
(problems.length ? " " + problems.length + " problem(s): see the report." : ""));
|
|
if (problems.length) {
|
|
for (i = 0; i < problems.length; i += 1) {
|
|
diagLog("QC session problem: " + problems[i]);
|
|
}
|
|
showDiagDialog("QC session built with " + problems.length + " problem(s).", diagReport());
|
|
}
|
|
}
|
|
|
|
// ── QC: which export a decision is for ────────────────────────────────────
|
|
|
|
// The queue entry for a tag. It is also selected in the list, so the status
|
|
// line describes it. If the list has been reloaded since, the tag itself
|
|
// carries all a decision needs.
|
|
function qcEntryForTag(tag) {
|
|
var i;
|
|
|
|
if (!tag) {
|
|
return null;
|
|
}
|
|
for (i = 0; i < qcState.entries.length; i += 1) {
|
|
if (qcState.entries[i].exportId === tag.exportId) {
|
|
if (uiState.qcList) {
|
|
uiState.qcList.selection = i;
|
|
}
|
|
return qcState.entries[i];
|
|
}
|
|
}
|
|
return tag;
|
|
}
|
|
|
|
function qcTagOfLayer(layer) {
|
|
var tag = null;
|
|
|
|
try {
|
|
tag = parseQCTag(layer.comment);
|
|
if (!tag && layer.source instanceof CompItem) {
|
|
tag = parseQCTag(layer.source.comment);
|
|
}
|
|
} catch (layerError) {
|
|
}
|
|
return tag;
|
|
}
|
|
|
|
// { fromComp, entry, problem }. fromComp is false unless the active comp is
|
|
// a QC comp, and then the list selection decides. In a scene comp, entry is
|
|
// the selected shot layer, or the shot under the playhead in an edit.
|
|
function getQCEntryFromActiveComp() {
|
|
var comp = app.project ? app.project.activeItem : null;
|
|
var comment;
|
|
var layers;
|
|
var layer;
|
|
var i;
|
|
|
|
if (!(comp instanceof CompItem)) {
|
|
return { fromComp: false };
|
|
}
|
|
comment = String(comp.comment || "");
|
|
if (parseQCTag(comment)) {
|
|
return { fromComp: true, entry: qcEntryForTag(parseQCTag(comment)) };
|
|
}
|
|
if (comment.indexOf(QC_EDIT_TAG) !== 0 && comment.indexOf(QC_CONTACT_TAG) !== 0) {
|
|
return { fromComp: false };
|
|
}
|
|
|
|
layers = comp.selectedLayers;
|
|
if (layers.length === 1) {
|
|
return { fromComp: true, entry: qcEntryForTag(qcTagOfLayer(layers[0])),
|
|
problem: "The selected layer in " + comp.name + " isn't a shot" };
|
|
}
|
|
if (layers.length > 1) {
|
|
return { fromComp: true, entry: null, problem: "Select one shot in " + comp.name };
|
|
}
|
|
if (comment.indexOf(QC_EDIT_TAG) === 0) {
|
|
for (i = 1; i <= comp.numLayers; i += 1) {
|
|
layer = comp.layer(i);
|
|
if (layer.enabled && comp.time >= layer.inPoint && comp.time < layer.outPoint) {
|
|
return { fromComp: true, entry: qcEntryForTag(qcTagOfLayer(layer)),
|
|
problem: "No shot under the playhead in " + comp.name };
|
|
}
|
|
}
|
|
return { fromComp: true, entry: null, problem: "No shot under the playhead in " + comp.name };
|
|
}
|
|
return { fromComp: true, entry: null, problem: "Select a shot in " + comp.name };
|
|
}
|
|
|
|
// Colours the shot comp, and its layers in the scene comps, by the result.
|
|
function markQCResultInProject(exportId, result) {
|
|
var label = result === "PASS" ? QC_LABEL_PASS : QC_LABEL_FAIL;
|
|
var item;
|
|
var comment;
|
|
var tag;
|
|
var i;
|
|
var j;
|
|
|
|
app.beginUndoGroup("VFXReview Connector - Mark QC " + result);
|
|
try {
|
|
for (i = 1; i <= app.project.numItems; i += 1) {
|
|
item = app.project.item(i);
|
|
if (!(item instanceof CompItem)) {
|
|
continue;
|
|
}
|
|
comment = String(item.comment || "");
|
|
tag = parseQCTag(comment);
|
|
if (tag && tag.exportId === exportId) {
|
|
item.label = label;
|
|
} else if (comment.indexOf(QC_EDIT_TAG) === 0 || comment.indexOf(QC_CONTACT_TAG) === 0) {
|
|
for (j = 1; j <= item.numLayers; j += 1) {
|
|
tag = qcTagOfLayer(item.layer(j));
|
|
if (tag && tag.exportId === exportId) {
|
|
item.layer(j).label = label;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (markError) {
|
|
diagLog("QC: could not colour comps for " + exportId + ": " + describeError(markError));
|
|
} finally {
|
|
app.endUndoGroup();
|
|
}
|
|
}
|
|
|
|
function submitQC(result) {
|
|
// A QC comp in the viewer decides over the list selection.
|
|
var picked = getQCEntryFromActiveComp();
|
|
var entry = picked.fromComp ? picked.entry : getSelectedQCEntry();
|
|
var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : "";
|
|
var source = picked.fromComp ? "From the comp in the viewer:\n" : "";
|
|
var body;
|
|
var response;
|
|
|
|
if (!entry) {
|
|
setQCStatus(picked.fromComp ? picked.problem : "Select a queue entry first, or open a shot's QC comp");
|
|
return;
|
|
}
|
|
|
|
// A bare FAIL leaves the artist guessing at what to fix.
|
|
if (result === "FAIL" && !notes) {
|
|
if (!confirm(source + "Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" +
|
|
"The artist only sees the reason you write here.")) {
|
|
return;
|
|
}
|
|
}
|
|
if (result === "PASS" && !confirm(source + "QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" +
|
|
"This marks the version ready for delivery. Nothing is sent to the client.")) {
|
|
return;
|
|
}
|
|
// Picked from the viewer rather than the list, so a fail is confirmed too.
|
|
if (result === "FAIL" && notes && picked.fromComp &&
|
|
!confirm(source + "QC FAIL " + entry.shotCode + " " + entry.versionString + "?\n\n" + notes)) {
|
|
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 = "";
|
|
}
|
|
markQCResultInProject(entry.exportId, result);
|
|
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 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 compareQCButton;
|
|
var qcSessionRow;
|
|
var qcSessionButton;
|
|
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.";
|
|
uiState.saveAepCheckbox = pipelineRow1.add("checkbox", undefined, "Keep AEP");
|
|
uiState.saveAepCheckbox.value = CONFIG.saveExportAep !== false;
|
|
uiState.saveAepCheckbox.helpTip =
|
|
"Save a copy of the project with each export, in its version\n" +
|
|
"folder as {shot}_cmp_TT_{version}.aep, so the exact comp behind\n" +
|
|
"a render can be reopened later.\n\n" +
|
|
"The copy is reduced to the exported comp and what it uses.\n" +
|
|
"Footage is linked, not copied: no EXRs are stored.\n\n" +
|
|
"Your project is reopened afterwards, which clears undo.";
|
|
uiState.saveAepCheckbox.onClick = function () {
|
|
CONFIG.saveExportAep = uiState.saveAepCheckbox.value;
|
|
saveConfig();
|
|
};
|
|
|
|
var handlesGroup = pipelinePanel.add("group");
|
|
handlesGroup.orientation = "row";
|
|
handlesGroup.alignChildren = ["left", "center"];
|
|
handlesGroup.spacing = 6;
|
|
uiState.handlesCheckbox = handlesGroup.add("checkbox", undefined, "Includes handles");
|
|
uiState.handlesCheckbox.value = true;
|
|
uiState.handleFramesField = handlesGroup.add("edittext", undefined, String(CONFIG.handleFrames));
|
|
uiState.handleFramesField.characters = 3;
|
|
handlesGroup.add("statictext", undefined, "frames each end");
|
|
uiState.handlesCheckbox.helpTip =
|
|
"The render runs past the cut at both ends (handles). The review\n" +
|
|
"player marks those frames HANDLE / NOT IN EDIT so a glitch in a\n" +
|
|
"handle isn't mistaken for a problem in the shot.\n\n" +
|
|
"Review-only: nothing is burned in and the EXRs are unchanged.\n" +
|
|
"Untick for a render cut exactly to the edit.";
|
|
uiState.handlesCheckbox.onClick = function () {
|
|
uiState.handleFramesField.enabled = uiState.handlesCheckbox.value;
|
|
};
|
|
|
|
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.";
|
|
incrementVersionButton = pipelineRow3.add("button", undefined, "Increment Shot Version");
|
|
|
|
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.";
|
|
compareQCButton = qcRow1.add("button", undefined, "Compare Versions");
|
|
compareQCButton.helpTip = "Import earlier versions of this shot under the selected export in its QC comp, lined up by frame.";
|
|
|
|
qcSessionRow = qcPanel.add("group");
|
|
qcSessionRow.orientation = "row";
|
|
qcSessionRow.alignChildren = ["fill", "center"];
|
|
qcSessionRow.spacing = 6;
|
|
qcSessionButton = qcSessionRow.add("button", undefined, "Build QC Review Session");
|
|
qcSessionButton.helpTip =
|
|
"Every shot waiting for QC in this project gets a QC comp: the export over\n" +
|
|
"its earlier versions and plates, with only the export and main plate on.\n" +
|
|
"Each scene also gets a continuous edit (handles trimmed) and a contact sheet.\n\n" +
|
|
"QC Pass / Fail then act on the open shot comp, the selected shot in a\n" +
|
|
"scene comp, or the shot under the playhead in a scene edit.";
|
|
|
|
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");
|
|
|
|
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);
|
|
compareQCButton.onClick = guarded("Compare QC Versions", compareQCVersions);
|
|
qcSessionButton.onClick = guarded("Build QC Session", buildQCSession);
|
|
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;
|
|
}
|
|
|
|
// A previous instance may have left a repeating poll armed, and its shot
|
|
// code went with that closure. Cancel it before the tick is pointed at this
|
|
// instance, rather than letting it fire into a fresh, empty state.
|
|
stopExportPolling();
|
|
|
|
// 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();
|
|
|
|
// Closing the panel leaves an armed task running with no UI left to update.
|
|
// The export itself is unaffected: it runs on the farm, and the panel picks
|
|
// its status back up from the server on the next Refresh.
|
|
panel.onClose = function () {
|
|
stopExportPolling();
|
|
};
|
|
|
|
if (panel instanceof Window) {
|
|
panel.center();
|
|
panel.show();
|
|
}
|
|
}(this));
|