Files
vfxreview/VFXReviewConnector.jsx
T
twotalesanimation 7f2f5e1005 feat(connector): resolve footage per project type, anchor parsing on showId
Build Shot only ever worked on UNG. Two assumptions were baked in: that a shot's
plates sit flat in a folder named after the episode, and that a shot code is
three letters followed by three 3-digit tokens. DH2_VFX breaks both -- it groups
a shot's plates under a {shotCode}/ folder, has no episode level, and its showId
contains a digit and an underscore.

- findSequenceFolders prefers {group}/{shotCode}/ and falls back to UNG's flat
  {group}/{shotCode}_* scan, so both layouts work with no migration -- moving
  delivered footage would break the links in every AE project pointing at it. An
  existing-but-empty shot folder returns nothing rather than falling through, so
  stray plate folders sitting beside a shot cannot be read as its footage.
  Underscore-prefixed folders are never shots or plates.
- getFootageGroupFolder replaces getEpisodeRootFolder: the {showId}_{episode}
  folder for episodic shows, the show root otherwise. A scene filters the shot
  list, it is not a directory -- which is why Build Shot on DH2 was looking in
  V:/_FOOTAGE/DH2/027.
- Shot codes are now parsed by stripping the known showId prefix
  ({showId}_{episode}_{scene}_{shot} for episodic, {showId}_{scene}_{shot} for
  standard) rather than matching /[A-Z]{3}_.../ or counting underscores.
  extractShotCode keeps the old pattern as a fallback so a comp from a different
  show than the selected one still reads. Same anchoring in deriveEpisodeCode
  (no more hardcoded "UNG_"), deriveEpisodeAPIValue, isEpisodeCode and the
  getShotExportDir episode fallback.
- Two positional cuts of the same class, found on the way: the DELIVERY path and
  Pull Picture Lock's episode-prefix match both used shotCode.substring(0, 7).
  Both now go through getEpisodePrefix(). Output is identical for UNG and DH2
  (DH2_VFX happens to be 7 characters); the one behaviour change is that
  standard shows no longer get a constant group level under DELIVERY/, and DH2
  has no delivery history to move.

Verified against the live V:/_FOOTAGE tree by running the real functions under a
Folder stub: UNG_102_019_010 resolves its BG01/FG01 plates via the flat scan, and
DH2_VFX_027_010 and DH2_VFX_066_380 resolve theirs via the grouped layout. Not
yet exercised in After Effects.

Initialise Workspace is untouched and still hardcoded to UNG -- next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:15:42 +02:00

3396 lines
114 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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:""
};
var SETTINGS_GROUP = "VFXReviewConnector";
var SETTINGS_KEY = "cfg";
(function VFXReviewConnector(thisObj) {
var uiState = {
projectDropdown: null,
episodeDropdown: null,
shotDropdown: null,
episodeLabel: null,
selectionText: null,
lastActionText: null,
pipelineStatusText: null,
urgentCheckbox: null,
submissionNoteField: null
};
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
};
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+$/, "");
}
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;
}
function getShotData(shotCode) {
var url;
var command;
var response;
var parsed;
try {
url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
"?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
command = "curl -s " +
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Accept: application/json\" " +
"\"" + url + "\"";
response = system.callSystem(command);
} catch (callError) {
return null;
}
parsed = parseJSON(response);
if (!parsed) {
return null;
}
return parsed;
}
function getAPIData(url) {
var command;
var response;
var parsed;
try {
command = "curl -s " +
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Accept: application/json\" " +
"\"" + url + "\"";
response = system.callSystem(command);
} catch (callError) {
return null;
}
parsed = parseJSON(response);
if (!parsed) {
return null;
}
return parsed;
}
function getListFromResponse(data, keyName) {
if (!data) {
return [];
}
if (data.length !== undefined) {
return data;
}
if (keyName && data[keyName] && data[keyName].length !== undefined) {
return data[keyName];
}
if (data.data && data.data.length !== undefined) {
return data.data;
}
if (data.results && data.results.length !== undefined) {
return data.results;
}
if (data.items && data.items.length !== undefined) {
return data.items;
}
return [];
}
function padEpisodeNumber(value) {
var text = String(value);
while (text.length < 3) {
text = "0" + text;
}
return text;
}
// Episode folders on disk are named {showId}_{episode} (UNG/UNG_102/...), so
// the prefix comes from the selected project rather than a hardcoded "UNG_".
function deriveEpisodeCode(value) {
var text = trimText(value);
var showId = getShowId();
if (showId && text.indexOf(showId + "_") === 0) {
return text;
}
if (/^\d+$/.test(text)) {
return (showId ? showId + "_" : "") + padEpisodeNumber(text);
}
return text;
}
function deriveEpisodeAPIValue(value) {
var text = trimText(value);
var showId = getShowId();
var match;
if (showId && text.indexOf(showId + "_") === 0) {
text = text.substring(showId.length + 1);
}
match = text.match(/^\d+/);
if (match) {
return String(parseInt(match[0], 10));
}
match = text.match(/[A-Z]{3}_(\d{3})/);
if (match && match.length > 1) {
return String(parseInt(match[1], 10));
}
return text;
}
function getEpisodeValue(item) {
if (item === null || item === undefined) {
return "";
}
if (typeof item !== "object") {
return deriveEpisodeAPIValue(item);
}
return deriveEpisodeAPIValue(item.episode || item.episodeNumber || item.number || item.id || item.code || item.episodeCode || item.name || "");
}
function getEpisodeLabel(item) {
if (item === null || item === undefined) {
return "";
}
if (typeof item !== "object") {
return deriveEpisodeCode(item);
}
return String(item.episodeCode || item.code || item.name || deriveEpisodeCode(getEpisodeValue(item)));
}
function getShotCodeValue(item) {
if (item === null || item === undefined) {
return "";
}
if (typeof item !== "object") {
return String(item);
}
return String(item.shotCode || item.code || item.name || "");
}
function getEpisodesFromAPI() {
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/episodes";
var data = getAPIData(url);
return getListFromResponse(data, "episodes");
}
function getShotsFromAPI(value) {
var param = isEpisodicProject() ? "episode" : "scene";
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?" + param + "=" + encodeURIComponent(value);
var data = getAPIData(url);
return getListFromResponse(data, "shots");
}
function getScenesFromAPI() {
var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?limit=500";
var data = getAPIData(url);
var shots = getListFromResponse(data, "shots");
var seen = {};
var scenes = [];
for (var i = 0; i < shots.length; i += 1) {
var sc = shots[i].scene || "";
if (sc && !seen[sc]) { seen[sc] = true; scenes.push(sc); }
}
scenes.sort();
return scenes;
}
function getProjectsFromAPI() {
var url = CONFIG.baseUrl + "/api/ext/projects";
var data = getAPIData(url);
return getListFromResponse(data, "projects");
}
function normalizeShotData(data) {
if (!data) {
return null;
}
if (data.shotCode) {
return data;
}
if (data.shot && data.shot.shotCode) {
return data.shot;
}
if (data.data && data.data.shotCode) {
return data.data;
}
if (data.result && data.result.shotCode) {
return data.result;
}
if (data.results && data.results.length && data.results[0].shotCode) {
return data.results[0];
}
return null;
}
// The showId is the single source of truth for both the on-disk show folder
// and the shot-code prefix: UNG (episodic) and DH2_VFX (standard) both follow
// {showId}_{...}, so anything parsing a code anchors on this rather than
// counting underscores or assuming a three-letter code.
function getShowId() {
return CURRENT_PROJECT ? trimText(CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : "";
}
function isEpisodicProject() {
return !!(CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC");
}
function escapeRegexText(text) {
return String(text).replace(/([.*+?^${}()|[\]\\\-])/g, "\\$1");
}
// Shot codes are {showId}_{episode}_{scene}_{shot} on episodic shows and
// {showId}_{scene}_{shot} on standard ones -- so the number of 3-digit tokens
// after the showId is what distinguishes them.
function getShotCodeRegex() {
var showId = getShowId();
if (!showId) {
return LEGACY_SHOT_CODE_REGEX;
}
return new RegExp("(" + escapeRegexText(showId) +
"(?:_\\d{3}){" + (isEpisodicProject() ? 3 : 2) + "})");
}
function isEpisodeCode(value) {
var showId = getShowId();
if (!showId) {
return LEGACY_EPISODE_CODE_REGEX.test(String(value));
}
return new RegExp("^" + escapeRegexText(showId) + "_\\d{3}$").test(String(value));
}
function getShowExportRoot() {
var showId = getShowId();
return CONFIG.exportRoot + (showId ? "/" + showId : "");
}
function getShowFootageRoot() {
var showId = getShowId();
return CONFIG.footageRoot + (showId ? "/" + showId : "");
}
// First 3-digit token after the showId, for episodic shots whose API record
// is missing `episode`.
function deriveEpisodeFromShotCode(shotCode) {
var showId = getShowId();
var text = String(shotCode);
var match;
if (showId && text.indexOf(showId + "_") === 0) {
match = text.substring(showId.length + 1).match(/^(\d{3})_/);
return match ? match[1] : "";
}
match = text.match(/^[A-Z]{3}_(\d{3})_/);
return match ? match[1] : "";
}
// The prefix episode-wide files share: "UNG_102" for episodic shows, plain
// "DH2_VFX" for standard ones, which have no episode level. Replaces
// shotCode.substring(0, 7), which only produced this for a 3-letter showId.
function getEpisodePrefix(shotCode) {
var showId = getShowId();
var episode;
if (!isEpisodicProject()) {
return showId;
}
episode = deriveEpisodeFromShotCode(shotCode);
return episode ? showId + "_" + episode : showId;
}
// Deliveries are grouped per episode ("DELIVERY/UNG_102/..."). Standard shows
// have nothing to group by, so they get no segment at all.
function getDeliveryGroupSegment(shotCode) {
return isEpisodicProject() ? "/" + getEpisodePrefix(shotCode) : "";
}
// Mirrors the server's export layout so farm and local renders land together:
// {exportRoot}/{showId}/[{episode} for episodic]/{shotCode}/{version}
function getShotExportDir(shot) {
var path = getShowExportRoot();
var episode;
if (isEpisodicProject()) {
episode = trimText(shot.episode);
if (!episode) {
episode = deriveEpisodeFromShotCode(shot.shotCode);
}
if (episode) {
path += "/" + episode;
}
}
return path + "/" + shot.shotCode + "/" + (shot.shotVersion || "v001");
}
function ensureFolder(path) {
var folder = new Folder(path);
var parent;
if (folder.exists) {
return true;
}
parent = folder.parent;
if (parent && !parent.exists) {
if (!ensureFolder(parent.fsName)) {
return false;
}
}
try {
return folder.create();
} catch (folderError) {
return false;
}
}
function setCompStartFrame(comp, frameNumber) {
try {
comp.displayStartFrame = frameNumber;
return;
} catch (frameError) {
}
try {
comp.displayStartTime = frameNumber / comp.frameRate;
} catch (timeError) {
}
}
function deselectAllProjectItems() {
var i;
try {
for (i = 1; i <= app.project.numItems; i += 1) {
app.project.item(i).selected = false;
}
} catch (deselectError) {
}
}
// AE scripting exposes no direct read of a footage item's embedded start
// timecode. Recreate the UI behaviour instead: "New Comp from Selection"
// inherits the footage's start timecode from the file metadata, so build a
// throwaway comp, read the start time AE gave it, then delete it.
//
// Returns null when the probe could not run (menu command unavailable or
// disabled), so callers can fall back to the API timecode.
function probeFootageStartFrames(footageItem, fps) {
var commandId;
var projectPanelId;
var existingIds = {};
var probeComp = null;
var frames = null;
var i;
var item;
try {
commandId = app.findMenuCommandId("New Comp from Selection");
if (!commandId) {
return null;
}
for (i = 1; i <= app.project.numItems; i += 1) {
existingIds[app.project.item(i).id] = true;
}
// executeCommand mirrors the menu bar, so the Project panel has to
// be frontmost or "New Comp from Selection" is greyed out and the
// call silently does nothing.
try {
projectPanelId = app.findMenuCommandId("Project");
if (projectPanelId) {
app.executeCommand(projectPanelId);
}
} catch (focusError) {
}
deselectAllProjectItems();
footageItem.selected = true;
app.executeCommand(commandId);
// Only ever touch a comp that did not exist before the probe —
// never app.project.activeItem, which is an existing open comp
// when the command did not run.
for (i = app.project.numItems; i >= 1; i -= 1) {
item = app.project.item(i);
if (item instanceof CompItem && !existingIds[item.id]) {
probeComp = item;
break;
}
}
if (probeComp) {
frames = Math.round((probeComp.displayStartTime || 0) * (fps || probeComp.frameRate || 24));
}
} catch (probeError) {
frames = null;
}
try {
if (probeComp) {
probeComp.remove();
}
} catch (removeError) {
}
return frames;
}
// The plate's own start timecode: AE metadata when we can read it,
// otherwise the API's source clip timecode less the handle frames — the
// same convention the Set Start Timecode button uses.
function getFootageStartFrames(footageItem, apiShot, fps) {
var probed = probeFootageStartFrames(footageItem, fps);
var frames;
if (probed !== null && probed > 0) {
return { frames: probed, source: "footage metadata" };
}
if (apiShot && apiShot.timecodeStart) {
frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - CONFIG.handleFrames;
if (frames > 0) {
return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + CONFIG.handleFrames + "f" };
}
}
return { frames: 0, source: probed === 0 ? "footage starts at 00:00:00:00" : "no timecode found" };
}
function findComp(name) {
var i;
var item;
if (!app.project) {
return null;
}
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof CompItem && item.name === name) {
return item;
}
}
return null;
}
function findFolder(name) {
var i;
var item;
if (!app.project) {
return null;
}
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FolderItem && item.name === name) {
return item;
}
}
return null;
}
function findEpisodeProjectFolder(episodeCode) {
var shotsRoot = findFolder("__SHOTS");
var i;
var item;
if (!shotsRoot) {
return findFolder(episodeCode);
}
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FolderItem && item.name === episodeCode && item.parentFolder === shotsRoot) {
return item;
}
}
return findFolder(episodeCode);
}
// The folder a shot's plates are grouped under: the {showId}_{episode} folder
// on episodic shows, the show root on standard ones -- a scene never gets its
// own folder on disk, so the scene value from the dropdown is not a path.
function getFootageGroupFolder(groupCode) {
var root = getShowFootageRoot();
if (isEpisodicProject() && trimText(groupCode)) {
return new Folder(root + "/" + trimText(groupCode));
}
return new Folder(root);
}
// Underscore-prefixed folders are never shots or plates: UNG already uses the
// convention for _ARCHIVE, _GFX, _PICLOCKS and _PULLS.
function isNonShotFolderName(name) {
return String(name).indexOf("_") === 0;
}
function getEpisodeCodesFromDisk() {
var root = new Folder(getShowFootageRoot());
var folders;
var codes = [];
var i;
if (!root.exists) {
return codes;
}
folders = root.getFiles();
for (i = 0; i < folders.length; i += 1) {
if (folders[i] instanceof Folder) {
codes.push(folders[i].name);
}
}
codes.sort();
return codes;
}
function addUniqueText(values, value) {
var i;
for (i = 0; i < values.length; i += 1) {
if (values[i] === value) {
return;
}
}
values.push(value);
}
function getShotCodesForEpisode(episodeCode) {
var root = getFootageGroupFolder(episodeCode);
var folders;
var shotCodes = [];
var i;
var shotCode;
if (!root.exists) {
return shotCodes;
}
folders = root.getFiles();
for (i = 0; i < folders.length; i += 1) {
if (folders[i] instanceof Folder) {
shotCode = extractShotCode(folders[i].name);
if (shotCode && folders[i].name.indexOf(shotCode) === 0) {
addUniqueText(shotCodes, shotCode);
}
}
}
shotCodes.sort();
return shotCodes;
}
// Two layouts in the wild and neither is being migrated -- moving delivered
// footage would break the file links in every AE project already pointing at
// it. DH2 groups a shot's plates under a {shotCode}/ folder; UNG scatters them
// directly in the episode folder:
//
// {show}/[{show}_{episode}/]{shotCode}/{shotCode}_{ELEM}_{VER}/ (standard)
// {show}/{show}_{episode}/{shotCode}_{ELEM}_{VER}/ (UNG)
//
// The grouped form wins outright where it exists -- an empty shot folder means
// "no plates yet", not "go look at the show root", where stray plate folders
// for the same shot code would otherwise be picked up as its footage.
function findSequenceFolders(shotCode, groupCode) {
var root = getFootageGroupFolder(groupCode);
var shotFolder;
var folders;
var matches = [];
var i;
if (!root.exists) {
return matches;
}
shotFolder = new Folder(root.fsName + "/" + shotCode);
if (shotFolder.exists) {
folders = shotFolder.getFiles();
for (i = 0; i < folders.length; i += 1) {
if (folders[i] instanceof Folder && !isNonShotFolderName(folders[i].name)) {
matches.push(folders[i]);
}
}
matches.sort();
return matches;
}
folders = root.getFiles();
for (i = 0; i < folders.length; i += 1) {
if (folders[i] instanceof Folder && folders[i].name.indexOf(shotCode + "_") === 0) {
matches.push(folders[i]);
}
}
matches.sort();
return matches;
}
function importEXRSequence(folder) {
var files = folder.getFiles("*.exr");
var importOptions;
var footage;
if (files.length < 1) {
return null;
}
files.sort();
importOptions = new ImportOptions(files[0]);
importOptions.sequence = true;
footage = app.project.importFile(importOptions);
footage.mainSource.conformFrameRate = 24;
return footage;
}
function addOCIOToLayer(layer) {
var ocio;
var outputProp;
try {
ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
ocio.enabled = false;
} catch (ocioError) {
return;
}
try {
outputProp = ocio.property("Output Color Space");
} catch (propNameError) {
outputProp = null;
}
if (!outputProp) {
try {
outputProp = ocio.property(2);
} catch (propIdxError) {
outputProp = null;
}
}
if (outputProp) {
try {
outputProp.setValue(93);
} catch (setValueError) {
}
}
}
function getDateString() {
var today = new Date();
return today.getFullYear() +
"/" +
("0" + (today.getMonth() + 1)).slice(-2) +
"/" +
("0" + today.getDate()).slice(-2);
}
function parseTimecodeToSeconds(tc, fps) {
var parts = String(tc).split(/[:;]/);
var hh;
var mm;
var ss;
var ff;
if (parts.length < 4) {
return 0;
}
hh = parseInt(parts[0], 10);
mm = parseInt(parts[1], 10);
ss = parseInt(parts[2], 10);
ff = parseInt(parts[3], 10);
return hh * 3600 + mm * 60 + ss + ff / (fps || 24);
}
function parseTimecodeToFrames(tc, fps) {
var parts = String(tc).split(/[:;]/);
var hh;
var mm;
var ss;
var ff;
if (parts.length < 4) {
return 0;
}
hh = parseInt(parts[0], 10);
mm = parseInt(parts[1], 10);
ss = parseInt(parts[2], 10);
ff = parseInt(parts[3], 10);
return (hh * 3600 + mm * 60 + ss) * Math.round(fps || 24) + ff;
}
function findOverlayLayer(comp) {
var i;
var layer;
for (i = 1; i <= comp.numLayers; i += 1) {
layer = comp.layer(i);
try {
if (layer.source && layer.source instanceof CompItem && layer.source.name === "UNG_VFX_OVERLAY") {
return layer;
}
} catch (layerError) {
}
}
return null;
}
function setEssentialProperty(layer, propName, value) {
try {
layer.property("Essential Properties").property(propName).setValue(String(value));
return true;
} catch (propertyError) {
return false;
}
}
function applyOutputModuleTemplate(outputModule, templateName) {
try {
outputModule.applyTemplate(templateName);
return true;
} catch (templateError) {
return false;
}
}
function queueCompWithOutput(comp, templateName, outputPath) {
var renderQueueItem;
var outputModule;
try {
renderQueueItem = app.project.renderQueue.items.add(comp);
outputModule = renderQueueItem.outputModule(1);
if (!applyOutputModuleTemplate(outputModule, templateName)) {
renderQueueItem.remove();
return false;
}
outputModule.file = new File(outputPath);
return true;
} catch (queueError) {
try {
if (renderQueueItem) {
renderQueueItem.remove();
}
} catch (removeError) {
}
return false;
}
}
function updateStatus(selectedCount, message) {
if (uiState.selectionText) {
uiState.selectionText.text = selectedCount + " comps selected";
}
if (uiState.lastActionText) {
uiState.lastActionText.text = message || "";
}
}
function refreshSelection() {
var comps = getSelectedComps();
var message = comps.length + " comps selected";
updateStatus(comps.length, message);
return comps;
}
function extractShotCode(name) {
var text = String(name);
var match = text.match(getShotCodeRegex());
if (match && match.length > 1) {
return match[1];
}
// A comp from a different show than the one selected in the panel: read it
// with the old pattern rather than reporting no shot code at all.
match = text.match(LEGACY_SHOT_CODE_REGEX);
if (match && match.length > 1) {
return match[1];
}
return null;
}
function getShotCodeFromComp(comp) {
return extractShotCode(comp.name) || comp.name;
}
function isPreviewComp(comp) {
return /_PREVIEW$/.test(comp.name);
}
function getMainCompForSelection(comp) {
var shotCode = getShotCodeFromComp(comp);
if (isPreviewComp(comp)) {
return findComp(shotCode);
}
return comp;
}
function getPreviewCompName(shotCode) {
return shotCode + "_PREVIEW";
}
function getPreviewCompForShot(shotCode) {
return findComp(getPreviewCompName(shotCode));
}
function getTargetComps() {
var comps = getSelectedComps();
if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
comps.push(app.project.activeItem);
}
return comps;
}
function disableLayerByName(comp, layerName) {
var i;
var layer;
for (i = 1; i <= comp.numLayers; i += 1) {
try {
layer = comp.layer(i);
if (layer.name === layerName || (layer.source && layer.source instanceof CompItem && layer.source.name === layerName)) {
layer.enabled = false;
}
} catch (disableError) {
}
}
}
function updateOverlayForComp(comp, shotCode, apiData) {
var overlayComp = findComp("UNG_VFX_OVERLAY");
var overlayLayer;
var essentialProperties;
var overlayShotCode;
if (!overlayComp || !apiData || !apiData.shot) {
return false;
}
try {
overlayShotCode = apiData.shot.shotCode || shotCode;
overlayLayer = findOverlayLayer(comp);
if (!overlayLayer) {
overlayLayer = comp.layers.add(overlayComp);
overlayLayer.moveToBeginning();
}
overlayLayer.enabled = true;
essentialProperties = overlayLayer.property("Essential Properties");
essentialProperties.property("DATE").setValue(getDateString());
essentialProperties.property("SHOT NAME").setValue(overlayShotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001"));
return true;
} catch (overlayError) {
return false;
}
}
function makeBatchResult(total) {
return {
total: total,
queued: 0,
renamed: 0,
skipped: 0,
failures: []
};
}
function addFailure(result, compName, reason) {
result.skipped += 1;
result.failures.push(compName + ": " + reason);
}
function fixCompNames() {
var comps = getSelectedComps();
var renamed = 0;
var i;
var shotCode;
app.beginUndoGroup("VFXReview Connector - Fix Comp Names");
for (i = 0; i < comps.length; i += 1) {
shotCode = extractShotCode(comps[i].name);
if (shotCode && comps[i].name !== shotCode) {
comps[i].name = shotCode;
renamed += 1;
}
}
app.endUndoGroup();
updateStatus(comps.length, renamed + " comp(s) renamed.");
}
function queueEXR() {
var comps = getSelectedComps();
var queued = 0;
var i;
var comp;
var lookupCode;
var shot;
var result = makeBatchResult(comps.length);
var shotCode;
var exrOutput;
var folderPath;
var outputPath;
app.beginUndoGroup("VFXReview Connector - Queue EXR");
for (i = 0; i < comps.length; i += 1) {
comp = comps[i];
lookupCode = getShotCodeFromComp(comp);
updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
shot = normalizeShotData(getShotData(lookupCode));
if (!shot || !shot.shotCode || !shot.exrOutput) {
addFailure(result, comp.name, "API lookup failed or missing EXR output");
continue;
}
shotCode = shot.shotCode;
exrOutput = shot.exrOutput;
disableLayerByName(comp, "UNG_VFX_OVERLAY");
disableLayerByName(comp, "_SHOW LUT");
folderPath = getShotExportDir(shot);
if (!ensureFolder(folderPath)) {
addFailure(result, comp.name, "Could not create output folder");
continue;
}
outputPath = folderPath + "/" + exrOutput + ".[#####].exr";
if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
queued += 1;
result.queued += 1;
} else {
addFailure(result, comp.name, "Could not queue EXR render");
}
}
app.endUndoGroup();
updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
}
function addOverlay() {
var comps = getSelectedComps();
var overlayComp = findComp("UNG_VFX_OVERLAY");
var overlayLayer;
var added = 0;
var updated = 0;
var skipped = 0;
var i;
var shotCode;
var data;
var dateString;
var essentialProperties;
if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) {
comps.push(app.project.activeItem);
}
if (comps.length < 1) {
updateStatus(0, "Please select a comp first.");
return;
}
if (!overlayComp) {
updateStatus(comps.length, "UNG_VFX_OVERLAY not found.");
return;
}
dateString = getDateString();
app.beginUndoGroup("VFXReview Connector - Add Overlay");
for (i = 0; i < comps.length; i += 1) {
shotCode = getShotCodeFromComp(comps[i]);
updateStatus(comps.length, "Updating overlay for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
data = getShotData(shotCode);
if (!data || !data.shot) {
skipped += 1;
continue;
}
try {
overlayLayer = findOverlayLayer(comps[i]);
if (!overlayLayer) {
overlayLayer = comps[i].layers.add(overlayComp);
overlayLayer.moveToBeginning();
added += 1;
}
essentialProperties = overlayLayer.property("Essential Properties");
essentialProperties.property("DATE").setValue(dateString);
essentialProperties.property("SHOT NAME").setValue(shotCode + "_cmp_TT_" + (data.shot.shotVersion || "v001"));
updated += 1;
} catch (overlayError) {
skipped += 1;
}
}
app.endUndoGroup();
updateStatus(comps.length, updated + " overlay(s) updated. " + added + " added. " + skipped + " skipped.");
}
function buildPreviewForShot(shotComp, shotCode, apiData) {
var templateComp = findComp("UNG_EXPORT_TEMPLATE");
var previewsFolder = findFolder("_PREVIEWS");
var shot;
var previewComp;
var slateLayer;
var frameCount;
var essentialProperties;
var shotLayer;
if (!templateComp || !apiData || !apiData.shot) {
return null;
}
shot = apiData.shot;
previewComp = null;
try {
previewComp = templateComp.duplicate();
previewComp.name = getPreviewCompName(shotCode);
if (previewsFolder) {
previewComp.parentFolder = previewsFolder;
}
previewComp.layer("SHOT").replaceSource(shotComp, false);
previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);
slateLayer = previewComp.layer("NETFLIX_SLATE");
frameCount = Math.round(shotComp.duration * shotComp.frameRate);
essentialProperties = slateLayer.property("Essential Properties");
essentialProperties.property(3).setValue(shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"));
setEssentialProperty(slateLayer, "Date", getDateString());
setEssentialProperty(slateLayer, "Desc", shot.description);
setEssentialProperty(slateLayer, "Notes", shot.notes);
essentialProperties.property(10).setValue(shotCode);
setEssentialProperty(slateLayer, "Episode", shot.episode);
setEssentialProperty(slateLayer, "Scene", shot.scene);
setEssentialProperty(slateLayer, "Frames", frameCount);
shotLayer = previewComp.layer("SHOT");
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
if (targetOutPoint > previewComp.duration) {
previewComp.duration = targetOutPoint;
}
shotLayer.outPoint = targetOutPoint;
previewComp.duration = targetOutPoint;
return previewComp;
} catch (previewError) {
try {
if (previewComp) {
previewComp.remove();
}
} catch (removePreviewError) {
}
return null;
}
}
// groupCode is the episode code for episodic shows and the scene for standard
// ones -- see getFootageGroupFolder for how each resolves on disk.
function buildShotFromCode(groupCode, shotCode) {
var existingShotComp = findComp(shotCode);
var sequenceFolders;
var footageItems = [];
var footageFolder = findFolder("_FOOTAGE_4K");
var precompFolder = findFolder("_PRECOMPS");
var episodeProjectFolder = findEpisodeProjectFolder(groupCode);
var apiData;
var footage;
var footageComp;
var shotComp;
var footageLayer;
var showLut;
var lutLayer;
var maxDuration = 0;
var width = 1920;
var height = 1080;
var primaryFootage = null;
var startInfo;
var i;
apiData = getShotData(shotCode);
if (!apiData || !apiData.shot) {
return null;
}
if (existingShotComp) {
updateOverlayForComp(existingShotComp, shotCode, apiData);
return existingShotComp;
}
sequenceFolders = findSequenceFolders(shotCode, groupCode);
if (sequenceFolders.length < 1) {
return null;
}
for (i = 0; i < sequenceFolders.length; i += 1) {
footage = importEXRSequence(sequenceFolders[i]);
if (footage) {
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;
}
}
}
if (footageItems.length < 1) {
return null;
}
if (!primaryFootage) {
primaryFootage = footageItems[0];
}
// Both comps start at the plate's native start timecode (as AE would
// when creating a comp from the footage), not at frame 0.
startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24);
buildState.lastStartSource = startInfo.source;
buildState.lastStartFrames = startInfo.frames;
footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24);
setCompStartFrame(footageComp, startInfo.frames);
if (precompFolder) {
footageComp.parentFolder = precompFolder;
}
for (i = footageItems.length - 1; i >= 0; i -= 1) {
footageLayer = footageComp.layers.add(footageItems[i]);
addOCIOToLayer(footageLayer);
}
shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24);
setCompStartFrame(shotComp, startInfo.frames);
if (episodeProjectFolder) {
shotComp.parentFolder = episodeProjectFolder;
}
shotComp.layers.add(footageComp);
showLut = findComp("_SHOW LUT");
if (showLut) {
lutLayer = shotComp.layers.add(showLut);
lutLayer.collapseTransformation = true;
}
updateOverlayForComp(shotComp, shotCode, apiData);
return shotComp;
}
function buildShotFromDropdown() {
var groupCode;
var shotCode;
var shotComp;
var isEpisodic = isEpisodicProject();
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 : "") + ")");
} else {
updateStatus(getSelectedComps().length, "Shot build failed: " + shotCode);
}
}
function buildPreviews() {
var comps = getTargetComps();
var templateComp = findComp("UNG_EXPORT_TEMPLATE");
var created = 0;
var skipped = 0;
var i;
var shotComp;
var shotCode;
var apiData;
var previewComp;
if (comps.length < 1) {
updateStatus(0, "Please select/open a shot comp.");
return;
}
if (!templateComp) {
updateStatus(comps.length, "UNG_EXPORT_TEMPLATE not found.");
return;
}
app.beginUndoGroup("VFXReview Connector - Build Preview");
for (i = 0; i < comps.length; i += 1) {
shotCode = getShotCodeFromComp(comps[i]);
shotComp = getMainCompForSelection(comps[i]);
updateStatus(comps.length, "Building preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
if (!shotComp) {
skipped += 1;
continue;
}
apiData = getShotData(shotCode);
if (!apiData || !apiData.shot) {
skipped += 1;
continue;
}
previewComp = buildPreviewForShot(shotComp, shotCode, apiData);
if (previewComp) {
created += 1;
} else {
skipped += 1;
}
}
app.endUndoGroup();
updateStatus(comps.length, created + " preview(s) built. " + skipped + " skipped.");
}
function queueReview(templateName, extension, label) {
var comps = getTargetComps();
var queued = 0;
var i;
var result = makeBatchResult(comps.length);
var shotCode;
var apiData;
var mainComp;
var previewComp;
var outputPath;
var folderPath;
app.beginUndoGroup("VFXReview Connector - Queue " + label);
for (i = 0; i < comps.length; i += 1) {
shotCode = getShotCodeFromComp(comps[i]);
updateStatus(comps.length, "Preparing " + label + " preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
apiData = getShotData(shotCode);
if (!apiData || !apiData.shot) {
addFailure(result, comps[i].name, "Shot lookup failed");
continue;
}
mainComp = getMainCompForSelection(comps[i]);
if (!mainComp) {
addFailure(result, comps[i].name, "Main shot comp not found");
continue;
}
updateOverlayForComp(mainComp, shotCode, apiData);
if (isPreviewComp(comps[i])) {
previewComp = comps[i];
} else {
previewComp = getPreviewCompForShot(shotCode);
if (!previewComp) {
previewComp = buildPreviewForShot(mainComp, shotCode, apiData);
}
}
if (!previewComp) {
addFailure(result, comps[i].name, "Preview comp not found or could not be created");
continue;
}
updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
folderPath = getShotExportDir(apiData.shot);
if (!ensureFolder(folderPath)) {
addFailure(result, comps[i].name, "Could not create output folder");
continue;
}
outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension;
if (queueCompWithOutput(previewComp, templateName, outputPath)) {
queued += 1;
result.queued += 1;
} else {
addFailure(result, comps[i].name, "Could not queue " + label + " render");
}
}
app.endUndoGroup();
updateStatus(comps.length, queued + " " + label + " renders queued. " + result.skipped + " skipped.");
}
function queueEXRPreview() {
var comps = getSelectedComps();
var queued = 0;
var i;
var comp;
var lookupCode;
var shot;
var result = makeBatchResult(comps.length);
var shotCode;
var folderPath;
var outputPath;
app.beginUndoGroup("VFXReview Connector - Queue EXR Preview");
for (i = 0; i < comps.length; i += 1) {
comp = comps[i];
lookupCode = getShotCodeFromComp(comp);
updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")");
shot = normalizeShotData(getShotData(lookupCode));
if (!shot || !shot.shotCode) {
addFailure(result, comp.name, "API lookup failed");
continue;
}
shotCode = shot.shotCode;
disableLayerByName(comp, "UNG_VFX_OVERLAY");
disableLayerByName(comp, "_SHOW LUT");
folderPath = getShotExportDir(shot);
if (!ensureFolder(folderPath)) {
addFailure(result, comp.name, "Could not create output folder");
continue;
}
outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (shot.shotVersion || "v001") + ".[#####].exr";
if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) {
queued += 1;
result.queued += 1;
} else {
addFailure(result, comp.name, "Could not queue EXR render");
}
}
app.endUndoGroup();
updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped.");
}
function queueMP4() {
queueReview("REVIEW_PREVIEW", "mp4", "MP4");
}
function queueMOV() {
queueReview("4444 Tri", "mov", "MOV");
}
function importExportedEXR() {
var comps = getTargetComps();
var exportsProjectFolder;
var comp;
var shotCode;
var shot;
var exportFolder;
var files;
var importOptions;
var footage;
var layer;
var i;
var imported = 0;
var skipped = 0;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
app.beginUndoGroup("VFXReview Connector - Import Exported EXR");
for (i = 0; i < comps.length; i += 1) {
comp = comps[i];
shotCode = getShotCodeFromComp(comp);
updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
shot = normalizeShotData(getShotData(shotCode));
if (!shot || !shot.shotCode || !shot.exrOutput) {
updateStatus(comps.length, "API lookup failed or missing EXR output for " + shotCode);
skipped += 1;
continue;
}
exportFolder = new Folder(getShotExportDir(shot));
if (!exportFolder.exists) {
updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName);
skipped += 1;
continue;
}
// Prefer the current review-render version naming convention:
// shotCode_cmp_TT_vXXX.####.exr
var currentVersion = shot.shotVersion || "v001";
var reviewPrefix = shot.shotCode + "_cmp_TT_" + currentVersion + ".";
files = exportFolder.getFiles(function (f) {
return f instanceof File &&
/\.exr$/i.test(f.name) &&
f.name.indexOf(reviewPrefix) === 0;
});
// Fallback to the broader queue EXR naming pattern if needed
if (!files || files.length < 1) {
var cmpPrefix = shot.shotCode + "_cmp_TT_";
files = exportFolder.getFiles(function (f) {
return f instanceof File &&
/\.exr$/i.test(f.name) &&
f.name.indexOf(cmpPrefix) === 0;
});
}
if (!files || files.length < 1) {
// Last resort: any EXR in the folder
files = exportFolder.getFiles("*.exr");
}
if (!files || files.length < 1) {
updateStatus(comps.length, "No EXR files found in " + exportFolder.fsName);
skipped += 1;
continue;
}
files.sort();
try {
importOptions = new ImportOptions(files[0]);
importOptions.sequence = true;
footage = app.project.importFile(importOptions);
footage.mainSource.conformFrameRate = 24;
} catch (importErr) {
updateStatus(comps.length, "Import failed for " + shot.shotCode + ": " + importErr.message);
skipped += 1;
continue;
}
exportsProjectFolder = findOrCreateFolder("_EXPORTS", null);
if (exportsProjectFolder) {
footage.parentFolder = exportsProjectFolder;
}
layer = comp.layers.add(footage);
layer.moveToBeginning();
imported += 1;
}
app.endUndoGroup();
updateStatus(comps.length, imported + " EXR export(s) imported. " + skipped + " skipped.");
}
function importRenders() {
var comps = getTargetComps();
var comp;
var shotCode;
var rendersRoot;
var rendersProjectFolder;
var allFiles;
var subFolders;
var importedFootage = [];
var addedLayers = [];
var layerIndices = [];
var footage;
var layer;
var i;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
comp = comps[0];
shotCode = getShotCodeFromComp(comp);
rendersRoot = new Folder("X:/shared_projects_2026/UNGO_VFX/production/renders/" + shotCode);
if (!rendersRoot.exists) {
updateStatus(getSelectedComps().length, "Renders folder not found: " + rendersRoot.fsName);
return;
}
rendersProjectFolder = findOrCreateFolder("_RENDERS", null);
app.beginUndoGroup("VFXReview Connector - Import Renders");
// Try subfolders first (each subfolder may be a separate pass/layer)
subFolders = rendersRoot.getFiles();
for (i = 0; i < subFolders.length; i += 1) {
if (subFolders[i] instanceof Folder) {
footage = importEXRSequence(subFolders[i]);
if (footage) {
footage.name = subFolders[i].name;
footage.mainSource.conformFrameRate = 24;
if (rendersProjectFolder) {
footage.parentFolder = rendersProjectFolder;
}
importedFootage.push(footage);
}
}
}
// Fall back to sequences directly in the root renders folder
if (importedFootage.length < 1) {
footage = importEXRSequence(rendersRoot);
if (footage) {
footage.name = shotCode + "_render";
footage.mainSource.conformFrameRate = 24;
if (rendersProjectFolder) {
footage.parentFolder = rendersProjectFolder;
}
importedFootage.push(footage);
}
}
if (importedFootage.length < 1) {
app.endUndoGroup();
updateStatus(getSelectedComps().length, "No EXR sequences found in renders folder.");
return;
}
// Add footage layers to comp and apply Extractor effect
for (i = 0; i < importedFootage.length; i += 1) {
layer = comp.layers.add(importedFootage[i]);
try {
var extractor = layer.property("Effects").addProperty("Extractor");
try {
extractor.property("Layers").setValue(1);
} catch (layersPropError) {
// Property name may differ — try by index (property 1 is typically "Layers")
try {
extractor.property(1).setValue(1);
} catch (layersIdxError) {
}
}
} catch (fxError) {
}
addedLayers.push(layer);
}
// Collect current layer indices for pre-compose
for (i = 0; i < addedLayers.length; i += 1) {
layerIndices.push(addedLayers[i].index);
}
// Pre-compose all render layers into {shotCode}_RENDER
try {
comp.layers.precompose(layerIndices, shotCode + "_RENDER", true);
updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported into " + shotCode + "_RENDER.");
} catch (precompError) {
updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported. Pre-compose failed: " + precompError.message);
}
app.endUndoGroup();
}
function prepDelivery() {
var comps = getTargetComps();
var i;
var j;
var comp;
var shotCode;
var shot;
var exrOutputBase;
var sourceFolder;
var deliveryFolderPath;
var exrFiles;
var expectedBaseName;
var result = makeBatchResult(comps.length);
var srcFile;
var srcBaseName;
var frameMatch;
var frameNum;
var destFileName;
var destPath;
var psLines;
var scriptFile;
var vbsFile;
var launched = 0;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
for (i = 0; i < comps.length; i += 1) {
comp = comps[i];
shotCode = getShotCodeFromComp(comp);
updateStatus(comps.length, "Preparing delivery for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
shot = normalizeShotData(getShotData(shotCode));
if (!shot || !shot.shotCode) {
addFailure(result, comp.name, "API lookup failed");
continue;
}
shotCode = shot.shotCode;
expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001");
exrOutputBase = shot.exrOutput || "";
sourceFolder = new Folder(getShotExportDir(shot));
if (!sourceFolder.exists) {
addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName);
continue;
}
// Prefer the current review-render version naming convention:
// shotCode_cmp_TT_vXXX.####.exr
exrFiles = null;
var currentVersion = shot.shotVersion || "v001";
var reviewPrefix = shotCode + "_cmp_TT_" + currentVersion + ".";
exrFiles = sourceFolder.getFiles(function (f) {
return f instanceof File &&
/\.exr$/i.test(f.name) &&
f.name.indexOf(reviewPrefix) === 0;
});
// Fallback to the broader queue EXR naming pattern if needed
if (!exrFiles || exrFiles.length < 1) {
var cmpPfx = shotCode + "_cmp_TT_";
exrFiles = sourceFolder.getFiles(function (f) {
return f instanceof File &&
/\.exr$/i.test(f.name) &&
f.name.indexOf(cmpPfx) === 0;
});
}
if (!exrFiles || exrFiles.length < 1) {
// Last resort: any EXR in the folder
exrFiles = sourceFolder.getFiles("*.exr");
}
if (!exrFiles || exrFiles.length < 1) {
addFailure(result, comp.name, "No EXR files found in " + sourceFolder.fsName);
continue;
}
exrFiles.sort();
var deliveryDate = getDateString().replace(/\//g, "");
deliveryFolderPath = getShowExportRoot() + "/DELIVERY" + getDeliveryGroupSegment(shotCode) +
"/" + deliveryDate + "_Delivery/" + shotCode;
if (!ensureFolder(deliveryFolderPath)) {
addFailure(result, comp.name, "Could not create delivery folder");
continue;
}
// Build a list of PowerShell Copy-Item commands
psLines = [];
for (j = 0; j < exrFiles.length; j += 1) {
srcFile = exrFiles[j];
srcBaseName = srcFile.name.replace(/\.exr$/i, "");
if (srcBaseName.indexOf(expectedBaseName + ".") === 0) {
destFileName = srcFile.name;
} else {
// Extract frame number and rename to delivery convention
frameMatch = srcFile.name.match(/\.(\d+)\.exr$/i);
if (!frameMatch) {
continue;
}
frameNum = frameMatch[1];
destFileName = expectedBaseName + "." + frameNum + ".exr";
}
destPath = (new File(deliveryFolderPath + "/" + destFileName)).fsName;
// Single-quoted PS strings are fully literal — safe for Windows paths
psLines.push("Copy-Item -LiteralPath '" + srcFile.fsName + "' -Destination '" + destPath + "'");
}
if (psLines.length < 1) {
addFailure(result, comp.name, "No valid EXR files to copy");
continue;
}
// Write PS1 script to temp folder
scriptFile = new File(Folder.temp.fsName + "/vfxdelivery_" + shotCode + ".ps1");
try {
scriptFile.open("w");
scriptFile.write(psLines.join("\r\n"));
// Self-delete once all transfers finish
scriptFile.write("\r\nRemove-Item -LiteralPath '" + scriptFile.fsName + "' -Force -ErrorAction SilentlyContinue");
scriptFile.close();
} catch (writeError) {
addFailure(result, comp.name, "Could not write delivery script: " + writeError.message);
continue;
}
// VBScript launcher: WScript.Shell.Run with bWaitOnReturn=False creates a fully
// independent process with no inherited pipe handles, so system.callSystem returns
// immediately and AE is never blocked regardless of how long the copy takes.
vbsFile = new File(Folder.temp.fsName + "/vfxdelivery_launch_" + shotCode + ".vbs");
try {
vbsFile.open("w");
vbsFile.write("Set sh = CreateObject(\"WScript.Shell\")\r\n");
vbsFile.write("sh.Run \"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File \" & Chr(34) & \"" + scriptFile.fsName + "\" & Chr(34), 0, False\r\n");
vbsFile.write("Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n");
vbsFile.write("fso.DeleteFile WScript.ScriptFullName\r\n");
vbsFile.close();
} catch (vbsError) {
addFailure(result, comp.name, "Could not write launcher script: " + vbsError.message);
continue;
}
system.callSystem("wscript.exe /nologo \"" + vbsFile.fsName + "\"");
launched += 1;
}
var statusMsg;
if (launched > 0) {
statusMsg = "Delivery transfer running in background for " + launched + " shot(s).";
} else {
statusMsg = "No shots queued for delivery.";
}
if (result.skipped > 0) {
statusMsg += " " + result.skipped + " skipped.";
}
updateStatus(comps.length, statusMsg);
}
function pullPictureLock() {
var comps = getTargetComps();
var comp;
var shotCode;
var episodePrefix;
var piclocksProjectFolder;
var piclockFootage;
var piclocksDir;
var files;
var foundFile;
var importOptions;
var apiData;
var shot;
var fps;
var handles;
var piclockStartTC;
var tcIn;
var tcOut;
var inOffset;
var outOffset;
var layer;
var i;
var item;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
comp = comps[0];
shotCode = getShotCodeFromComp(comp);
episodePrefix = getEpisodePrefix(shotCode); // e.g. "UNG_106"
// Fetch API data for seq timecodes
updateStatus(getSelectedComps().length, "Looking up " + shotCode + "...");
apiData = getShotData(shotCode);
if (!apiData || !apiData.shot) {
updateStatus(getSelectedComps().length, "API lookup failed for " + shotCode);
return;
}
shot = apiData.shot;
if (!shot.seqTimecodeStart || !shot.seqTimecodeEnd) {
updateStatus(getSelectedComps().length, "No sequence timecodes for " + shotCode + ". Import picture tracker CSV first.");
return;
}
// Find or create _PICLOCKS project folder
piclocksProjectFolder = findOrCreateFolder("_PICLOCKS", null);
piclockFootage = null;
// Search only inside the _PICLOCKS project folder for matching footage
if (piclocksProjectFolder) {
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FootageItem &&
item.parentFolder === piclocksProjectFolder &&
item.name.indexOf(episodePrefix + "_") === 0) {
piclockFootage = item;
break;
}
}
}
// Not in project — import from disk
if (!piclockFootage) {
piclocksDir = new Folder(getShowFootageRoot() + "/_PICLOCKS");
if (!piclocksDir.exists) {
updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName);
return;
}
files = piclocksDir.getFiles(episodePrefix + "_*");
foundFile = null;
if (files && files.length > 0) {
for (i = 0; i < files.length; i += 1) {
if (files[i] instanceof File) {
foundFile = files[i];
break;
}
}
}
if (!foundFile) {
updateStatus(getSelectedComps().length, "No piclock file matching " + episodePrefix + "_* found in " + piclocksDir.fsName);
return;
}
try {
importOptions = new ImportOptions(foundFile);
piclockFootage = app.project.importFile(importOptions);
if (piclocksProjectFolder) {
piclockFootage.parentFolder = piclocksProjectFolder;
}
} catch (importError) {
updateStatus(getSelectedComps().length, "Import failed: " + importError.message);
return;
}
}
// Calculate layer in/out offsets from seqTimecodes + handles
fps = comp.frameRate || 24;
handles = 8;
piclockStartTC = 3590;
try {
if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") {
piclockStartTC = piclockFootage.mainSource.startTimecode;
}
} catch (tcReadError) {
}
tcIn = parseTimecodeToSeconds(shot.seqTimecodeStart, fps);
tcOut = parseTimecodeToSeconds(shot.seqTimecodeEnd, fps);
inOffset = tcIn - piclockStartTC - (handles / fps);
outOffset = tcOut - piclockStartTC + (handles / fps);
if (inOffset < 0) {
inOffset = 0;
}
if (outOffset > piclockFootage.duration) {
outOffset = piclockFootage.duration;
}
app.beginUndoGroup("VFXReview Connector - Pull Picture Lock");
layer = comp.layers.add(piclockFootage);
var scale =
Math.max(
comp.width / piclockFootage.width,
comp.height / piclockFootage.height
) * 100;
layer.property("Scale")
.setValue([
scale,
scale
]);
layer.moveToBeginning();
// Offset startTime so the correct footage frame plays at comp time 0
layer.startTime = -inOffset;
layer.inPoint = 0;
layer.outPoint = outOffset - inOffset;
app.endUndoGroup();
updateStatus(
getSelectedComps().length,
"Piclock added: " + shot.seqTimecodeStart + " \u2192 " + shot.seqTimecodeEnd + " (+" + handles + "fr handles)."
);
}
function applyOCIOColorSpace() {
var comps = getTargetComps();
var applied = 0;
var skipped = 0;
var i;
var j;
var comp;
var layer;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
app.beginUndoGroup("VFXReview Connector - Apply OCIO");
for (i = 0; i < comps.length; i += 1) {
comp = comps[i];
for (j = 1; j <= comp.numLayers; j += 1) {
try {
layer = comp.layer(j);
// Only apply to footage layers (not comps, solids, etc.)
if (layer.source && layer.source instanceof FootageItem && layer.source.mainSource instanceof FileSource) {
addOCIOToLayer(layer);
applied += 1;
}
} catch (layerError) {
skipped += 1;
}
}
}
app.endUndoGroup();
updateStatus(comps.length, "OCIO applied to " + applied + " layer(s). " + skipped + " skipped.");
}
function findOrCreateFolder(name, parentFolder) {
var i;
var item;
if (!app.project) {
return null;
}
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FolderItem && item.name === name) {
if (!parentFolder || item.parentFolder === parentFolder) {
return item;
}
}
}
var newFolder = app.project.items.addFolder(name);
if (parentFolder) {
newFolder.parentFolder = parentFolder;
}
return newFolder;
}
function initialiseWorkspace() {
var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"];
var templatePath = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep";
var shotsFolder;
var epNum;
var epCode;
var templateFile;
var importOptions;
var importStatus;
var i;
if (!app.project) {
updateStatus(0, "No project open.");
return;
}
app.beginUndoGroup("VFXReview Connector - Initialise Workspace");
// Create __SHOTS with UNG_101 – UNG_117 subfolders
shotsFolder = findOrCreateFolder("__SHOTS", null);
for (epNum = 101; epNum <= 117; epNum += 1) {
epCode = "UNG_" + epNum;
findOrCreateFolder(epCode, shotsFolder);
}
// Create remaining top-level folders
for (i = 0; i < topFolderNames.length; i += 1) {
findOrCreateFolder(topFolderNames[i], null);
}
// Import AEP template
importStatus = "";
templateFile = new File(templatePath);
if (templateFile.exists) {
try {
importOptions = new ImportOptions(templateFile);
importOptions.importAs = ImportAsType.PROJECT;
app.project.importFile(importOptions);
importStatus = " Template imported.";
} catch (importError) {
importStatus = " Template import failed: " + importError.message;
}
} else {
importStatus = " Template file not found.";
}
app.endUndoGroup();
// ── Color settings (outside undo group — project-level settings) ──────
var colorStatus = "";
// 32-bit depth
try {
app.project.bitsPerChannel = 32;
colorStatus += " 32-bit depth set.";
} catch (bpcError) {
colorStatus += " Could not set 32-bit depth.";
}
// OCIO color management — AE 2022+ scripting API
try {
app.project.colorSettings.enabled = true;
colorStatus += " Color management enabled.";
} catch (csError) {
colorStatus += " Color management API unavailable (set manually).";
}
try {
// workingSpace index for OCIO ACES 1.2 varies by installation;
// attempt to match by name first, fall back to direct assignment.
var cs = app.project.colorSettings;
var found = false;
var wi;
if (cs.getWorkingSpaceList) {
var spaces = cs.getWorkingSpaceList();
for (wi = 0; wi < spaces.length; wi += 1) {
if (String(spaces[wi]).indexOf("ACES") !== -1 && String(spaces[wi]).indexOf("1.2") !== -1) {
cs.workingSpace = spaces[wi];
found = true;
break;
}
}
}
if (!found && cs.workingSpace !== undefined) {
cs.workingSpace = "ACES - ACES2065-1";
}
colorStatus += " OCIO ACES 1.2 applied.";
} catch (ocioError) {
colorStatus += " OCIO config must be set manually in Project Settings.";
}
updateStatus(getSelectedComps().length, "Workspace initialised." + importStatus + colorStatus);
}
function clearDropdown(dropdown) {
while (dropdown.items.length > 0) {
dropdown.remove(dropdown.items[0]);
}
}
function populateShotDropdown() {
var filterValue;
var shots;
var i;
var shotCode;
var item;
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown) {
return;
}
filterValue = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
shots = getShotsFromAPI(filterValue);
clearDropdown(uiState.shotDropdown);
for (i = 0; i < shots.length; i += 1) {
shotCode = getShotCodeValue(shots[i]);
if (shotCode) {
item = uiState.shotDropdown.add("item", shotCode);
item.shotCode = shotCode;
}
}
if (uiState.shotDropdown.items.length > 0) {
uiState.shotDropdown.selection = 0;
updateStatus(getSelectedComps().length, uiState.shotDropdown.items.length + " shot(s) found for " + uiState.episodeDropdown.selection.text);
} else {
updateStatus(getSelectedComps().length, "No shots found for " + uiState.episodeDropdown.selection.text);
}
}
function populateEpisodeOrSceneDropdown() {
var isEpisodic = isEpisodicProject();
var i;
var item;
if (!uiState.episodeDropdown || !uiState.shotDropdown) { return; }
clearDropdown(uiState.episodeDropdown);
clearDropdown(uiState.shotDropdown);
if (uiState.episodeLabel) {
uiState.episodeLabel.text = isEpisodic ? "Ep:" : "Scene:";
}
if (isEpisodic) {
var episodes = getEpisodesFromAPI();
for (i = 0; i < episodes.length; i += 1) {
var label = getEpisodeLabel(episodes[i]);
var episodeValue = getEpisodeValue(episodes[i]);
if (label && episodeValue) {
item = uiState.episodeDropdown.add("item", label);
item.filterValue = episodeValue;
item.episodeCode = deriveEpisodeCode(label);
if (!isEpisodeCode(item.episodeCode)) {
item.episodeCode = deriveEpisodeCode(episodeValue);
}
}
}
} else {
var scenes = getScenesFromAPI();
for (i = 0; i < scenes.length; i += 1) {
item = uiState.episodeDropdown.add("item", scenes[i]);
item.filterValue = scenes[i];
item.episodeCode = null;
}
}
if (uiState.episodeDropdown.items.length > 0) {
uiState.episodeDropdown.selection = 0;
populateShotDropdown();
} else {
updateStatus(getSelectedComps().length, isEpisodic ? "No episodes found." : "No scenes found.");
}
}
function populateEpisodeDropdown() {
populateEpisodeOrSceneDropdown();
}
function populateProjectDropdown() {
var projects;
var i;
var p;
var item;
var selectIdx = 0;
if (!uiState.projectDropdown) { return; }
projects = getProjectsFromAPI();
clearDropdown(uiState.projectDropdown);
clearDropdown(uiState.episodeDropdown);
clearDropdown(uiState.shotDropdown);
for (i = 0; i < projects.length; i += 1) {
p = projects[i];
item = uiState.projectDropdown.add("item", p.code + " — " + p.name);
item.projectData = p;
if (p.code === CONFIG.lastProjectCode) { selectIdx = i; }
}
if (uiState.projectDropdown.items.length > 0) {
uiState.projectDropdown.selection = selectIdx;
CURRENT_PROJECT = uiState.projectDropdown.items[selectIdx].projectData;
populateEpisodeOrSceneDropdown();
} else {
updateStatus(0, "No projects found — check server URL and token in Settings.");
}
}
function openSettingsDialog() {
var dlg = new Window("dialog", "VFXReview Connector — Settings", undefined);
dlg.orientation = "column";
dlg.alignChildren = ["fill", "top"];
dlg.spacing = 6;
dlg.margins = 16;
function addField(parent, lbl, val) {
var grp = parent.add("group");
grp.orientation = "row";
grp.alignChildren = ["fill", "center"];
var label = grp.add("statictext", undefined, lbl);
label.preferredSize = [110, 20];
var field = grp.add("edittext", undefined, val || "");
field.preferredSize = [280, 22];
return field;
}
var fUrl = addField(dlg, "Server URL:", CONFIG.baseUrl);
var fToken = addField(dlg, "API Token:", CONFIG.token);
var fExport = addField(dlg, "Export Root:", CONFIG.exportRoot);
var fFootage= addField(dlg, "Footage Root:", CONFIG.footageRoot);
var fBanner = addField(dlg, "Banner Path:", CONFIG.bannerImagePath);
var fEmail = addField(dlg, "Artist Email:", CONFIG.artistEmail);
var fHandles= addField(dlg, "Handle Frames:", String(CONFIG.handleFrames));
var 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;
saveConfig();
populateProjectDropdown();
}
}
// ── Shot Version Increment ────────────────────────────────────────────────
function incrementVersionString(ver) {
var match = String(ver).match(/^v(\d+)$/);
var num;
var str;
if (!match) {
return "v002";
}
num = parseInt(match[1], 10) + 1;
str = String(num);
while (str.length < 3) {
str = "0" + str;
}
return "v" + str;
}
function patchShotVersion(shotId, newVersion) {
var url = CONFIG.baseUrl + "/api/ext/shots/" + shotId;
// Inner double-quotes escaped for Windows cmd.exe
var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}";
var command;
var response;
try {
command = "curl -s -X PATCH " +
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Content-Type: application/json\" " +
"-H \"Accept: application/json\" " +
"-d \"" + body + "\" " +
"\"" + url + "\"";
response = system.callSystem(command);
} catch (patchError) {
return null;
}
return parseJSON(response);
}
function incrementShotVersions() {
var comps = getTargetComps();
var i;
var shotCode;
var data;
var shot;
var newVersion;
var patchResult;
var versionName;
var previewComp;
var slateLayer;
var essentialProperties;
var updated = 0;
var skipped = 0;
var lastVersion = "";
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
app.beginUndoGroup("VFXReview Connector - Increment Shot Version");
for (i = 0; i < comps.length; i += 1) {
shotCode = getShotCodeFromComp(comps[i]);
updateStatus(comps.length, "Incrementing " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
data = getShotData(shotCode);
if (!data || !data.shot) {
skipped += 1;
continue;
}
shot = data.shot;
newVersion = incrementVersionString(shot.shotVersion || "v001");
patchResult = patchShotVersion(shot.id, newVersion);
if (!patchResult || !patchResult.success) {
skipped += 1;
continue;
}
data.shot.shotVersion = newVersion;
versionName = shotCode + "_cmp_TT_" + newVersion;
// 1. Update SHOT NAME on UNG_VFX_OVERLAY inside the selected comp
updateOverlayForComp(comps[i], shotCode, data);
// 2. Update {shotCode}_PREVIEW slate VersionName if the comp exists
previewComp = findComp(getPreviewCompName(shotCode));
if (previewComp) {
try {
slateLayer = previewComp.layer("NETFLIX_SLATE");
essentialProperties = slateLayer.property("Essential Properties");
try {
essentialProperties.property("VersionName").setValue(versionName);
} catch (byNameError) {
essentialProperties.property(3).setValue(versionName);
}
} catch (slateError) {
}
}
lastVersion = newVersion;
updated += 1;
}
app.endUndoGroup();
if (updated > 0) {
updateStatus(comps.length, updated + " shot(s) → " + lastVersion + ". " + skipped + " skipped.");
} else {
updateStatus(comps.length, "No shots incremented. " + skipped + " failed.");
}
}
function setStartFrameFromTimecode() {
var comps = getTargetComps();
var i;
var shotCode;
var data;
var shot;
var fps;
var frameNumber;
var updated = 0;
var skipped = 0;
if (comps.length < 1) {
updateStatus(0, "Please select or open a comp first.");
return;
}
app.beginUndoGroup("VFXReview Connector - Set Start Timecode");
for (i = 0; i < comps.length; i += 1) {
shotCode = getShotCodeFromComp(comps[i]);
updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")");
data = getShotData(shotCode);
if (!data || !data.shot || !data.shot.timecodeStart) {
skipped += 1;
continue;
}
shot = data.shot;
fps = shot.fps || comps[i].frameRate || 24;
frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - CONFIG.handleFrames;
setCompStartFrame(comps[i], frameNumber);
updated += 1;
}
app.endUndoGroup();
updateStatus(comps.length, updated + " comp(s) start frame set. " + skipped + " skipped.");
}
// ── Render Pipeline (Queue Export → server render queue) ─────────────────
// Additive: the manual Queue EXR / MP4 / MOV / Prep Delivery buttons above
// stay as escape hatches. Queue Export submits a Render Manifest to
// POST /api/ext/exports; the SERVER increments the shot version and
// decides output paths, so no local render or Increment Version click is
// needed on this path.
function jsonEscapeString(value) {
var text = String(value);
var out = "";
var i;
var ch;
var code;
for (i = 0; i < text.length; i += 1) {
ch = text.charAt(i);
code = text.charCodeAt(i);
if (ch === "\"") {
out += "\\\"";
} else if (ch === "\\") {
out += "\\\\";
} else if (ch === "\n") {
out += "\\n";
} else if (ch === "\r") {
out += "\\r";
} else if (ch === "\t") {
out += "\\t";
} else if (code < 32) {
out += "\\u" + ("000" + code.toString(16)).slice(-4);
} else {
out += ch;
}
}
return out;
}
function jsonStringify(value) {
var parts;
var key;
var i;
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "string") {
return "\"" + jsonEscapeString(value) + "\"";
}
if (value instanceof Array) {
parts = [];
for (i = 0; i < value.length; i += 1) {
parts.push(jsonStringify(value[i]));
}
return "[" + parts.join(",") + "]";
}
parts = [];
for (key in value) {
if (value.hasOwnProperty(key) && value[key] !== undefined) {
parts.push("\"" + jsonEscapeString(key) + "\":" + jsonStringify(value[key]));
}
}
return "{" + parts.join(",") + "}";
}
function getConfigPath() {
return Folder.userData.fsName + "/VFXReviewConnector/config.json";
}
function loadConfig() {
try {
var f = new File(getConfigPath());
if (f.exists) {
f.encoding = "UTF-8";
f.open("r");
var raw = f.read();
f.close();
var parsed = parseJSON(raw);
if (parsed) {
for (var k in parsed) {
if (parsed.hasOwnProperty(k)) CONFIG[k] = parsed[k];
}
}
}
} catch (e) {}
}
function saveConfig() {
try {
var dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector");
if (!dir.exists) { dir.create(); }
var f = new File(getConfigPath());
f.encoding = "UTF-8";
f.open("w");
f.write(jsonStringify(CONFIG));
f.close();
} catch (e) {}
}
// POST JSON via a temp file (-d @file) — avoids cmd.exe quote-escaping
// problems for large bodies. Returns { status: httpCode, data: parsed }.
function postJSON(url, bodyObject) {
var bodyFile;
var command;
var response = null;
var markerIndex;
var statusCode = 0;
var jsonText;
bodyFile = new File(Folder.temp.fsName + "/vfxr_post_body.json");
try {
bodyFile.encoding = "UTF-8";
if (!bodyFile.open("w")) {
return { status: 0, data: null };
}
bodyFile.write(jsonStringify(bodyObject));
bodyFile.close();
command = "curl -s -X POST " +
"-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Content-Type: application/json\" " +
"-H \"Accept: application/json\" " +
"-d \"@" + bodyFile.fsName + "\" " +
"-w \"__HTTP__:%{http_code}\" " +
"\"" + url + "\"";
response = system.callSystem(command);
} catch (postError) {
response = null;
}
try {
bodyFile.remove();
} catch (removeError) {
}
if (!response) {
return { status: 0, data: null };
}
markerIndex = response.lastIndexOf("__HTTP__:");
if (markerIndex >= 0) {
statusCode = parseInt(response.substring(markerIndex + 9), 10) || 0;
jsonText = response.substring(0, markerIndex);
} else {
jsonText = response;
}
return { status: statusCode, data: parseJSON(jsonText) };
}
function getLatestExportData(shotCode) {
var url = CONFIG.baseUrl + "/api/ext/exports/latest" +
"?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
return getAPIData(url);
}
function isActiveExportStatus(status) {
return status === "QUEUED" || status === "RENDERING" ||
status === "VALIDATING" || status === "GENERATING_PREVIEW";
}
// Mirrors REGENERATABLE_STATUSES on the server: every state where the EXRs
// are known good, so only the preview needs rebuilding.
function isRegeneratableExportStatus(status) {
return status === "READY_FOR_QC" || status === "QC_FAILED" ||
status === "PREVIEW_FAILED";
}
function formatEtaShort(seconds) {
var mins;
var secs;
if (!seconds || seconds <= 0) {
return "";
}
mins = Math.floor(seconds / 60);
secs = Math.round(seconds % 60);
if (secs < 10) {
secs = "0" + secs;
}
return mins + ":" + secs;
}
function formatExportStatus(shotCode, exportInfo) {
var text;
var job;
var pct;
var eta;
if (!exportInfo) {
return shotCode + " — no exports yet";
}
text = shotCode + " " + (exportInfo.versionString || "") + " — " +
String(exportInfo.status || "").replace(/_/g, " ");
job = exportInfo.renderJob;
if (exportInfo.status === "RENDERING" && job) {
pct = Math.round((job.progress || 0) * 100);
text += " " + pct + "%";
eta = formatEtaShort(job.etaSeconds);
if (eta) {
text += " (ETA " + eta;
if (job.machineName) {
text += " on " + job.machineName;
}
text += ")";
} else if (job.machineName) {
text += " (" + job.machineName + ")";
}
} else if (job && job.errorMessage && /FAILED/.test(String(exportInfo.status))) {
text += ": " + String(job.errorMessage).substring(0, 60);
}
return text;
}
function setPipelineStatus(message) {
if (uiState.pipelineStatusText) {
uiState.pipelineStatusText.text = message || "";
}
}
function stopExportPolling() {
if (pipelineState.pollTaskId) {
try {
app.cancelTask(pipelineState.pollTaskId);
} catch (cancelError) {
}
pipelineState.pollTaskId = 0;
}
pipelineState.pollShotCode = null;
}
function pollExportTick() {
var shotCode = pipelineState.pollShotCode;
var data;
var exportInfo;
if (!shotCode) {
stopExportPolling();
return;
}
try {
data = getLatestExportData(shotCode);
if (data && data.export !== undefined) {
exportInfo = data.export;
setPipelineStatus(formatExportStatus(shotCode, exportInfo));
if (!exportInfo || !isActiveExportStatus(exportInfo.status)) {
stopExportPolling();
}
}
} catch (pollError) {
}
}
// While an export is active the panel polls every 15 s (spec §12.1); the
// task cancels itself once the export reaches a non-active state.
function startExportPolling(shotCode) {
stopExportPolling();
pipelineState.pollShotCode = shotCode;
try {
pipelineState.pollTaskId = app.scheduleTask("__vfxrExportPollTick()", 15000, true);
} catch (scheduleError) {
pipelineState.pollTaskId = 0;
}
}
function getStatusShotCode() {
var comps = getTargetComps();
var mainComp;
var code;
if (comps.length > 0) {
mainComp = getMainCompForSelection(comps[0]) || comps[0];
code = extractShotCode(mainComp.name);
if (code) {
return code;
}
}
if (uiState.shotDropdown && uiState.shotDropdown.selection) {
return String(uiState.shotDropdown.selection.text || "") || null;
}
return null;
}
// Slate fields carry forward: pull the previous submission's values when the
// selected shot changes, but never clobber edits the artist has just typed
// for the same shot.
function prefillSlateFields(shotCode, exportInfo) {
if (pipelineState.prefilledShotCode === shotCode) {
return;
}
if (uiState.submissionNoteField) {
uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
}
pipelineState.prefilledShotCode = shotCode;
}
function refreshExportStatus() {
var shotCode = getStatusShotCode();
var data;
if (!shotCode) {
setPipelineStatus("Select a comp or shot first");
return;
}
setPipelineStatus("Checking " + shotCode + "...");
data = getLatestExportData(shotCode);
if (!data || data.export === undefined) {
setPipelineStatus(shotCode + " — status check failed (server unreachable?)");
return;
}
prefillSlateFields(shotCode, data.export);
setPipelineStatus(formatExportStatus(shotCode, data.export));
if (data.export && isActiveExportStatus(data.export.status)) {
startExportPolling(shotCode);
}
}
function buildRenderManifest(comp, shot) {
var frameStart;
var frameEnd;
var frames;
var aepPath = app.project.file.fsName.replace(/\\/g, "/");
// 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;
}
return {
manifestVersion: 1,
projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "",
shotCode: shot.shotCode,
shotId: shot.id,
aepPath: aepPath,
compName: comp.name,
rendererType: "aerender",
outputDir: "auto",
outputPattern: "auto",
outputModuleTemplate: "EXR Sequence",
frameStart: frameStart,
frameEnd: frameEnd,
fps: Math.round(comp.frameRate * 1000) / 1000,
width: comp.width,
height: comp.height
};
}
function submitExport(manifest, urgent, force) {
var body = {
manifest: manifest,
force: force ? true : false
};
if (CONFIG.artistEmail) {
body.submittedByEmail = CONFIG.artistEmail;
}
if (urgent) {
body.priority = 20;
}
// submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
if (uiState.submissionNoteField) {
var note = trimText(uiState.submissionNoteField.text);
if (note) {
body.submissionNote = note;
}
}
return postJSON(CONFIG.baseUrl + "/api/ext/exports", body);
}
function queueExport() {
var comps = getTargetComps();
var result = makeBatchResult(comps.length);
var urgent = uiState.urgentCheckbox ? uiState.urgentCheckbox.value : false;
var i;
var comp;
var lookupCode;
var data;
var manifest;
var response;
var errorText;
var lastQueued = null;
var lastShotCode = null;
if (comps.length < 1) {
updateStatus(0, "Select or open a comp first.");
return;
}
if (!app.project || !app.project.file) {
updateStatus(comps.length, "Save the project first — the render farm opens the AEP from disk.");
return;
}
// The worker renders the saved file; unsaved changes would silently
// render stale comps.
try {
app.project.save();
} catch (saveError) {
updateStatus(comps.length, "Could not save the project: " + saveError);
return;
}
for (i = 0; i < comps.length; i += 1) {
comp = getMainCompForSelection(comps[i]);
if (!comp) {
addFailure(result, comps[i].name, "Main comp not found for preview comp");
continue;
}
lookupCode = getShotCodeFromComp(comp);
updateStatus(comps.length, "Queuing " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")...");
data = getShotData(lookupCode);
if (!data || !data.shot || !data.shot.id) {
addFailure(result, comp.name, "API lookup failed");
continue;
}
manifest = buildRenderManifest(comp, data.shot);
response = submitExport(manifest, urgent, false);
if (response.status === 409) {
errorText = (response.data && response.data.error) ?
response.data.error : "An active export already exists for this shot.";
if (confirm(lookupCode + ":\n" + errorText +
"\n\nForce supersede the active export and queue anyway?")) {
response = submitExport(manifest, urgent, true);
} else {
addFailure(result, comp.name, "Skipped (active export kept)");
continue;
}
}
if (response.status === 201 && response.data && response.data.export) {
result.queued += 1;
lastQueued = response.data.export;
lastShotCode = response.data.export.shotCode || lookupCode;
} else {
errorText = (response.data && response.data.error) ?
response.data.error : ("HTTP " + response.status);
addFailure(result, comp.name, errorText);
}
}
if (lastQueued) {
updateStatus(comps.length, "Queued " + lastQueued.versionString +
(urgent ? " (urgent)" : "") + " on render farm. " +
result.queued + " queued, " + result.skipped + " skipped.");
setPipelineStatus(formatExportStatus(lastShotCode, lastQueued));
startExportPolling(lastShotCode);
} else {
updateStatus(comps.length, "No exports queued. " +
(result.failures.length > 0 ? result.failures[0] : ""));
}
}
function retryLatestExport() {
var shotCode = getStatusShotCode();
var data;
var exportInfo;
var response;
if (!shotCode) {
setPipelineStatus("Select a comp or shot first");
return;
}
data = getLatestExportData(shotCode);
exportInfo = data ? data.export : null;
if (!exportInfo) {
setPipelineStatus(shotCode + " — no export to retry");
return;
}
if (!/FAILED$/.test(String(exportInfo.status))) {
setPipelineStatus(formatExportStatus(shotCode, exportInfo) + " — nothing to retry");
return;
}
response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/retry",
{ note: "Retry from AE panel" });
if (response.status === 200) {
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued");
startExportPolling(shotCode);
} else {
setPipelineStatus("Retry failed: " +
((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
}
}
// Rebuild the delivered MOV/MP4 after a slate/burn-in mistake: same version,
// same EXRs, freshly built slate. Slate text (description, scope, note)
// comes from the shot record, so it is fixed on the web app first — except
// the panel's Sub. Note field, which still wins as a per-export override.
function regenerateLatestPreview() {
var shotCode = getStatusShotCode();
var data;
var exportInfo;
var body = {};
var note;
var response;
if (!shotCode) {
setPipelineStatus("Select a comp or shot first");
return;
}
data = getLatestExportData(shotCode);
exportInfo = data ? data.export : null;
if (!exportInfo) {
setPipelineStatus(shotCode + " — no export to regenerate");
return;
}
if (!isRegeneratableExportStatus(String(exportInfo.status))) {
setPipelineStatus(formatExportStatus(shotCode, exportInfo) +
" — preview can only be regenerated once the render is done");
return;
}
if (!confirm("Rebuild the preview for " + shotCode + " " + exportInfo.versionString + "?\n\n" +
"The rendered EXRs are reused and the version number stays the same.\n" +
"The existing review version's media is replaced.")) {
return;
}
if (uiState.submissionNoteField) {
note = trimText(uiState.submissionNoteField.text);
if (note) {
body.submissionNote = note;
}
}
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — regenerating preview...");
response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/regenerate-preview", body);
if (response.status === 200) {
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — preview queued");
startExportPolling(shotCode);
} else {
setPipelineStatus("Regenerate failed: " +
((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
}
}
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;
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");
title.alignment = ["fill", "top"];
shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder");
shotBuilderPanel.orientation = "column";
shotBuilderPanel.alignChildren = ["fill", "top"];
shotBuilderPanel.margins = 10;
shotBuilderGroup = shotBuilderPanel.add("group");
shotBuilderGroup.orientation = "row";
shotBuilderGroup.alignChildren = ["fill", "center"];
shotBuilderGroup.spacing = 6;
// Project row
var projectRow = shotBuilderPanel.add("group");
projectRow.orientation = "row";
projectRow.alignChildren = ["fill", "center"];
projectRow.spacing = 6;
var projectLabel = projectRow.add("statictext", undefined, "Project:");
projectLabel.preferredSize = [42, 20];
uiState.projectDropdown = projectRow.add("dropdownlist", undefined, []);
uiState.projectDropdown.preferredSize = [220, 24];
var settingsBtn = projectRow.add("button", undefined, "\u2699");
settingsBtn.preferredSize = [26, 24];
settingsBtn.helpTip = "Settings";
// Episode / Scene row
var episodeRow = shotBuilderPanel.add("group");
episodeRow.orientation = "row";
episodeRow.alignChildren = ["fill", "center"];
episodeRow.spacing = 6;
uiState.episodeLabel = episodeRow.add("statictext", undefined, "Ep:");
uiState.episodeLabel.preferredSize = [42, 20];
uiState.episodeDropdown = episodeRow.add("dropdownlist", undefined, []);
uiState.episodeDropdown.preferredSize = [110, 24];
uiState.shotDropdown = episodeRow.add("dropdownlist", undefined, []);
uiState.shotDropdown.preferredSize = [130, 24];
shotBuilderButton = episodeRow.add("button", undefined, "Build Shot");
pipelinePanel = scrollContent.add("panel", undefined, "Render Pipeline");
pipelinePanel.orientation = "column";
pipelinePanel.alignChildren = ["fill", "top"];
pipelinePanel.margins = 8;
pipelinePanel.spacing = 4;
uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked");
pipelineRow1 = pipelinePanel.add("group");
pipelineRow1.orientation = "row";
pipelineRow1.alignChildren = ["fill", "center"];
pipelineRow1.spacing = 6;
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");
var slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
var slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Sub. Note:");
slateNoteLabel.preferredSize = [70, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: false });
uiState.submissionNoteField.preferredSize = [240, 22];
pipelineRow2 = pipelinePanel.add("group");
pipelineRow2.orientation = "row";
pipelineRow2.alignChildren = ["fill", "center"];
pipelineRow2.spacing = 6;
refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status");
retryExportButton = pipelineRow2.add("button", undefined, "Retry Export");
pipelineRow3 = pipelinePanel.add("group");
pipelineRow3.orientation = "row";
pipelineRow3.alignChildren = ["fill", "center"];
pipelineRow3.spacing = 6;
regeneratePreviewButton = pipelineRow3.add("button", undefined, "Regenerate Previews");
regeneratePreviewButton.helpTip =
"Rebuild the MOV/MP4 slate and burn-ins for the delivered version.\n" +
"Reuses the rendered EXRs and keeps the version number. Slate text is\n" +
"re-read from the shot, so fix it on the web app's Slate tab first.";
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");
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;
// ScriptUI silently swallows exceptions thrown inside onClick handlers.
settingsBtn.onClick = function () {
try {
openSettingsDialog();
} catch (settingsError) {
alert("VFXReview Connector \u2014 Settings error:\n" + settingsError.toString() +
(settingsError.line ? "\nLine " + settingsError.line : ""));
}
};
shotBuilderButton.onClick = buildShotFromDropdown;
queueExportButton.onClick = queueExport;
refreshExportButton.onClick = refreshExportStatus;
retryExportButton.onClick = retryLatestExport;
regeneratePreviewButton.onClick = regenerateLatestPreview;
refreshButton.onClick = refreshSelection;
fixNamesButton.onClick = fixCompNames;
overlayButton.onClick = addOverlay;
previewButton.onClick = buildPreviews;
exrButton.onClick = queueEXR;
exrPreviewButton.onClick = queueEXRPreview;
importExrButton.onClick = importExportedEXR;
prepDeliveryButton.onClick = prepDelivery;
mp4Button.onClick = queueMP4;
movButton.onClick = queueMOV;
ocioButton.onClick = applyOCIOColorSpace;
initWorkspaceButton.onClick = initialiseWorkspace;
importRendersButton.onClick = importRenders;
pictureLockButton.onClick = pullPictureLock;
setStartTCButton.onClick = setStartFrameFromTimecode;
incrementVersionButton.onClick = incrementShotVersions;
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();
if (panel instanceof Window) {
panel.center();
panel.show();
}
}(this));