Files
vfxreview/VFXReviewConnector.jsx
T
twotalesanimation cc89415a29 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00

2923 lines
95 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
*/
var TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4=";
var BASE_URL = "http://localhost:3000";
var PROJECT_CODE = "UNG_S1";
var EXPORT_ROOT = "V:/_EXPORTS/UNG";
var FOOTAGE_ROOT = "V:/_FOOTAGE/UNG";
var BANNER_IMAGE_PATH = "V:/VFXReviewConnector/banner.png";
// Optional: artist email for pipeline export attribution (Queue Export)
var ARTIST_EMAIL = "chris@twotalesanimation.com";
// Handle frames included in plates before the shot's source TC in-point.
// Used to derive the plate's own start timecode from the API's timecodeStart.
var HANDLE_FRAMES = 8;
(function VFXReviewConnector(thisObj) {
var uiState = {
episodeDropdown: null,
shotDropdown: null,
selectionText: null,
lastActionText: null,
pipelineStatusText: null,
urgentCheckbox: null,
vfxScopeField: null,
submissionNoteField: null
};
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;
}
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 = BASE_URL + "/api/ext/shots/lookup" +
"?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + PROJECT_CODE;
command = "curl -s " +
"-H \"Authorization: Bearer " + 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 " + 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;
}
function deriveEpisodeCode(value) {
var text = String(value);
if (/^[A-Z]{3}_\d{3}$/.test(text)) {
return text;
}
if (/^\d+$/.test(text)) {
return "UNG_" + padEpisodeNumber(text);
}
return text;
}
function deriveEpisodeAPIValue(value) {
var text = String(value);
var 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 = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/episodes";
var data = getAPIData(url);
return getListFromResponse(data, "episodes");
}
function getShotsFromAPI(episodeValue) {
var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/shots?episode=" + encodeURIComponent(episodeValue);
var data = getAPIData(url);
return getListFromResponse(data, "shots");
}
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;
}
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) - HANDLE_FRAMES;
if (frames > 0) {
return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + HANDLE_FRAMES + "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);
}
function getEpisodeRootFolder(episodeCode) {
return new Folder(FOOTAGE_ROOT + "/" + episodeCode);
}
function getEpisodeCodesFromDisk() {
var root = new Folder(FOOTAGE_ROOT);
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 = getEpisodeRootFolder(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;
}
function findSequenceFolders(shotCode, episodeCode) {
var root = getEpisodeRootFolder(episodeCode);
var folders;
var matches = [];
var i;
if (!root.exists) {
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 match = String(name).match(/([A-Z]{3}_\d{3}_\d{3}_\d{3})/);
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 = EXPORT_ROOT + "/" + shotCode;
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;
}
}
function buildShotFromCode(episodeCode, shotCode) {
var existingShotComp = findComp(shotCode);
var sequenceFolders;
var footageItems = [];
var footageFolder = findFolder("_FOOTAGE_4K");
var precompFolder = findFolder("_PRECOMPS");
var episodeProjectFolder = findEpisodeProjectFolder(episodeCode);
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, episodeCode);
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 episodeCode;
var shotCode;
var shotComp;
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) {
updateStatus(getSelectedComps().length, "Choose an episode and shot first.");
return;
}
episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(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(episodeCode, 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;
app.beginUndoGroup("VFXReview Connector - Queue " + label);
if (!ensureFolder(EXPORT_ROOT)) {
app.endUndoGroup();
updateStatus(comps.length, "Could not create export folder.");
return;
}
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 + ")");
outputPath = EXPORT_ROOT + "/" + 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 = EXPORT_ROOT + "/" + shotCode;
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(EXPORT_ROOT + "/" + shot.shotCode);
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(EXPORT_ROOT + "/" + shotCode);
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 episodeCode = shotCode.substring(0, 7);
var deliveryDate = getDateString().replace(/\//g, "");
deliveryFolderPath = EXPORT_ROOT + "/DELIVERY/" + episodeCode + "/" + 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 = shotCode.substring(0, 7); // 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.substring(0, 7) === episodePrefix) {
piclockFootage = item;
break;
}
}
}
// Not in project — import from disk
if (!piclockFootage) {
piclocksDir = new Folder(FOOTAGE_ROOT + "/_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 episodeValue;
var shots;
var i;
var shotCode;
var item;
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown) {
return;
}
episodeValue = uiState.episodeDropdown.selection.episodeValue || uiState.episodeDropdown.selection.text;
shots = getShotsFromAPI(episodeValue);
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 populateEpisodeDropdown() {
var episodes = getEpisodesFromAPI();
var i;
var label;
var episodeValue;
var item;
if (!uiState.episodeDropdown || !uiState.shotDropdown) {
return;
}
clearDropdown(uiState.episodeDropdown);
clearDropdown(uiState.shotDropdown);
for (i = 0; i < episodes.length; i += 1) {
label = getEpisodeLabel(episodes[i]);
episodeValue = getEpisodeValue(episodes[i]);
if (label && episodeValue) {
item = uiState.episodeDropdown.add("item", label);
item.episodeValue = episodeValue;
item.episodeCode = deriveEpisodeCode(label);
if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) {
item.episodeCode = deriveEpisodeCode(episodeValue);
}
}
}
if (uiState.episodeDropdown.items.length > 0) {
uiState.episodeDropdown.selection = 0;
populateShotDropdown();
} else {
updateStatus(getSelectedComps().length, "No episodes found from API.");
}
}
// ── 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 = BASE_URL + "/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 " + 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) - HANDLE_FRAMES;
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(",") + "}";
}
// 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 " + 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 = BASE_URL + "/api/ext/exports/latest" +
"?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + PROJECT_CODE;
return getAPIData(url);
}
function isActiveExportStatus(status) {
return status === "QUEUED" || status === "RENDERING" ||
status === "VALIDATING" || status === "GENERATING_PREVIEW";
}
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.vfxScopeField) {
uiState.vfxScopeField.text = (exportInfo && exportInfo.vfxScope) ? exportInfo.vfxScope : "";
}
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: 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 (ARTIST_EMAIL) {
body.submittedByEmail = ARTIST_EMAIL;
}
if (urgent) {
body.priority = 20;
}
// Sent every time so edits stick; omitting them would make the server
// inherit the previous submission's values instead.
if (uiState.vfxScopeField) {
body.vfxScope = uiState.vfxScopeField.text;
}
if (uiState.submissionNoteField) {
body.submissionNote = uiState.submissionNoteField.text;
}
return postJSON(BASE_URL + "/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(BASE_URL + "/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));
}
}
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 slateScopeGroup;
var slateScopeLabel;
var slateNoteGroup;
var slateNoteLabel;
var pipelineRow1;
var pipelineRow2;
var queueExportButton;
var refreshExportButton;
var retryExportButton;
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", "top"];
win.spacing = 8;
win.margins = 12;
bannerFile = new File(BANNER_IMAGE_PATH);
if (bannerFile.exists) {
try {
bannerImage = win.add("image", undefined, bannerFile);
bannerImage.alignment = ["fit", "top"];
bannerImage.size = [360, 80];
} catch (bannerError) {
}
}
title = win.add("statictext", undefined, "VFXReview Connector");
title.alignment = ["fill", "top"];
shotBuilderPanel = win.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;
uiState.episodeDropdown = shotBuilderGroup.add("dropdownlist", undefined, []);
uiState.episodeDropdown.preferredSize = [110, 24];
uiState.shotDropdown = shotBuilderGroup.add("dropdownlist", undefined, []);
uiState.shotDropdown.preferredSize = [150, 24];
shotBuilderButton = shotBuilderGroup.add("button", undefined, "Build Shot");
pipelinePanel = win.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");
slateScopeGroup = pipelinePanel.add("group");
slateScopeGroup.orientation = "row";
slateScopeGroup.alignChildren = ["fill", "center"];
slateScopeGroup.spacing = 6;
slateScopeLabel = slateScopeGroup.add("statictext", undefined, "VFX Scope:");
slateScopeLabel.preferredSize = [90, 20];
uiState.vfxScopeField = slateScopeGroup.add("edittext", undefined, "");
uiState.vfxScopeField.preferredSize = [220, 22];
slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Submission Note:");
slateNoteLabel.preferredSize = [90, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: true });
uiState.submissionNoteField.preferredSize = [220, 48];
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");
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");
buttonsPanel = win.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 = win.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 = win.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 = win.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.episodeDropdown.onChange = populateShotDropdown;
shotBuilderButton.onClick = buildShotFromDropdown;
queueExportButton.onClick = queueExport;
refreshExportButton.onClick = refreshExportStatus;
retryExportButton.onClick = retryLatestExport;
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();
win.onResizing = win.onResize = function () {
this.layout.resize();
};
return win;
}
// app.scheduleTask evaluates in global scope — expose the poll tick there
$.global.__vfxrExportPollTick = pollExportTick;
var panel = buildUI(thisObj);
refreshSelection();
populateEpisodeDropdown();
if (panel instanceof Window) {
panel.center();
panel.show();
}
}(this));