From 7dcb9303e5bdf15d6a694f4dbe8f7997869d8151 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:07:16 +0200 Subject: [PATCH] updated export paths --- RenderWorker/scripts/test-preview-build.ps1 | 2 +- VFXReviewConnector.jsx | 423 ++++++++++++++---- VFXReviewConnector.md | 96 +++- .../ext/projects/[projectCode]/shots/route.ts | 2 + lib/render-pipeline/config.ts | 2 +- lib/render-pipeline/exports.ts | 10 +- .../migration.sql | 7 + 7 files changed, 452 insertions(+), 90 deletions(-) create mode 100644 prisma/migrations/20260807010000_render_output_root_drop_renders/migration.sql diff --git a/RenderWorker/scripts/test-preview-build.ps1 b/RenderWorker/scripts/test-preview-build.ps1 index 3cddf9e..cfb9413 100644 --- a/RenderWorker/scripts/test-preview-build.ps1 +++ b/RenderWorker/scripts/test-preview-build.ps1 @@ -15,7 +15,7 @@ and then quit it. .EXAMPLE - .\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG\renders\UNG_S1\111\UNG_111_001_030\v002" ` + .\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG_S1\111\UNG_111_001_030\v002" ` -ShotCode "UNG_111_001_030" -Version "v002" .EXAMPLE diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index 1857a8c..3cbc50f 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -3,22 +3,27 @@ Adobe After Effects 2024+ dockable ScriptUI panel */ -var TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4="; -var BASE_URL = "http://localhost:3000"; -var PROJECT_CODE = "UNG_S1"; -var EXPORT_ROOT = "V:/_EXPORTS/UNG"; -var FOOTAGE_ROOT = "V:/_FOOTAGE/UNG"; -var BANNER_IMAGE_PATH = "V:/VFXReviewConnector/banner.png"; -// Optional: artist email for pipeline export attribution (Queue Export) -var ARTIST_EMAIL = "chris@twotalesanimation.com"; -// Handle frames included in plates before the shot's source TC in-point. -// Used to derive the plate's own start timecode from the API's timecodeStart. -var HANDLE_FRAMES = 8; +// Default config — overridden by stored settings at runtime +var CONFIG = { + baseUrl: "http://localhost:3000", + token: "", + exportRoot: "", + footageRoot: "", + bannerImagePath:"", + artistEmail: "", + handleFrames: 8, + lastProjectCode:"" +}; + +var SETTINGS_GROUP = "VFXReviewConnector"; +var SETTINGS_KEY = "cfg"; (function VFXReviewConnector(thisObj) { var uiState = { + projectDropdown: null, episodeDropdown: null, shotDropdown: null, + episodeLabel: null, selectionText: null, lastActionText: null, pipelineStatusText: null, @@ -26,6 +31,8 @@ var HANDLE_FRAMES = 8; submissionNoteField: null }; + var CURRENT_PROJECT = null; // { code, name, showId, projectType } + var pipelineState = { pollShotCode: null, pollTaskId: 0, @@ -57,6 +64,14 @@ var HANDLE_FRAMES = 8; return null; } + // ExtendScript is ES3 — String.prototype.trim does not exist. + function trimText(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value).replace(/^\s+/, "").replace(/\s+$/, ""); + } + function getSelectedComps() { var comps = []; var selection; @@ -83,12 +98,12 @@ var HANDLE_FRAMES = 8; var parsed; try { - url = BASE_URL + "/api/ext/shots/lookup" + + url = CONFIG.baseUrl + "/api/ext/shots/lookup" + "?shotCode=" + encodeURIComponent(shotCode) + - "&projectCode=" + PROJECT_CODE; + "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : ""); command = "curl -s " + - "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Authorization: Bearer " + CONFIG.token + "\" " + "-H \"Accept: application/json\" " + "\"" + url + "\""; @@ -112,7 +127,7 @@ var HANDLE_FRAMES = 8; try { command = "curl -s " + - "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Authorization: Bearer " + CONFIG.token + "\" " + "-H \"Accept: application/json\" " + "\"" + url + "\""; @@ -229,19 +244,39 @@ var HANDLE_FRAMES = 8; } function getEpisodesFromAPI() { - var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/episodes"; + var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/episodes"; var data = getAPIData(url); - return getListFromResponse(data, "episodes"); } - function getShotsFromAPI(episodeValue) { - var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/shots?episode=" + encodeURIComponent(episodeValue); + function getShotsFromAPI(value) { + var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; + var param = isEpisodic ? "episode" : "scene"; + var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?" + param + "=" + encodeURIComponent(value); var data = getAPIData(url); - return getListFromResponse(data, "shots"); } + function getScenesFromAPI() { + var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?limit=500"; + var data = getAPIData(url); + var shots = getListFromResponse(data, "shots"); + var seen = {}; + var scenes = []; + for (var i = 0; i < shots.length; i += 1) { + var sc = shots[i].scene || ""; + if (sc && !seen[sc]) { seen[sc] = true; scenes.push(sc); } + } + scenes.sort(); + return scenes; + } + + function getProjectsFromAPI() { + var url = CONFIG.baseUrl + "/api/ext/projects"; + var data = getAPIData(url); + return getListFromResponse(data, "projects"); + } + function normalizeShotData(data) { if (!data) { return null; @@ -270,6 +305,16 @@ var HANDLE_FRAMES = 8; return null; } + function getShowExportRoot() { + var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; + return CONFIG.exportRoot + (showId ? "/" + showId : ""); + } + + function getShowFootageRoot() { + var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; + return CONFIG.footageRoot + (showId ? "/" + showId : ""); + } + function ensureFolder(path) { var folder = new Folder(path); var parent; @@ -397,9 +442,9 @@ var HANDLE_FRAMES = 8; } if (apiShot && apiShot.timecodeStart) { - frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - HANDLE_FRAMES; + frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - CONFIG.handleFrames; if (frames > 0) { - return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + HANDLE_FRAMES + "f" }; + return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + CONFIG.handleFrames + "f" }; } } @@ -462,11 +507,11 @@ var HANDLE_FRAMES = 8; } function getEpisodeRootFolder(episodeCode) { - return new Folder(FOOTAGE_ROOT + "/" + episodeCode); + return new Folder(getShowFootageRoot() + "/" + episodeCode); } function getEpisodeCodesFromDisk() { - var root = new Folder(FOOTAGE_ROOT); + var root = new Folder(getShowFootageRoot()); var folders; var codes = []; var i; @@ -867,7 +912,7 @@ var HANDLE_FRAMES = 8; disableLayerByName(comp, "UNG_VFX_OVERLAY"); disableLayerByName(comp, "_SHOW LUT"); - folderPath = EXPORT_ROOT + "/" + shotCode; + folderPath = getShowExportRoot() + "/" + shotCode; if (!ensureFolder(folderPath)) { addFailure(result, comp.name, "Could not create output folder"); continue; @@ -1108,13 +1153,19 @@ var HANDLE_FRAMES = 8; var episodeCode; var shotCode; var shotComp; + var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) { - updateStatus(getSelectedComps().length, "Choose an episode and shot first."); + updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first."); return; } - episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); + // For episodic, derive full episode code; for standard pass the selected scene value + if (isEpisodic) { + episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); + } else { + episodeCode = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text; + } shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text; app.beginUndoGroup("VFXReview Connector - Build Shot"); @@ -1193,7 +1244,7 @@ var HANDLE_FRAMES = 8; var outputPath; app.beginUndoGroup("VFXReview Connector - Queue " + label); - if (!ensureFolder(EXPORT_ROOT)) { + if (!ensureFolder(getShowExportRoot())) { app.endUndoGroup(); updateStatus(comps.length, "Could not create export folder."); return; @@ -1231,7 +1282,7 @@ var HANDLE_FRAMES = 8; } updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); - outputPath = EXPORT_ROOT + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension; + outputPath = getShowExportRoot() + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension; if (queueCompWithOutput(previewComp, templateName, outputPath)) { queued += 1; result.queued += 1; @@ -1272,7 +1323,7 @@ var HANDLE_FRAMES = 8; disableLayerByName(comp, "UNG_VFX_OVERLAY"); disableLayerByName(comp, "_SHOW LUT"); - folderPath = EXPORT_ROOT + "/" + shotCode; + folderPath = getShowExportRoot() + "/" + shotCode; if (!ensureFolder(folderPath)) { addFailure(result, comp.name, "Could not create output folder"); continue; @@ -1333,7 +1384,7 @@ var HANDLE_FRAMES = 8; continue; } - exportFolder = new Folder(EXPORT_ROOT + "/" + shot.shotCode); + exportFolder = new Folder(getShowExportRoot() + "/" + shot.shotCode); if (!exportFolder.exists) { updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName); skipped += 1; @@ -1545,7 +1596,7 @@ var HANDLE_FRAMES = 8; expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"); exrOutputBase = shot.exrOutput || ""; - sourceFolder = new Folder(EXPORT_ROOT + "/" + shotCode); + sourceFolder = new Folder(getShowExportRoot() + "/" + shotCode); if (!sourceFolder.exists) { addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName); continue; @@ -1586,7 +1637,7 @@ var HANDLE_FRAMES = 8; var episodeCode = shotCode.substring(0, 7); var deliveryDate = getDateString().replace(/\//g, ""); - deliveryFolderPath = EXPORT_ROOT + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode; + deliveryFolderPath = getShowExportRoot() + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode; if (!ensureFolder(deliveryFolderPath)) { addFailure(result, comp.name, "Could not create delivery folder"); continue; @@ -1731,7 +1782,7 @@ var HANDLE_FRAMES = 8; // Not in project — import from disk if (!piclockFootage) { - piclocksDir = new Folder(FOOTAGE_ROOT + "/_PICLOCKS"); + piclocksDir = new Folder(getShowFootageRoot() + "/_PICLOCKS"); if (!piclocksDir.exists) { updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName); return; @@ -1978,7 +2029,7 @@ var HANDLE_FRAMES = 8; } function populateShotDropdown() { - var episodeValue; + var filterValue; var shots; var i; var shotCode; @@ -1988,8 +2039,8 @@ var HANDLE_FRAMES = 8; return; } - episodeValue = uiState.episodeDropdown.selection.episodeValue || uiState.episodeDropdown.selection.text; - shots = getShotsFromAPI(episodeValue); + filterValue = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text; + shots = getShotsFromAPI(filterValue); clearDropdown(uiState.shotDropdown); for (i = 0; i < shots.length; i += 1) { @@ -2008,38 +2059,132 @@ var HANDLE_FRAMES = 8; } } - function populateEpisodeDropdown() { - var episodes = getEpisodesFromAPI(); + function populateEpisodeOrSceneDropdown() { + var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; var i; - var label; - var episodeValue; var item; - if (!uiState.episodeDropdown || !uiState.shotDropdown) { - return; - } + if (!uiState.episodeDropdown || !uiState.shotDropdown) { return; } clearDropdown(uiState.episodeDropdown); clearDropdown(uiState.shotDropdown); - for (i = 0; i < episodes.length; i += 1) { - label = getEpisodeLabel(episodes[i]); - episodeValue = getEpisodeValue(episodes[i]); - if (label && episodeValue) { - item = uiState.episodeDropdown.add("item", label); - item.episodeValue = episodeValue; - item.episodeCode = deriveEpisodeCode(label); - if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) { - item.episodeCode = deriveEpisodeCode(episodeValue); + if (uiState.episodeLabel) { + uiState.episodeLabel.text = isEpisodic ? "Ep:" : "Scene:"; + } + + if (isEpisodic) { + var episodes = getEpisodesFromAPI(); + for (i = 0; i < episodes.length; i += 1) { + var label = getEpisodeLabel(episodes[i]); + var episodeValue = getEpisodeValue(episodes[i]); + if (label && episodeValue) { + item = uiState.episodeDropdown.add("item", label); + item.filterValue = episodeValue; + item.episodeCode = deriveEpisodeCode(label); + if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) { + item.episodeCode = deriveEpisodeCode(episodeValue); + } } } + } else { + var scenes = getScenesFromAPI(); + for (i = 0; i < scenes.length; i += 1) { + item = uiState.episodeDropdown.add("item", scenes[i]); + item.filterValue = scenes[i]; + item.episodeCode = null; + } } if (uiState.episodeDropdown.items.length > 0) { uiState.episodeDropdown.selection = 0; populateShotDropdown(); } else { - updateStatus(getSelectedComps().length, "No episodes found from API."); + updateStatus(getSelectedComps().length, isEpisodic ? "No episodes found." : "No scenes found."); + } + } + + function populateEpisodeDropdown() { + populateEpisodeOrSceneDropdown(); + } + + function populateProjectDropdown() { + var projects; + var i; + var p; + var item; + var selectIdx = 0; + + if (!uiState.projectDropdown) { return; } + + projects = getProjectsFromAPI(); + clearDropdown(uiState.projectDropdown); + clearDropdown(uiState.episodeDropdown); + clearDropdown(uiState.shotDropdown); + + for (i = 0; i < projects.length; i += 1) { + p = projects[i]; + item = uiState.projectDropdown.add("item", p.code + " — " + p.name); + item.projectData = p; + if (p.code === CONFIG.lastProjectCode) { selectIdx = i; } + } + + if (uiState.projectDropdown.items.length > 0) { + uiState.projectDropdown.selection = selectIdx; + CURRENT_PROJECT = uiState.projectDropdown.items[selectIdx].projectData; + populateEpisodeOrSceneDropdown(); + } else { + updateStatus(0, "No projects found — check server URL and token in Settings."); + } + } + + function openSettingsDialog() { + var dlg = new Window("dialog", "VFXReview Connector — Settings", undefined); + dlg.orientation = "column"; + dlg.alignChildren = ["fill", "top"]; + dlg.spacing = 6; + dlg.margins = 16; + + function addField(parent, lbl, val) { + var grp = parent.add("group"); + grp.orientation = "row"; + grp.alignChildren = ["fill", "center"]; + var label = grp.add("statictext", undefined, lbl); + label.preferredSize = [110, 20]; + var field = grp.add("edittext", undefined, val || ""); + field.preferredSize = [280, 22]; + return field; + } + + var fUrl = addField(dlg, "Server URL:", CONFIG.baseUrl); + var fToken = addField(dlg, "API Token:", CONFIG.token); + var fExport = addField(dlg, "Export Root:", CONFIG.exportRoot); + var fFootage= addField(dlg, "Footage Root:", CONFIG.footageRoot); + var fBanner = addField(dlg, "Banner Path:", CONFIG.bannerImagePath); + var fEmail = addField(dlg, "Artist Email:", CONFIG.artistEmail); + var fHandles= addField(dlg, "Handle Frames:", String(CONFIG.handleFrames)); + + var btnRow = dlg.add("group"); + btnRow.orientation = "row"; + btnRow.alignChildren = ["fill", "center"]; + btnRow.spacing = 8; + // Using named "ok"/"cancel" — the only reliable way to capture dialog result in ScriptUI + btnRow.add("button", undefined, "Save", { name: "ok" }); + btnRow.add("button", undefined, "Cancel", { name: "cancel" }); + + dlg.layout.layout(true); + dlg.center(); + + if (dlg.show() === 1) { + CONFIG.baseUrl = trimText(fUrl.text) || CONFIG.baseUrl; + CONFIG.token = trimText(fToken.text); + CONFIG.exportRoot = trimText(fExport.text); + CONFIG.footageRoot = trimText(fFootage.text); + CONFIG.bannerImagePath = trimText(fBanner.text); + CONFIG.artistEmail = trimText(fEmail.text); + CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8; + saveConfig(); + populateProjectDropdown(); } } @@ -2063,7 +2208,7 @@ var HANDLE_FRAMES = 8; } function patchShotVersion(shotId, newVersion) { - var url = BASE_URL + "/api/ext/shots/" + shotId; + var url = CONFIG.baseUrl + "/api/ext/shots/" + shotId; // Inner double-quotes escaped for Windows cmd.exe var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}"; var command; @@ -2071,7 +2216,7 @@ var HANDLE_FRAMES = 8; try { command = "curl -s -X PATCH " + - "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Authorization: Bearer " + CONFIG.token + "\" " + "-H \"Content-Type: application/json\" " + "-H \"Accept: application/json\" " + "-d \"" + body + "\" " + @@ -2188,7 +2333,7 @@ var HANDLE_FRAMES = 8; shot = data.shot; fps = shot.fps || comps[i].frameRate || 24; - frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - HANDLE_FRAMES; + frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - CONFIG.handleFrames; setCompStartFrame(comps[i], frameNumber); updated += 1; } @@ -2263,6 +2408,40 @@ var HANDLE_FRAMES = 8; 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) { @@ -2283,7 +2462,7 @@ var HANDLE_FRAMES = 8; bodyFile.close(); command = "curl -s -X POST " + - "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Authorization: Bearer " + CONFIG.token + "\" " + "-H \"Content-Type: application/json\" " + "-H \"Accept: application/json\" " + "-d \"@" + bodyFile.fsName + "\" " + @@ -2315,9 +2494,9 @@ var HANDLE_FRAMES = 8; } function getLatestExportData(shotCode) { - var url = BASE_URL + "/api/ext/exports/latest" + + var url = CONFIG.baseUrl + "/api/ext/exports/latest" + "?shotCode=" + encodeURIComponent(shotCode) + - "&projectCode=" + PROJECT_CODE; + "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : ""); return getAPIData(url); } @@ -2501,7 +2680,7 @@ var HANDLE_FRAMES = 8; return { manifestVersion: 1, - projectCode: PROJECT_CODE, + projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "", shotCode: shot.shotCode, shotId: shot.id, aepPath: aepPath, @@ -2524,20 +2703,20 @@ var HANDLE_FRAMES = 8; force: force ? true : false }; - if (ARTIST_EMAIL) { - body.submittedByEmail = ARTIST_EMAIL; + 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 = uiState.submissionNoteField.text; - if (note && note.trim()) { + var note = trimText(uiState.submissionNoteField.text); + if (note) { body.submissionNote = note; } } - return postJSON(BASE_URL + "/api/ext/exports", body); + return postJSON(CONFIG.baseUrl + "/api/ext/exports", body); } function queueExport() { @@ -2646,7 +2825,7 @@ var HANDLE_FRAMES = 8; return; } - response = postJSON(BASE_URL + "/api/ext/exports/" + exportInfo.id + "/retry", + response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + exportInfo.id + "/retry", { note: "Retry from AE panel" }); if (response.status === 200) { setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued"); @@ -2703,24 +2882,42 @@ var HANDLE_FRAMES = 8; var statusPanel; win.orientation = "column"; - win.alignChildren = ["fill", "top"]; - win.spacing = 8; - win.margins = 12; + win.alignChildren = ["fill", "fill"]; + win.spacing = 0; + win.margins = 0; - bannerFile = new File(BANNER_IMAGE_PATH); + 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 = win.add("image", undefined, bannerFile); + bannerImage = scrollContent.add("image", undefined, bannerFile); bannerImage.alignment = ["fit", "top"]; bannerImage.size = [360, 80]; } catch (bannerError) { } } - title = win.add("statictext", undefined, "VFXReview Connector"); + title = scrollContent.add("statictext", undefined, "VFXReview Connector"); title.alignment = ["fill", "top"]; - shotBuilderPanel = win.add("panel", undefined, "Shot Builder"); + shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder"); shotBuilderPanel.orientation = "column"; shotBuilderPanel.alignChildren = ["fill", "top"]; shotBuilderPanel.margins = 10; @@ -2730,13 +2927,33 @@ var HANDLE_FRAMES = 8; shotBuilderGroup.alignChildren = ["fill", "center"]; shotBuilderGroup.spacing = 6; - uiState.episodeDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); - uiState.episodeDropdown.preferredSize = [110, 24]; - uiState.shotDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); - uiState.shotDropdown.preferredSize = [150, 24]; - shotBuilderButton = shotBuilderGroup.add("button", undefined, "Build Shot"); + // 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"; - pipelinePanel = win.add("panel", undefined, "Render Pipeline"); + // 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; @@ -2767,7 +2984,7 @@ var HANDLE_FRAMES = 8; refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status"); retryExportButton = pipelineRow2.add("button", undefined, "Retry Export"); - buttonsPanel = win.add("panel", undefined, "Actions"); + buttonsPanel = scrollContent.add("panel", undefined, "Actions"); buttonsPanel.orientation = "column"; buttonsPanel.alignChildren = ["fill", "top"]; buttonsPanel.margins = 8; @@ -2837,21 +3054,21 @@ var HANDLE_FRAMES = 8; btnRow7.spacing = 6; incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version"); - workspacePanel = win.add("panel", undefined, "Workspace"); + 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 = win.add("panel", undefined, "Color Space"); + 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 = win.add("panel", undefined, "Status"); + statusPanel = scrollContent.add("panel", undefined, "Status"); statusPanel.orientation = "column"; statusPanel.alignChildren = ["fill", "top"]; statusPanel.margins = 10; @@ -2859,7 +3076,26 @@ var HANDLE_FRAMES = 8; uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected"); uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready"); + uiState.projectDropdown.onChange = function () { + if (uiState.projectDropdown.selection) { + CURRENT_PROJECT = uiState.projectDropdown.selection.projectData; + if (CURRENT_PROJECT) { + CONFIG.lastProjectCode = CURRENT_PROJECT.code; + saveConfig(); + } + populateEpisodeOrSceneDropdown(); + } + }; uiState.episodeDropdown.onChange = populateShotDropdown; + // ScriptUI silently swallows exceptions thrown inside onClick handlers. + settingsBtn.onClick = function () { + try { + openSettingsDialog(); + } catch (settingsError) { + alert("VFXReview Connector \u2014 Settings error:\n" + settingsError.toString() + + (settingsError.line ? "\nLine " + settingsError.line : "")); + } + }; shotBuilderButton.onClick = buildShotFromDropdown; queueExportButton.onClick = queueExport; refreshExportButton.onClick = refreshExportStatus; @@ -2883,8 +3119,24 @@ var HANDLE_FRAMES = 8; 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; @@ -2893,9 +3145,10 @@ var HANDLE_FRAMES = 8; // app.scheduleTask evaluates in global scope — expose the poll tick there $.global.__vfxrExportPollTick = pollExportTick; + loadConfig(); var panel = buildUI(thisObj); refreshSelection(); - populateEpisodeDropdown(); + populateProjectDropdown(); if (panel instanceof Window) { panel.center(); diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index e272899..71a97fe 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -6,7 +6,101 @@ A dockable ScriptUI panel for Adobe After Effects 2024+ that integrates with the ## Configuration -At the top of the script, four global variables control how the panel connects to your project: +Settings are stored per-machine via the **⚙ Settings** button in the panel (persisted in After Effects' built-in preferences — no need to edit the JSX file). + +| Setting | Purpose | +|---|---| +| **Server URL** | Base URL of the VFXReview server (e.g. `https://review.twotalesvfx.com`) | +| **API Token** | Bearer token matching `API_SECRET_KEY` in the server `.env` | +| **Export Root** | Local path where renders and deliverables are written | +| **Footage Root** | Local path where EXR plate folders are read from | +| **Banner Path** | Path to the panel banner image | +| **Artist Email** | Used for render attribution in the pipeline | +| **Handle Frames** | Plate handle frames prepended before the shot's source TC in-point (default: 8) | + +--- + +## Pipeline Folder Structure + +### Export Root + +All outputs from the connector are written under **`/{SHOW_ID}/`** (e.g. `V:/_EXPORTS`). The show ID is appended automatically from the selected project — configure `exportRoot` as the bare root without a show ID. + +``` +/ +└── {SHOW_ID}/ # e.g. UNG/ + ├── {SHOT_CODE}/ # e.g. UNG_108_001_010/ + │ ├── {shot}_cmp_TT_{ver}.[#####].exr # Queue EXR / Queue EXR (Review) + │ └── {shot}_cmp_TT_{ver}.mp4 / .mov # Queue MP4 / Queue MOV + │ + ├── DELIVERY/ + │ └── {EPISODE_OR_SCENE}/ # e.g. UNG_108/ + │ └── {YYYYMMDD}_Delivery/ + │ └── {SHOT_CODE}/ + │ └── {shot}_cmp_TT_{ver}.[#####].exr + │ + └── (render farm outputs — managed by the server) + └── {PROJECT_CODE}/{EPISODE}/{SHOT_CODE}/{ver}/ + └── {shot}_cmp_TT_{ver}.[#####].exr +``` + +**Version naming:** `{SHOT_CODE}_cmp_TT_{version}` — e.g. `UNG_108_001_010_cmp_TT_v003.[01001].exr` + +Versions are set manually via **Increment Shot Version** or the shot detail page. Queuing a render does **not** auto-increment the version. + +--- + +### Footage Root + +Source EXR plates are read from **`/{SHOW_ID}/`**. Configure `footageRoot` as the bare root (e.g. `V:/_FOOTAGE`). + +``` +/ +└── {SHOW_ID}/ # e.g. UNG/ + ├── {EPISODE_CODE}/ # episodic: e.g. UNG_108/ + │ └── {SHOT_CODE}_{CLIP}/ # e.g. UNG_108_001_010_A315C001/ + │ └── *.exr # EXR sequence + │ + ├── {SCENE}/ # standard: e.g. 001/ + │ └── {SHOT_CODE}_{CLIP}/ + │ └── *.exr + │ + └── _PICLOCKS/ + └── {EPISODE_CODE}_*.mov # matched by episode prefix +``` + +Sequence folders must start with the shot code followed by an underscore (`{SHOT_CODE}_*`). The connector imports all matching folders as separate footage layers. + +--- + +### Episodic vs. Standard productions + +| | Episodic | Standard | +|---|---|---| +| Shot code format | `{SHOW}_{EP}_{SCENE}_{NUM}` | `{SHOW}_{SCENE}_{NUM}` | +| Shot Builder | Episode dropdown → Shot dropdown | Scene dropdown → Shot dropdown | +| Footage path | `{footageRoot}/{showId}/{EPISODE}/` | `{footageRoot}/{showId}/{SCENE}/` | +| Export path | `{exportRoot}/{showId}/{SHOT_CODE}/` | same | +| Delivery subfolder | `DELIVERY/{EPISODE}/…` | `DELIVERY/{SCENE}/…` | + +--- + +## Migrating an existing project (e.g. UNG) + +If you previously set `exportRoot = V:/_EXPORTS/UNG`, update it to `V:/_EXPORTS`. The code now appends `/{showId}/` automatically, so `V:/_EXPORTS/UNG/…` is still produced — no files need to move. + +Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`. + +--- + +## New production checklist + +1. Create `/{SHOW_ID}/{EPISODE or SCENE}/` and place EXR sequences inside, named `{SHOT_CODE}_{clip}`. +2. `/{SHOW_ID}/` is created automatically on first render. +3. Add the project to the VFXReview server with the correct **Show ID** set. +4. Open the panel in After Effects, click **⚙**, enter the Server URL, API Token, bare `exportRoot`, and bare `footageRoot`, then click **Save**. +5. The **Project** dropdown will populate automatically. Select the production and the episode/scene list will load. + | Variable | Default | Description | |---|---|---| diff --git a/app/api/ext/projects/[projectCode]/shots/route.ts b/app/api/ext/projects/[projectCode]/shots/route.ts index d790dba..40440ff 100644 --- a/app/api/ext/projects/[projectCode]/shots/route.ts +++ b/app/api/ext/projects/[projectCode]/shots/route.ts @@ -39,6 +39,7 @@ export async function GET( const { searchParams } = new URL(req.url); const episode = searchParams.get("episode")?.trim() || undefined; + const scene = searchParams.get("scene")?.trim() || undefined; const sequence = searchParams.get("sequence")?.trim() || undefined; const status = searchParams.get("status")?.trim() || undefined; const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); @@ -98,6 +99,7 @@ export async function GET( where: { projectId: project.id, ...(episode ? { episode } : {}), + ...(scene ? { scene } : {}), ...(sequence ? { sequence } : {}), ...(status ? { status: status as never } : {}), }, diff --git a/lib/render-pipeline/config.ts b/lib/render-pipeline/config.ts index ea18672..0229b34 100644 --- a/lib/render-pipeline/config.ts +++ b/lib/render-pipeline/config.ts @@ -11,7 +11,7 @@ export const PIPELINE_CONFIG_DEFAULTS = { "render.maxAttempts": 3, "render.stallTimeoutSeconds": 600, "render.urgentPriorityThreshold": 20, - "render.outputRoot": "//SAN/renders", + "render.outputRoot": "//SAN", "validation.sampleEvery": 25, "preview.maxWidth": 1920, // Preview stage (§9): headless AE rebuild of the render with slate/burn-ins diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index 8f6f619..c6f1a0f 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -81,7 +81,7 @@ export async function createExport(input: CreateExportInput) { const shot = await db.shot.findUnique({ where: { id: manifest.shotId }, include: { - project: { select: { id: true, code: true, showId: true } }, + project: { select: { id: true, code: true, showId: true, projectType: true } }, tasks: { where: { type: "COMP" }, orderBy: { sortOrder: "asc" }, take: 1, select: { id: true } }, }, }); @@ -150,9 +150,15 @@ export async function createExport(input: CreateExportInput) { const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber); const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString); + // {root}/{showId}/[{episode} for episodic]/{shotCode}/{version} + const showSegment = shot.project.showId || shot.project.code; + const episodeSegment = + shot.project.projectType === "EPISODIC" && shot.episode?.trim() ? shot.episode.trim() : null; const outputDir = manifest.outputDir === "auto" - ? [outputRoot, shot.project.code, ...(shot.episode ? [shot.episode] : []), shot.shotCode, versionString].join("/") + ? [outputRoot, showSegment, episodeSegment, shot.shotCode, versionString] + .filter(Boolean) + .join("/") : manifest.outputDir; const outputPattern = manifest.outputPattern === "auto" diff --git a/prisma/migrations/20260807010000_render_output_root_drop_renders/migration.sql b/prisma/migrations/20260807010000_render_output_root_drop_renders/migration.sql new file mode 100644 index 0000000..49b7250 --- /dev/null +++ b/prisma/migrations/20260807010000_render_output_root_drop_renders/migration.sql @@ -0,0 +1,7 @@ +-- Export paths are {outputRoot}/{showId}/[{episode}]/{shotCode}/{version}. +-- A stored "render.outputRoot" of "//SAN/renders" injected a stray "renders/" +-- segment; strip it so the root is just the SAN mount point. +UPDATE "system_config" +SET "value" = regexp_replace("value", '/+renders/*$', '') +WHERE "key" = 'render.outputRoot' + AND "value" ~ '/+renders/*$';