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