Files
vfxreview/VFXReviewConnector.jsx
T
twotalesanimation 26a4719c2a fix(connector): strip reference LUT/overlay before Queue Export renders EXRs
Build Shot adds the show LUT and overlay burn-in into the artist's main
comp, enabled by default, for reference while compositing. The legacy
manual "Queue EXR" button has always disabled both before rendering;
Queue Export (the pipeline's one-click path) never got that same step,
so EXRs queued through it could ship with the LUT graded in and the
burn-in baked on. Queue Export now disables them the same way, right
before the project save the render worker reads from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:42 +02:00

4815 lines
172 KiB
React

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