3 Commits

Author SHA1 Message Date
twotalesanimation 7dcb9303e5 updated export paths
Deploy / deploy (push) Successful in 6m32s
2026-08-07 12:07:16 +02:00
twotalesanimation 7caf41e17c Slates
Deploy / deploy (push) Successful in 6m17s
2026-08-07 07:34:10 +02:00
twotalesanimation 2f2e286adb Merge develop: render queue feature
Deploy / deploy (push) Failing after 18m31s
2026-08-06 15:56:37 +02:00
13 changed files with 732 additions and 142 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
and then quit it. and then quit it.
.EXAMPLE .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" -ShotCode "UNG_111_001_030" -Version "v002"
.EXAMPLE .EXAMPLE
+343 -108
View File
@@ -3,30 +3,36 @@
Adobe After Effects 2024+ dockable ScriptUI panel Adobe After Effects 2024+ dockable ScriptUI panel
*/ */
var TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4="; // Default config — overridden by stored settings at runtime
var BASE_URL = "http://localhost:3000"; var CONFIG = {
var PROJECT_CODE = "UNG_S1"; baseUrl: "http://localhost:3000",
var EXPORT_ROOT = "V:/_EXPORTS/UNG"; token: "",
var FOOTAGE_ROOT = "V:/_FOOTAGE/UNG"; exportRoot: "",
var BANNER_IMAGE_PATH = "V:/VFXReviewConnector/banner.png"; footageRoot: "",
// Optional: artist email for pipeline export attribution (Queue Export) bannerImagePath:"",
var ARTIST_EMAIL = "chris@twotalesanimation.com"; artistEmail: "",
// Handle frames included in plates before the shot's source TC in-point. handleFrames: 8,
// Used to derive the plate's own start timecode from the API's timecodeStart. lastProjectCode:""
var HANDLE_FRAMES = 8; };
var SETTINGS_GROUP = "VFXReviewConnector";
var SETTINGS_KEY = "cfg";
(function VFXReviewConnector(thisObj) { (function VFXReviewConnector(thisObj) {
var uiState = { var uiState = {
projectDropdown: null,
episodeDropdown: null, episodeDropdown: null,
shotDropdown: null, shotDropdown: null,
episodeLabel: null,
selectionText: null, selectionText: null,
lastActionText: null, lastActionText: null,
pipelineStatusText: null, pipelineStatusText: null,
urgentCheckbox: null, urgentCheckbox: null,
vfxScopeField: null,
submissionNoteField: null submissionNoteField: null
}; };
var CURRENT_PROJECT = null; // { code, name, showId, projectType }
var pipelineState = { var pipelineState = {
pollShotCode: null, pollShotCode: null,
pollTaskId: 0, pollTaskId: 0,
@@ -58,6 +64,14 @@ var HANDLE_FRAMES = 8;
return null; 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() { function getSelectedComps() {
var comps = []; var comps = [];
var selection; var selection;
@@ -84,12 +98,12 @@ var HANDLE_FRAMES = 8;
var parsed; var parsed;
try { try {
url = BASE_URL + "/api/ext/shots/lookup" + url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
"?shotCode=" + encodeURIComponent(shotCode) + "?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + PROJECT_CODE; "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
command = "curl -s " + command = "curl -s " +
"-H \"Authorization: Bearer " + TOKEN + "\" " + "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Accept: application/json\" " + "-H \"Accept: application/json\" " +
"\"" + url + "\""; "\"" + url + "\"";
@@ -113,7 +127,7 @@ var HANDLE_FRAMES = 8;
try { try {
command = "curl -s " + command = "curl -s " +
"-H \"Authorization: Bearer " + TOKEN + "\" " + "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Accept: application/json\" " + "-H \"Accept: application/json\" " +
"\"" + url + "\""; "\"" + url + "\"";
@@ -230,19 +244,39 @@ var HANDLE_FRAMES = 8;
} }
function getEpisodesFromAPI() { 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); var data = getAPIData(url);
return getListFromResponse(data, "episodes"); return getListFromResponse(data, "episodes");
} }
function getShotsFromAPI(episodeValue) { function getShotsFromAPI(value) {
var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/shots?episode=" + encodeURIComponent(episodeValue); 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); var data = getAPIData(url);
return getListFromResponse(data, "shots"); 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) { function normalizeShotData(data) {
if (!data) { if (!data) {
return null; return null;
@@ -271,6 +305,16 @@ var HANDLE_FRAMES = 8;
return null; 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) { function ensureFolder(path) {
var folder = new Folder(path); var folder = new Folder(path);
var parent; var parent;
@@ -398,9 +442,9 @@ var HANDLE_FRAMES = 8;
} }
if (apiShot && apiShot.timecodeStart) { if (apiShot && apiShot.timecodeStart) {
frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - HANDLE_FRAMES; frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - CONFIG.handleFrames;
if (frames > 0) { 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" };
} }
} }
@@ -463,11 +507,11 @@ var HANDLE_FRAMES = 8;
} }
function getEpisodeRootFolder(episodeCode) { function getEpisodeRootFolder(episodeCode) {
return new Folder(FOOTAGE_ROOT + "/" + episodeCode); return new Folder(getShowFootageRoot() + "/" + episodeCode);
} }
function getEpisodeCodesFromDisk() { function getEpisodeCodesFromDisk() {
var root = new Folder(FOOTAGE_ROOT); var root = new Folder(getShowFootageRoot());
var folders; var folders;
var codes = []; var codes = [];
var i; var i;
@@ -868,7 +912,7 @@ var HANDLE_FRAMES = 8;
disableLayerByName(comp, "UNG_VFX_OVERLAY"); disableLayerByName(comp, "UNG_VFX_OVERLAY");
disableLayerByName(comp, "_SHOW LUT"); disableLayerByName(comp, "_SHOW LUT");
folderPath = EXPORT_ROOT + "/" + shotCode; folderPath = getShowExportRoot() + "/" + shotCode;
if (!ensureFolder(folderPath)) { if (!ensureFolder(folderPath)) {
addFailure(result, comp.name, "Could not create output folder"); addFailure(result, comp.name, "Could not create output folder");
continue; continue;
@@ -1109,13 +1153,19 @@ var HANDLE_FRAMES = 8;
var episodeCode; var episodeCode;
var shotCode; var shotCode;
var shotComp; var shotComp;
var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC";
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) { 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; return;
} }
// For episodic, derive full episode code; for standard pass the selected scene value
if (isEpisodic) {
episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); 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; shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text;
app.beginUndoGroup("VFXReview Connector - Build Shot"); app.beginUndoGroup("VFXReview Connector - Build Shot");
@@ -1194,7 +1244,7 @@ var HANDLE_FRAMES = 8;
var outputPath; var outputPath;
app.beginUndoGroup("VFXReview Connector - Queue " + label); app.beginUndoGroup("VFXReview Connector - Queue " + label);
if (!ensureFolder(EXPORT_ROOT)) { if (!ensureFolder(getShowExportRoot())) {
app.endUndoGroup(); app.endUndoGroup();
updateStatus(comps.length, "Could not create export folder."); updateStatus(comps.length, "Could not create export folder.");
return; return;
@@ -1232,7 +1282,7 @@ var HANDLE_FRAMES = 8;
} }
updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); 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)) { if (queueCompWithOutput(previewComp, templateName, outputPath)) {
queued += 1; queued += 1;
result.queued += 1; result.queued += 1;
@@ -1273,7 +1323,7 @@ var HANDLE_FRAMES = 8;
disableLayerByName(comp, "UNG_VFX_OVERLAY"); disableLayerByName(comp, "UNG_VFX_OVERLAY");
disableLayerByName(comp, "_SHOW LUT"); disableLayerByName(comp, "_SHOW LUT");
folderPath = EXPORT_ROOT + "/" + shotCode; folderPath = getShowExportRoot() + "/" + shotCode;
if (!ensureFolder(folderPath)) { if (!ensureFolder(folderPath)) {
addFailure(result, comp.name, "Could not create output folder"); addFailure(result, comp.name, "Could not create output folder");
continue; continue;
@@ -1334,7 +1384,7 @@ var HANDLE_FRAMES = 8;
continue; continue;
} }
exportFolder = new Folder(EXPORT_ROOT + "/" + shot.shotCode); exportFolder = new Folder(getShowExportRoot() + "/" + shot.shotCode);
if (!exportFolder.exists) { if (!exportFolder.exists) {
updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName); updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName);
skipped += 1; skipped += 1;
@@ -1546,7 +1596,7 @@ var HANDLE_FRAMES = 8;
expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"); expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001");
exrOutputBase = shot.exrOutput || ""; exrOutputBase = shot.exrOutput || "";
sourceFolder = new Folder(EXPORT_ROOT + "/" + shotCode); sourceFolder = new Folder(getShowExportRoot() + "/" + shotCode);
if (!sourceFolder.exists) { if (!sourceFolder.exists) {
addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName); addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName);
continue; continue;
@@ -1587,7 +1637,7 @@ var HANDLE_FRAMES = 8;
var episodeCode = shotCode.substring(0, 7); var episodeCode = shotCode.substring(0, 7);
var deliveryDate = getDateString().replace(/\//g, ""); var deliveryDate = getDateString().replace(/\//g, "");
deliveryFolderPath = EXPORT_ROOT + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode; deliveryFolderPath = getShowExportRoot() + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode;
if (!ensureFolder(deliveryFolderPath)) { if (!ensureFolder(deliveryFolderPath)) {
addFailure(result, comp.name, "Could not create delivery folder"); addFailure(result, comp.name, "Could not create delivery folder");
continue; continue;
@@ -1732,7 +1782,7 @@ var HANDLE_FRAMES = 8;
// Not in project — import from disk // Not in project — import from disk
if (!piclockFootage) { if (!piclockFootage) {
piclocksDir = new Folder(FOOTAGE_ROOT + "/_PICLOCKS"); piclocksDir = new Folder(getShowFootageRoot() + "/_PICLOCKS");
if (!piclocksDir.exists) { if (!piclocksDir.exists) {
updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName); updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName);
return; return;
@@ -1979,7 +2029,7 @@ var HANDLE_FRAMES = 8;
} }
function populateShotDropdown() { function populateShotDropdown() {
var episodeValue; var filterValue;
var shots; var shots;
var i; var i;
var shotCode; var shotCode;
@@ -1989,8 +2039,8 @@ var HANDLE_FRAMES = 8;
return; return;
} }
episodeValue = uiState.episodeDropdown.selection.episodeValue || uiState.episodeDropdown.selection.text; filterValue = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text;
shots = getShotsFromAPI(episodeValue); shots = getShotsFromAPI(filterValue);
clearDropdown(uiState.shotDropdown); clearDropdown(uiState.shotDropdown);
for (i = 0; i < shots.length; i += 1) { for (i = 0; i < shots.length; i += 1) {
@@ -2009,38 +2059,132 @@ var HANDLE_FRAMES = 8;
} }
} }
function populateEpisodeDropdown() { function populateEpisodeOrSceneDropdown() {
var episodes = getEpisodesFromAPI(); var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC";
var i; var i;
var label;
var episodeValue;
var item; var item;
if (!uiState.episodeDropdown || !uiState.shotDropdown) { if (!uiState.episodeDropdown || !uiState.shotDropdown) { return; }
return;
}
clearDropdown(uiState.episodeDropdown); clearDropdown(uiState.episodeDropdown);
clearDropdown(uiState.shotDropdown); 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) { for (i = 0; i < episodes.length; i += 1) {
label = getEpisodeLabel(episodes[i]); var label = getEpisodeLabel(episodes[i]);
episodeValue = getEpisodeValue(episodes[i]); var episodeValue = getEpisodeValue(episodes[i]);
if (label && episodeValue) { if (label && episodeValue) {
item = uiState.episodeDropdown.add("item", label); item = uiState.episodeDropdown.add("item", label);
item.episodeValue = episodeValue; item.filterValue = episodeValue;
item.episodeCode = deriveEpisodeCode(label); item.episodeCode = deriveEpisodeCode(label);
if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) { if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) {
item.episodeCode = deriveEpisodeCode(episodeValue); 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) { if (uiState.episodeDropdown.items.length > 0) {
uiState.episodeDropdown.selection = 0; uiState.episodeDropdown.selection = 0;
populateShotDropdown(); populateShotDropdown();
} else { } 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();
} }
} }
@@ -2064,7 +2208,7 @@ var HANDLE_FRAMES = 8;
} }
function patchShotVersion(shotId, newVersion) { 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 // Inner double-quotes escaped for Windows cmd.exe
var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}"; var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}";
var command; var command;
@@ -2072,7 +2216,7 @@ var HANDLE_FRAMES = 8;
try { try {
command = "curl -s -X PATCH " + command = "curl -s -X PATCH " +
"-H \"Authorization: Bearer " + TOKEN + "\" " + "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Content-Type: application/json\" " + "-H \"Content-Type: application/json\" " +
"-H \"Accept: application/json\" " + "-H \"Accept: application/json\" " +
"-d \"" + body + "\" " + "-d \"" + body + "\" " +
@@ -2189,7 +2333,7 @@ var HANDLE_FRAMES = 8;
shot = data.shot; shot = data.shot;
fps = shot.fps || comps[i].frameRate || 24; 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); setCompStartFrame(comps[i], frameNumber);
updated += 1; updated += 1;
} }
@@ -2264,6 +2408,40 @@ var HANDLE_FRAMES = 8;
return "{" + parts.join(",") + "}"; 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 // POST JSON via a temp file (-d @file) — avoids cmd.exe quote-escaping
// problems for large bodies. Returns { status: httpCode, data: parsed }. // problems for large bodies. Returns { status: httpCode, data: parsed }.
function postJSON(url, bodyObject) { function postJSON(url, bodyObject) {
@@ -2284,7 +2462,7 @@ var HANDLE_FRAMES = 8;
bodyFile.close(); bodyFile.close();
command = "curl -s -X POST " + command = "curl -s -X POST " +
"-H \"Authorization: Bearer " + TOKEN + "\" " + "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
"-H \"Content-Type: application/json\" " + "-H \"Content-Type: application/json\" " +
"-H \"Accept: application/json\" " + "-H \"Accept: application/json\" " +
"-d \"@" + bodyFile.fsName + "\" " + "-d \"@" + bodyFile.fsName + "\" " +
@@ -2316,9 +2494,9 @@ var HANDLE_FRAMES = 8;
} }
function getLatestExportData(shotCode) { function getLatestExportData(shotCode) {
var url = BASE_URL + "/api/ext/exports/latest" + var url = CONFIG.baseUrl + "/api/ext/exports/latest" +
"?shotCode=" + encodeURIComponent(shotCode) + "?shotCode=" + encodeURIComponent(shotCode) +
"&projectCode=" + PROJECT_CODE; "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
return getAPIData(url); return getAPIData(url);
} }
@@ -2452,9 +2630,6 @@ var HANDLE_FRAMES = 8;
if (pipelineState.prefilledShotCode === shotCode) { if (pipelineState.prefilledShotCode === shotCode) {
return; return;
} }
if (uiState.vfxScopeField) {
uiState.vfxScopeField.text = (exportInfo && exportInfo.vfxScope) ? exportInfo.vfxScope : "";
}
if (uiState.submissionNoteField) { if (uiState.submissionNoteField) {
uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : ""; uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
} }
@@ -2505,7 +2680,7 @@ var HANDLE_FRAMES = 8;
return { return {
manifestVersion: 1, manifestVersion: 1,
projectCode: PROJECT_CODE, projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "",
shotCode: shot.shotCode, shotCode: shot.shotCode,
shotId: shot.id, shotId: shot.id,
aepPath: aepPath, aepPath: aepPath,
@@ -2528,21 +2703,20 @@ var HANDLE_FRAMES = 8;
force: force ? true : false force: force ? true : false
}; };
if (ARTIST_EMAIL) { if (CONFIG.artistEmail) {
body.submittedByEmail = ARTIST_EMAIL; body.submittedByEmail = CONFIG.artistEmail;
} }
if (urgent) { if (urgent) {
body.priority = 20; body.priority = 20;
} }
// Sent every time so edits stick; omitting them would make the server // submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
// inherit the previous submission's values instead.
if (uiState.vfxScopeField) {
body.vfxScope = uiState.vfxScopeField.text;
}
if (uiState.submissionNoteField) { if (uiState.submissionNoteField) {
body.submissionNote = uiState.submissionNoteField.text; 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() { function queueExport() {
@@ -2651,7 +2825,7 @@ var HANDLE_FRAMES = 8;
return; 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" }); { note: "Retry from AE panel" });
if (response.status === 200) { if (response.status === 200) {
setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued"); setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued");
@@ -2671,10 +2845,6 @@ var HANDLE_FRAMES = 8;
var shotBuilderGroup; var shotBuilderGroup;
var shotBuilderButton; var shotBuilderButton;
var pipelinePanel; var pipelinePanel;
var slateScopeGroup;
var slateScopeLabel;
var slateNoteGroup;
var slateNoteLabel;
var pipelineRow1; var pipelineRow1;
var pipelineRow2; var pipelineRow2;
var queueExportButton; var queueExportButton;
@@ -2712,24 +2882,42 @@ var HANDLE_FRAMES = 8;
var statusPanel; var statusPanel;
win.orientation = "column"; win.orientation = "column";
win.alignChildren = ["fill", "top"]; win.alignChildren = ["fill", "fill"];
win.spacing = 8; win.spacing = 0;
win.margins = 12; 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) { if (bannerFile.exists) {
try { try {
bannerImage = win.add("image", undefined, bannerFile); bannerImage = scrollContent.add("image", undefined, bannerFile);
bannerImage.alignment = ["fit", "top"]; bannerImage.alignment = ["fit", "top"];
bannerImage.size = [360, 80]; bannerImage.size = [360, 80];
} catch (bannerError) { } catch (bannerError) {
} }
} }
title = win.add("statictext", undefined, "VFXReview Connector"); title = scrollContent.add("statictext", undefined, "VFXReview Connector");
title.alignment = ["fill", "top"]; title.alignment = ["fill", "top"];
shotBuilderPanel = win.add("panel", undefined, "Shot Builder"); shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder");
shotBuilderPanel.orientation = "column"; shotBuilderPanel.orientation = "column";
shotBuilderPanel.alignChildren = ["fill", "top"]; shotBuilderPanel.alignChildren = ["fill", "top"];
shotBuilderPanel.margins = 10; shotBuilderPanel.margins = 10;
@@ -2739,13 +2927,33 @@ var HANDLE_FRAMES = 8;
shotBuilderGroup.alignChildren = ["fill", "center"]; shotBuilderGroup.alignChildren = ["fill", "center"];
shotBuilderGroup.spacing = 6; shotBuilderGroup.spacing = 6;
uiState.episodeDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); // Project row
uiState.episodeDropdown.preferredSize = [110, 24]; var projectRow = shotBuilderPanel.add("group");
uiState.shotDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); projectRow.orientation = "row";
uiState.shotDropdown.preferredSize = [150, 24]; projectRow.alignChildren = ["fill", "center"];
shotBuilderButton = shotBuilderGroup.add("button", undefined, "Build Shot"); 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.orientation = "column";
pipelinePanel.alignChildren = ["fill", "top"]; pipelinePanel.alignChildren = ["fill", "top"];
pipelinePanel.margins = 8; pipelinePanel.margins = 8;
@@ -2753,24 +2961,6 @@ var HANDLE_FRAMES = 8;
uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked"); uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked");
slateScopeGroup = pipelinePanel.add("group");
slateScopeGroup.orientation = "row";
slateScopeGroup.alignChildren = ["fill", "center"];
slateScopeGroup.spacing = 6;
slateScopeLabel = slateScopeGroup.add("statictext", undefined, "VFX Scope:");
slateScopeLabel.preferredSize = [90, 20];
uiState.vfxScopeField = slateScopeGroup.add("edittext", undefined, "");
uiState.vfxScopeField.preferredSize = [220, 22];
slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Submission Note:");
slateNoteLabel.preferredSize = [90, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: true });
uiState.submissionNoteField.preferredSize = [220, 48];
pipelineRow1 = pipelinePanel.add("group"); pipelineRow1 = pipelinePanel.add("group");
pipelineRow1.orientation = "row"; pipelineRow1.orientation = "row";
pipelineRow1.alignChildren = ["fill", "center"]; pipelineRow1.alignChildren = ["fill", "center"];
@@ -2778,6 +2968,15 @@ var HANDLE_FRAMES = 8;
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export"); queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent"); uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");
var slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
var slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Sub. Note:");
slateNoteLabel.preferredSize = [70, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: false });
uiState.submissionNoteField.preferredSize = [240, 22];
pipelineRow2 = pipelinePanel.add("group"); pipelineRow2 = pipelinePanel.add("group");
pipelineRow2.orientation = "row"; pipelineRow2.orientation = "row";
pipelineRow2.alignChildren = ["fill", "center"]; pipelineRow2.alignChildren = ["fill", "center"];
@@ -2785,7 +2984,7 @@ var HANDLE_FRAMES = 8;
refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status"); refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status");
retryExportButton = pipelineRow2.add("button", undefined, "Retry Export"); retryExportButton = pipelineRow2.add("button", undefined, "Retry Export");
buttonsPanel = win.add("panel", undefined, "Actions"); buttonsPanel = scrollContent.add("panel", undefined, "Actions");
buttonsPanel.orientation = "column"; buttonsPanel.orientation = "column";
buttonsPanel.alignChildren = ["fill", "top"]; buttonsPanel.alignChildren = ["fill", "top"];
buttonsPanel.margins = 8; buttonsPanel.margins = 8;
@@ -2855,21 +3054,21 @@ var HANDLE_FRAMES = 8;
btnRow7.spacing = 6; btnRow7.spacing = 6;
incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version"); incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version");
workspacePanel = win.add("panel", undefined, "Workspace"); workspacePanel = scrollContent.add("panel", undefined, "Workspace");
workspacePanel.orientation = "column"; workspacePanel.orientation = "column";
workspacePanel.alignChildren = ["fill", "top"]; workspacePanel.alignChildren = ["fill", "top"];
workspacePanel.margins = 8; workspacePanel.margins = 8;
workspacePanel.spacing = 4; workspacePanel.spacing = 4;
initWorkspaceButton = workspacePanel.add("button", undefined, "Initialise Workspace"); 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.orientation = "column";
colorSpacePanel.alignChildren = ["fill", "top"]; colorSpacePanel.alignChildren = ["fill", "top"];
colorSpacePanel.margins = 8; colorSpacePanel.margins = 8;
colorSpacePanel.spacing = 4; colorSpacePanel.spacing = 4;
ocioButton = colorSpacePanel.add("button", undefined, "Add OCIO to Footage Layers"); 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.orientation = "column";
statusPanel.alignChildren = ["fill", "top"]; statusPanel.alignChildren = ["fill", "top"];
statusPanel.margins = 10; statusPanel.margins = 10;
@@ -2877,7 +3076,26 @@ var HANDLE_FRAMES = 8;
uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected"); uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected");
uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready"); 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; 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; shotBuilderButton.onClick = buildShotFromDropdown;
queueExportButton.onClick = queueExport; queueExportButton.onClick = queueExport;
refreshExportButton.onClick = refreshExportStatus; refreshExportButton.onClick = refreshExportStatus;
@@ -2901,8 +3119,24 @@ var HANDLE_FRAMES = 8;
win.layout.layout(true); win.layout.layout(true);
win.layout.resize(); 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 () { win.onResizing = win.onResize = function () {
this.layout.resize(); 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; return win;
@@ -2911,9 +3145,10 @@ var HANDLE_FRAMES = 8;
// app.scheduleTask evaluates in global scope — expose the poll tick there // app.scheduleTask evaluates in global scope — expose the poll tick there
$.global.__vfxrExportPollTick = pollExportTick; $.global.__vfxrExportPollTick = pollExportTick;
loadConfig();
var panel = buildUI(thisObj); var panel = buildUI(thisObj);
refreshSelection(); refreshSelection();
populateEpisodeDropdown(); populateProjectDropdown();
if (panel instanceof Window) { if (panel instanceof Window) {
panel.center(); panel.center();
+95 -1
View File
@@ -6,7 +6,101 @@ A dockable ScriptUI panel for Adobe After Effects 2024+ that integrates with the
## Configuration ## 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 **`<Export Root>/{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.
```
<Export Root>/
└── {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 **`<Footage Root>/{SHOW_ID}/`**. Configure `footageRoot` as the bare root (e.g. `V:/_FOOTAGE`).
```
<Footage Root>/
└── {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 `<Footage Root>/{SHOW_ID}/{EPISODE or SCENE}/` and place EXR sequences inside, named `{SHOT_CODE}_{clip}`.
2. `<Export Root>/{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 | | Variable | Default | Description |
|---|---|---| |---|---|---|
+34
View File
@@ -489,6 +489,40 @@ export async function updateShotNotes(shotId: string, notes: string) {
return { success: true }; return { success: true };
} }
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
export async function updateShotSlate(
shotId: string,
data: {
slateType?: string | null;
slateDescription?: string | null;
slateVfxScope?: string | null;
slateSubmissionNote?: string | null;
}
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
},
});
revalidatePath(`/projects/${shot.projectId}`);
return { success: true };
}
// ── Toggle Key Shot ─────────────────────────────────────────────────────────── // ── Toggle Key Shot ───────────────────────────────────────────────────────────
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) { export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
@@ -30,11 +30,13 @@ import {
FileVideo, FileVideo,
Pencil, Pencil,
Check, Check,
LayoutList,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { ShotExportsTab } from "@/components/shots/ShotExportsTab"; import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
import { ShotSlateTab } from "@/components/shots/ShotSlateTab";
import { FootageViewer } from "@/components/shots/FootageViewer"; import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
@@ -90,7 +92,7 @@ export default function ShotDetailPage() {
const [isDuplicating, setIsDuplicating] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false); const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false); const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState(""); const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false); const [savingVersion, setSavingVersion] = useState(false);
@@ -520,6 +522,18 @@ export default function ShotDetailPage() {
<ListTodo className="h-4 w-4" /> <ListTodo className="h-4 w-4" />
Tasks Tasks
</button> </button>
<button
onClick={() => setActiveTab("slate")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "slate"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<LayoutList className="h-4 w-4" />
Slate
</button>
<button <button
onClick={() => setActiveTab("reviews")} onClick={() => setActiveTab("reviews")}
className={cn( className={cn(
@@ -583,6 +597,10 @@ export default function ShotDetailPage() {
/> />
)} )}
{activeTab === "slate" && (
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
)}
{activeTab === "reviews" && ( {activeTab === "reviews" && (
<div className="space-y-3"> <div className="space-y-3">
{tasks.length === 0 ? ( {tasks.length === 0 ? (
@@ -39,6 +39,7 @@ export async function GET(
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const episode = searchParams.get("episode")?.trim() || undefined; const episode = searchParams.get("episode")?.trim() || undefined;
const scene = searchParams.get("scene")?.trim() || undefined;
const sequence = searchParams.get("sequence")?.trim() || undefined; const sequence = searchParams.get("sequence")?.trim() || undefined;
const status = searchParams.get("status")?.trim() || undefined; const status = searchParams.get("status")?.trim() || undefined;
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
@@ -98,6 +99,7 @@ export async function GET(
where: { where: {
projectId: project.id, projectId: project.id,
...(episode ? { episode } : {}), ...(episode ? { episode } : {}),
...(scene ? { scene } : {}),
...(sequence ? { sequence } : {}), ...(sequence ? { sequence } : {}),
...(status ? { status: status as never } : {}), ...(status ? { status: status as never } : {}),
}, },
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useState, useTransition } from "react";
import Image from "next/image";
import { Film, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { updateShotSlate } from "@/actions/shots";
import type { ShotWithDetails } from "@/types";
interface Props {
shot: ShotWithDetails;
projectName: string;
onSaved: () => void;
}
/** Left-side slate row: label + editable/static value */
function SlateRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-0 border-b border-zinc-800/60 last:border-0">
<div className="w-36 shrink-0 py-3 px-4 text-right text-[11px] font-semibold text-zinc-500 uppercase tracking-widest self-start pt-3.5">
{label}
</div>
<div className="flex-1 py-2.5 px-3 text-sm text-zinc-100">
{children}
</div>
</div>
);
}
/** Right-side metadata row */
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between border-b border-zinc-800/50 last:border-0 py-1.5 px-3">
<span className="text-[11px] text-zinc-500">{label}</span>
<span className="text-[11px] text-zinc-300 text-right max-w-[55%] truncate">{value || "—"}</span>
</div>
);
}
export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const [slateType, setSlateType] = useState(shot.slateType ?? "");
const [slateDescription, setSlateDescription] = useState(shot.slateDescription ?? shot.description ?? "");
const [slateVfxScope, setSlateVfxScope] = useState(shot.slateVfxScope ?? "");
const [slateSubmissionNote, setSlateSubmissionNote] = useState(shot.slateSubmissionNote ?? "");
const today = new Date();
const dateStr = `${today.getFullYear()}/${String(today.getMonth() + 1).padStart(2, "0")}/${String(today.getDate()).padStart(2, "0")}`;
const versionName = `${shot.shotCode}_cmp_TT_${shot.shotVersion ?? "v001"}`;
const frames =
shot.frameStart != null && shot.frameEnd != null
? String(shot.frameEnd - shot.frameStart + 1)
: "—";
const handleSave = () => {
startTransition(async () => {
try {
await updateShotSlate(shot.id, {
slateType: slateType.trim() || null,
slateDescription: slateDescription.trim() || null,
slateVfxScope: slateVfxScope.trim() || null,
slateSubmissionNote: slateSubmissionNote.trim() || null,
});
toast({ title: "Slate saved" });
onSaved();
} catch (e) {
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
});
};
return (
<div className="space-y-4">
{/* Slate document */}
<div className="rounded-xl overflow-hidden border border-zinc-800 bg-zinc-950 max-w-4xl">
{/* Top accent bar */}
<div className="h-1 bg-red-600" />
<div className="flex">
{/* ── Left column: editorial data ─────────────────────────────── */}
<div className="flex-1 border-r border-zinc-800">
{/* Header row */}
<div className="flex border-b border-zinc-800">
<div className="flex-1 px-4 py-3 border-r border-zinc-800">
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-0.5">Show</p>
<p className="text-base font-bold text-white tracking-wide">{projectName.toUpperCase()}</p>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<p className="text-[10px] text-zinc-500 uppercase tracking-widest">Submitting For</p>
<span className="ml-1 px-2 py-0.5 rounded bg-red-600/20 border border-red-600/40 text-red-400 text-xs font-bold tracking-widest">REVIEW</span>
</div>
</div>
{/* Auto-filled rows */}
<SlateRow label="Version Name">
<span className="font-mono text-amber-400">{versionName}</span>
</SlateRow>
<SlateRow label="Date">
<span className="text-zinc-300">{dateStr}</span>
</SlateRow>
{/* Editable rows */}
<SlateRow label="Shot Type">
<Input
value={slateType}
onChange={(e) => setSlateType(e.target.value)}
placeholder="e.g. 2D COMP"
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-medium text-zinc-100 placeholder:text-zinc-600"
/>
</SlateRow>
<SlateRow label="Shot Description">
<Textarea
value={slateDescription}
onChange={(e) => setSlateDescription(e.target.value)}
placeholder={shot.description ?? "Shot description for slate…"}
rows={2}
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[56px]"
/>
</SlateRow>
<SlateRow label="VFX Scope">
<Textarea
value={slateVfxScope}
onChange={(e) => setSlateVfxScope(e.target.value)}
placeholder="VFX scope of work…"
rows={3}
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[72px]"
/>
</SlateRow>
<SlateRow label="Submission Note">
<Input
value={slateSubmissionNote}
onChange={(e) => setSlateSubmissionNote(e.target.value)}
placeholder="e.g. Done"
className="h-7 bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600"
/>
</SlateRow>
</div>
{/* ── Right column: thumbnail + metadata ──────────────────────── */}
<div className="w-56 shrink-0 flex flex-col">
{/* Thumbnail */}
<div className="aspect-video bg-zinc-900 border-b border-zinc-800 overflow-hidden flex items-center justify-center">
{shot.thumbnailUrl ? (
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
width={224}
height={126}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-8 w-8 text-zinc-700" />
)}
</div>
{/* Metadata */}
<div className="flex-1 text-right">
<div className="px-3 py-2 border-b border-zinc-800">
<p className="text-[10px] text-zinc-500 mb-0.5">Vendor</p>
<p className="text-[11px] text-zinc-200 font-medium leading-tight">TWO TALES ANIMATION (PTY) LTD</p>
</div>
<MetaRow label="Shot Name" value={shot.shotCode} />
<MetaRow label="Episode" value={shot.episode ?? "—"} />
<MetaRow label="Seq Name" value={shot.sequence ?? "—"} />
<MetaRow label="Scene" value={shot.scene} />
<MetaRow label="Frames" value={frames} />
<MetaRow label="Media Color" value="w/SHOW LUT" />
</div>
</div>
</div>
</div>
{/* Save button */}
<div className="flex justify-end max-w-4xl">
<Button onClick={handleSave} disabled={isPending} className="gap-2">
<Save className="h-4 w-4" />
{isPending ? "Saving…" : "Save Slate"}
</Button>
</div>
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const PIPELINE_CONFIG_DEFAULTS = {
"render.maxAttempts": 3, "render.maxAttempts": 3,
"render.stallTimeoutSeconds": 600, "render.stallTimeoutSeconds": 600,
"render.urgentPriorityThreshold": 20, "render.urgentPriorityThreshold": 20,
"render.outputRoot": "//SAN/renders", "render.outputRoot": "//SAN",
"validation.sampleEvery": 25, "validation.sampleEvery": 25,
"preview.maxWidth": 1920, "preview.maxWidth": 1920,
// Preview stage (§9): headless AE rebuild of the render with slate/burn-ins // Preview stage (§9): headless AE rebuild of the render with slate/burn-ins
+18 -23
View File
@@ -81,7 +81,7 @@ export async function createExport(input: CreateExportInput) {
const shot = await db.shot.findUnique({ const shot = await db.shot.findUnique({
where: { id: manifest.shotId }, where: { id: manifest.shotId },
include: { 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 } }, tasks: { where: { type: "COMP" }, orderBy: { sortOrder: "asc" }, take: 1, select: { id: true } },
}, },
}); });
@@ -137,26 +137,28 @@ export async function createExport(input: CreateExportInput) {
}); });
const freshShot = await tx.shot.findUniqueOrThrow({ const freshShot = await tx.shot.findUniqueOrThrow({
where: { id: shot.id }, where: { id: shot.id },
select: { shotVersion: true, exrOutput: true }, select: { shotVersion: true, exrOutput: true, slateVfxScope: true, slateSubmissionNote: true },
}); });
// Slate fields carry forward from the shot's previous submission // vfxScope: caller override → shot's slate field
// unless the caller supplies new ones. const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (freshShot.slateVfxScope ?? null);
const previous = await tx.export.findFirst({ // submissionNote: caller override (per-export) → shot's slate default
where: { shotId: shot.id },
orderBy: { versionNumber: "desc" },
select: { vfxScope: true, submissionNote: true },
});
const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (previous?.vfxScope ?? null);
const submissionNote = const submissionNote =
input.submissionNote !== undefined ? input.submissionNote : (previous?.submissionNote ?? null); input.submissionNote !== undefined ? input.submissionNote : (freshShot.slateSubmissionNote ?? null);
const versionNumber = // versionNumber is an internal sequence for DB uniqueness only; output files
Math.max(latest._max.versionNumber ?? 0, parseVersionString(freshShot.shotVersion)) + 1; // use the shot's current (manually-set) shotVersion.
const versionString = formatVersionString(versionNumber); const versionNumber = (latest._max.versionNumber ?? 0) + 1;
const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber);
const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString); 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 = const outputDir =
manifest.outputDir === "auto" 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; : manifest.outputDir;
const outputPattern = const outputPattern =
manifest.outputPattern === "auto" manifest.outputPattern === "auto"
@@ -236,13 +238,6 @@ export async function createExport(input: CreateExportInput) {
}, },
}); });
// Mirror the legacy panel PATCH: Shot stays the canonical "current version"
const updatedShot = await tx.shot.update({
where: { id: shot.id },
data: { shotVersion: versionString, exrOutput: exrBase },
select: { id: true, shotVersion: true, exrOutput: true },
});
return { return {
export: { export: {
id: created.id, id: created.id,
@@ -256,7 +251,7 @@ export async function createExport(input: CreateExportInput) {
submissionNote, submissionNote,
}, },
renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority }, renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority },
shot: updatedShot, shot: { id: shot.id, shotVersion: versionString, exrOutput: exrBase },
superseded: toSupersede.map((s) => s.id), superseded: toSupersede.map((s) => s.id),
}; };
}); });
@@ -0,0 +1,5 @@
-- AlterTable: add Netflix slate fields to shots
ALTER TABLE "shots" ADD COLUMN "slateType" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateDescription" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateVfxScope" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateSubmissionNote" TEXT;
@@ -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/*$';
+5
View File
@@ -418,6 +418,11 @@ model Shot {
shotVersion String @default("v001") shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation // Key shot flag — highlighted for prioritisation
isKeyShot Boolean @default(false) isKeyShot Boolean @default(false)
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String?
slateDescription String? @db.Text
slateVfxScope String? @db.Text
slateSubmissionNote String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+5
View File
@@ -181,6 +181,11 @@ export interface ShotWithDetails {
highResFilename: string | null; highResFilename: string | null;
// Shot-level version tracking // Shot-level version tracking
shotVersion: string; shotVersion: string;
// Netflix slate fields
slateType: string | null;
slateDescription: string | null;
slateVfxScope: string | null;
slateSubmissionNote: string | null;
shotGroup: { id: string; name: string } | null; shotGroup: { id: string; name: string } | null;
footagePlates: { footagePlates: {
id: string; id: string;