/* 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 } 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; } 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 = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/episodes"; var data = getAPIData(url); return getListFromResponse(data, "episodes"); } function getShotsFromAPI(value) { var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; var param = isEpisodic ? "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; } function getShowExportRoot() { var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; return CONFIG.exportRoot + (showId ? "/" + showId : ""); } function getShowFootageRoot() { var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; return CONFIG.footageRoot + (showId ? "/" + showId : ""); } 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); } function getEpisodeRootFolder(episodeCode) { return new Folder(getShowFootageRoot() + "/" + episodeCode); } 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 = 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 = getShowExportRoot() + "/" + 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; var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; 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) { episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); } else { episodeCode = 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(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(getShowExportRoot())) { 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 = getShowExportRoot() + "/" + 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 = getShowExportRoot() + "/" + 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(getShowExportRoot() + "/" + 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(getShowExportRoot() + "/" + 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 = getShowExportRoot() + "/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(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 = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; 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 (!/^[A-Z]{3}_\d{3}$/.test(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"; } 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)); } } 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 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", "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"); 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; 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));