Compare commits
13 Commits
43c717bb85
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dcb9303e5 | |||
| 7caf41e17c | |||
| 2f2e286adb | |||
| c9deb437c5 | |||
| a3be3489de | |||
| cc89415a29 | |||
| 6b15bae62a | |||
| 7b36329769 | |||
| d2c91e7b65 | |||
| 852081b4d6 | |||
| 98120f3ca8 | |||
| 5f3c89119a | |||
| c727795a78 |
@@ -31,3 +31,6 @@ yarn-error.log*
|
|||||||
.vercel
|
.vercel
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Local tooling
|
||||||
|
RenderWorker/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -951,6 +985,154 @@ export async function updateShotsSeqTimecodes(
|
|||||||
return { updated, skipped, errors };
|
return { updated, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Simple CSV shot import ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimpleCsvRow {
|
||||||
|
shotCode: string;
|
||||||
|
seqTimecodeStart: string;
|
||||||
|
seqTimecodeEnd: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
action: "create" | "update" | "skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importShotsFromSimpleCsv(
|
||||||
|
projectId: string,
|
||||||
|
rows: SimpleCsvRow[]
|
||||||
|
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
|
||||||
|
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 created: string[] = [];
|
||||||
|
const updated: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
|
||||||
|
try {
|
||||||
|
const existing = await db.shot.findFirst({
|
||||||
|
where: { projectId, shotCode: row.shotCode },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const shotGroupId = row.group?.trim()
|
||||||
|
? (await db.shotGroup.upsert({
|
||||||
|
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||||
|
create: { projectId, name: row.group.trim() },
|
||||||
|
update: {},
|
||||||
|
})).id
|
||||||
|
: undefined;
|
||||||
|
await db.shot.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
description: row.description || null,
|
||||||
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||||
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||||
|
...(shotGroupId ? { shotGroupId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updated.push(row.shotCode);
|
||||||
|
} else if (row.action === "create") {
|
||||||
|
const parts = row.shotCode.split("_");
|
||||||
|
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
|
||||||
|
const episode = parts.length >= 4 ? parts[1] : null;
|
||||||
|
const maxNum = await db.shot.findFirst({
|
||||||
|
where: { projectId, scene, episode },
|
||||||
|
orderBy: { shotNumber: "desc" },
|
||||||
|
select: { shotNumber: true },
|
||||||
|
});
|
||||||
|
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
|
||||||
|
const shotGroupId = row.group?.trim()
|
||||||
|
? (await db.shotGroup.upsert({
|
||||||
|
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||||
|
create: { projectId, name: row.group.trim() },
|
||||||
|
update: {},
|
||||||
|
})).id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await db.shot.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
shotCode: row.shotCode,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotNumber,
|
||||||
|
description: row.description || null,
|
||||||
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||||
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||||
|
shotGroupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(row.shotCode);
|
||||||
|
} else {
|
||||||
|
// action === "update" but shot not found
|
||||||
|
skipped.push(row.shotCode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
return { created, updated, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group Assignment ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GroupAssignRow {
|
||||||
|
shotCode: string;
|
||||||
|
groupName: string;
|
||||||
|
exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignShotGroups(
|
||||||
|
projectId: string,
|
||||||
|
rows: GroupAssignRow[]
|
||||||
|
): Promise<{ assigned: string[]; skipped: string[]; errors: string[] }> {
|
||||||
|
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 assigned: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.exists) { skipped.push(row.shotCode); continue; }
|
||||||
|
try {
|
||||||
|
const shot = await db.shot.findFirst({
|
||||||
|
where: { projectId, shotCode: row.shotCode },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!shot) { skipped.push(row.shotCode); continue; }
|
||||||
|
|
||||||
|
const group = await db.shotGroup.upsert({
|
||||||
|
where: { projectId_name: { projectId, name: row.groupName.trim() } },
|
||||||
|
create: { projectId, name: row.groupName.trim() },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.shot.update({
|
||||||
|
where: { id: shot.id },
|
||||||
|
data: { shotGroupId: group.id },
|
||||||
|
});
|
||||||
|
assigned.push(row.shotCode);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
return { assigned, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: handle client requesting changes on a shot.
|
* Internal: handle client requesting changes on a shot.
|
||||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
export default function DashboardError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// After a redeployment the old chunk URLs 404 — force a hard reload to pick up new manifest
|
||||||
|
if (error?.name === "ChunkLoadError") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (error?.name === "ChunkLoadError") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-400">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-amber-500" />
|
||||||
|
<p className="text-lg font-medium text-zinc-200">Something went wrong</p>
|
||||||
|
{error?.message && (
|
||||||
|
<p className="text-sm text-zinc-500 max-w-md text-center">{error.message}</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={reset} className="gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
FilePlus2,
|
FilePlus2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Film,
|
Film,
|
||||||
|
FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
|
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
|
||||||
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
|
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
|
||||||
import { importShotsFromEdl, updateShotsSeqTimecodes } from "@/actions/shots";
|
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
|
||||||
|
|
||||||
interface EdlImportClientProps {
|
interface EdlImportClientProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -54,6 +55,24 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
|
// ── Simple CSV state ────────────────────────────────────────────────────────
|
||||||
|
const scFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
|
||||||
|
const [scCsvText, setScCsvText] = useState("");
|
||||||
|
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
|
||||||
|
const [scErrors, setScErrors] = useState<string[]>([]);
|
||||||
|
const [scImporting, setScImporting] = useState(false);
|
||||||
|
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
|
// ── Group Assignment state ───────────────────────────────────────────────
|
||||||
|
const gaFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [gaStep, setGaStep] = useState<"input" | "preview" | "result">("input");
|
||||||
|
const [gaCsvText, setGaCsvText] = useState("");
|
||||||
|
const [gaRows, setGaRows] = useState<GroupAssignRow[]>([]);
|
||||||
|
const [gaErrors, setGaErrors] = useState<string[]>([]);
|
||||||
|
const [gaAssigning, setGaAssigning] = useState(false);
|
||||||
|
const [gaResult, setGaResult] = useState<{ assigned: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
// ── Picture Tracker state ─────────────────────────────────────────────────
|
// ── Picture Tracker state ─────────────────────────────────────────────────
|
||||||
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
||||||
@@ -128,6 +147,102 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Simple CSV handlers ─────────────────────────────────────────────────────
|
||||||
|
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScParse = useCallback(() => {
|
||||||
|
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
|
||||||
|
setScErrors(errors);
|
||||||
|
setScRows(parsed);
|
||||||
|
if (parsed.length > 0) setScStep("preview");
|
||||||
|
}, [scCsvText, existingShotCodes]);
|
||||||
|
|
||||||
|
const toggleScAction = (idx: number) => {
|
||||||
|
setScRows((prev) =>
|
||||||
|
prev.map((r, i) => {
|
||||||
|
if (i !== idx) return r;
|
||||||
|
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
|
||||||
|
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
|
||||||
|
return { ...r, action: next };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScImport = async () => {
|
||||||
|
setScImporting(true);
|
||||||
|
try {
|
||||||
|
const res = await importShotsFromSimpleCsv(projectId, scRows);
|
||||||
|
setScResult(res);
|
||||||
|
setScStep("result");
|
||||||
|
if (res.created.length + res.updated.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: "Import complete",
|
||||||
|
description: `${res.created.length} created, ${res.updated.length} updated`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setScImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScReset = () => {
|
||||||
|
setScStep("input");
|
||||||
|
setScCsvText("");
|
||||||
|
setScRows([]);
|
||||||
|
setScErrors([]);
|
||||||
|
setScResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Group Assignment handlers ────────────────────────────────────────────
|
||||||
|
const handleGaFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setGaCsvText(ev.target?.result as string ?? "");
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGaParse = useCallback(() => {
|
||||||
|
const { rows: parsed, errors } = parseGroupAssignCsv(gaCsvText, existingShotCodes);
|
||||||
|
setGaErrors(errors);
|
||||||
|
setGaRows(parsed);
|
||||||
|
if (parsed.length > 0) setGaStep("preview");
|
||||||
|
}, [gaCsvText, existingShotCodes]);
|
||||||
|
|
||||||
|
const handleGaAssign = async () => {
|
||||||
|
setGaAssigning(true);
|
||||||
|
try {
|
||||||
|
const res = await assignShotGroups(projectId, gaRows);
|
||||||
|
setGaResult(res);
|
||||||
|
setGaStep("result");
|
||||||
|
if (res.assigned.length > 0) {
|
||||||
|
toast({ title: "Groups assigned", description: `${res.assigned.length} shot${res.assigned.length !== 1 ? "s" : ""} updated` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Assignment failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setGaAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGaReset = () => {
|
||||||
|
setGaStep("input");
|
||||||
|
setGaCsvText("");
|
||||||
|
setGaRows([]);
|
||||||
|
setGaErrors([]);
|
||||||
|
setGaResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
||||||
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -426,6 +541,355 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
|
||||||
|
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mt-0.5">
|
||||||
|
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scStep === "input" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input ref={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload .csv
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={scCsvText}
|
||||||
|
onChange={(e) => setScCsvText(e.target.value)}
|
||||||
|
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
|
||||||
|
className="font-mono text-xs min-h-[200px] bg-zinc-950 border-zinc-700 resize-y"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||||
|
<p className="font-medium text-zinc-300">Expected format</p>
|
||||||
|
<p>Required: <span className="font-mono text-amber-400">Shot Name</span></p>
|
||||||
|
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
|
||||||
|
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Parse & Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scStep === "preview" && (() => {
|
||||||
|
const scCreate = scRows.filter((r) => r.action === "create").length;
|
||||||
|
const scUpdate = scRows.filter((r) => r.action === "update").length;
|
||||||
|
const scSkip = scRows.filter((r) => r.action === "skip").length;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
||||||
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
|
{scCreate} to create
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
{scUpdate} to update
|
||||||
|
</div>
|
||||||
|
{scSkip > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
{scSkip} to skip
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scErrors.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
{scErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||||
|
<span>Action</span>
|
||||||
|
<span>Shot Code</span>
|
||||||
|
<span>Seq TC In</span>
|
||||||
|
<span>Seq TC Out</span>
|
||||||
|
<span>Group</span>
|
||||||
|
<span>Description</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||||
|
{scRows.map((row, i) => {
|
||||||
|
const ActionIcon = ACTION_ICONS[row.action];
|
||||||
|
return (
|
||||||
|
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleScAction(i)}
|
||||||
|
title="Click to cycle: create → update → skip"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
|
||||||
|
ACTION_STYLES[row.action]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ActionIcon className="h-3 w-3" />
|
||||||
|
{row.action}
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
|
||||||
|
<span className="text-xs text-zinc-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</span>
|
||||||
|
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleScReset}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleScImport}
|
||||||
|
disabled={scImporting || (scCreate + scUpdate === 0)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{scStep === "result" && scResult && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||||
|
Import complete
|
||||||
|
</h2>
|
||||||
|
{scResult.created.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.created.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.updated.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.updated.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.skipped.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scResult.skipped.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> Errors ({scResult.errors.length})
|
||||||
|
</p>
|
||||||
|
{scResult.errors.map((e, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={handleScReset}>Import Another</Button>
|
||||||
|
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── GROUP ASSIGNMENT PANEL ─────────────────────────────────────── */}
|
||||||
|
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">Assign Shots to Groups</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mt-0.5">
|
||||||
|
Bulk-assign existing shots to groups from a CSV with <span className="font-mono text-zinc-300">Shot Name</span> and <span className="font-mono text-zinc-300">Group</span> columns. Groups are created automatically if they don’t exist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gaStep === "input" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input ref={gaFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleGaFileUpload} />
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => gaFileInputRef.current?.click()}>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload .csv
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={gaCsvText}
|
||||||
|
onChange={(e) => setGaCsvText(e.target.value)}
|
||||||
|
placeholder={`Shot Name,Group\nUNG_108_001_010,Action Sequences\nUNG_108_002_020,Compositing`}
|
||||||
|
className="font-mono text-xs min-h-[180px] bg-zinc-950 border-zinc-700 resize-y"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||||
|
<p className="font-medium text-zinc-300">Expected format</p>
|
||||||
|
<p>Required: <span className="font-mono text-amber-400">Shot Name</span>, <span className="font-mono text-amber-400">Group</span></p>
|
||||||
|
<p>Only shots that already exist in this project are updated. Unknown shots are skipped.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleGaParse} disabled={!gaCsvText.trim()} className="gap-2">
|
||||||
|
<Film className="h-4 w-4" />
|
||||||
|
Parse & Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gaStep === "preview" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
{gaRows.filter((r) => r.exists).length} to assign
|
||||||
|
</div>
|
||||||
|
{gaRows.filter((r) => !r.exists).length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
{gaRows.filter((r) => !r.exists).length} not found (will skip)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gaErrors.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> {gaErrors.length} parse warning{gaErrors.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
{gaErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_auto] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||||
|
<span>Shot Code</span>
|
||||||
|
<span>Group</span>
|
||||||
|
<span>Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||||
|
{gaRows.map((row, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm">
|
||||||
|
<span className="font-mono text-xs text-zinc-200">{row.shotCode}</span>
|
||||||
|
<span className="text-xs text-zinc-300">{row.groupName}</span>
|
||||||
|
{row.exists ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-500/10 text-blue-400 border-blue-500/20">
|
||||||
|
<Pencil className="h-3 w-3" /> assign
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-zinc-500/10 text-zinc-400 border-zinc-500/20">
|
||||||
|
<SkipForward className="h-3 w-3" /> skip
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleGaReset}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleGaAssign}
|
||||||
|
disabled={gaAssigning || gaRows.filter((r) => r.exists).length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{gaAssigning ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{gaAssigning
|
||||||
|
? "Assigning…"
|
||||||
|
: `Assign ${gaRows.filter((r) => r.exists).length} shot${gaRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gaStep === "result" && gaResult && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||||
|
Group assignment complete
|
||||||
|
</h2>
|
||||||
|
{gaResult.assigned.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Assigned ({gaResult.assigned.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{gaResult.assigned.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gaResult.skipped.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped / not found ({gaResult.skipped.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{gaResult.skipped.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gaResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> Errors ({gaResult.errors.length})
|
||||||
|
</p>
|
||||||
|
{gaResult.errors.map((e, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={handleGaReset}>Import Another</Button>
|
||||||
|
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
|
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
|
||||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||||
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
||||||
import { Plus, Search, Loader2 } from "lucide-react";
|
import { Plus, Search, Loader2 } from "lucide-react";
|
||||||
|
import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle";
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [hideArchived, setHideArchived] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHideArchived(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["projects", search],
|
queryKey: ["projects", search],
|
||||||
@@ -35,6 +41,8 @@ export default function ProjectsPage() {
|
|||||||
const projects = data?.projects ?? [];
|
const projects = data?.projects ?? [];
|
||||||
const clients = clientsData?.clients ?? [];
|
const clients = clientsData?.clients ?? [];
|
||||||
|
|
||||||
|
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
|
||||||
|
|
||||||
const SCROLL_KEY = 'projects-scroll';
|
const SCROLL_KEY = 'projects-scroll';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,6 +64,9 @@ export default function ProjectsPage() {
|
|||||||
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||||
<p className="text-zinc-400 mt-1">
|
<p className="text-zinc-400 mt-1">
|
||||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
|
{hideArchived && projects.length !== visibleProjects.length && (
|
||||||
|
<span className="text-zinc-600 ml-1">({projects.length - visibleProjects.length} archived hidden)</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowNew(true)} className="gap-2">
|
<Button onClick={() => setShowNew(true)} className="gap-2">
|
||||||
@@ -94,7 +105,7 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{projects.map((project) => (
|
{visibleProjects.map((project: any) => (
|
||||||
<div key={project.id} onClick={saveScroll}>
|
<div key={project.id} onClick={saveScroll}>
|
||||||
<ProjectCard project={project} />
|
<ProjectCard project={project} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getInitials } from "@/lib/utils";
|
|||||||
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
||||||
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
||||||
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
|
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
|
||||||
|
import { HideArchivedToggle } from "@/components/settings/HideArchivedToggle";
|
||||||
import { getHetznerConfig } from "@/actions/settings";
|
import { getHetznerConfig } from "@/actions/settings";
|
||||||
|
|
||||||
export const metadata = { title: "Settings" };
|
export const metadata = { title: "Settings" };
|
||||||
@@ -42,6 +43,15 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Display Preferences</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<HideArchivedToggle />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
|
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
|
||||||
|
|
||||||
{isAdmin && hetznerConfig && (
|
{isAdmin && hetznerConfig && (
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type ShotRow = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
isKeyShot: boolean;
|
isKeyShot: boolean;
|
||||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||||
|
shotGroup: { id: string; name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -490,6 +491,9 @@ export function ShotStatusClient({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Load saved episode expand state when project changes
|
// Load saved episode expand state when project changes
|
||||||
@@ -501,6 +505,12 @@ export function ShotStatusClient({
|
|||||||
} catch {
|
} catch {
|
||||||
setExpandedEpisodes(new Set());
|
setExpandedEpisodes(new Set());
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
|
||||||
|
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
|
||||||
|
} catch {
|
||||||
|
setGroupMode("flat");
|
||||||
|
}
|
||||||
}, [selectedProjectId]);
|
}, [selectedProjectId]);
|
||||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -516,6 +526,29 @@ export function ShotStatusClient({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleScene = (sc: string) =>
|
||||||
|
setExpandedScenes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(sc) ? next.delete(sc) : next.add(sc);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleGroup = (g: string) =>
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(g) ? next.delete(g) : next.add(g);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
|
||||||
|
setGroupMode(mode);
|
||||||
|
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
|
||||||
|
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
|
||||||
|
if (selectedProjectId) {
|
||||||
|
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProjectChange = (id: string) => {
|
const handleProjectChange = (id: string) => {
|
||||||
localStorage.setItem("shotStatus:lastProjectId", id);
|
localStorage.setItem("shotStatus:lastProjectId", id);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
@@ -567,6 +600,31 @@ export function ShotStatusClient({
|
|||||||
})()
|
})()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
|
||||||
|
? (() => {
|
||||||
|
const map = new Map<string, ShotRow[]>();
|
||||||
|
for (const shot of shots) {
|
||||||
|
const key = shot.scene || "(No Scene)";
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(shot);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
})()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
|
||||||
|
? (() => {
|
||||||
|
const map = new Map<string, [string, ShotRow[]]>();
|
||||||
|
for (const shot of shots) {
|
||||||
|
const id = shot.shotGroup?.id ?? "";
|
||||||
|
const name = shot.shotGroup?.name ?? "(No Group)";
|
||||||
|
if (!map.has(id)) map.set(id, [name, []]);
|
||||||
|
map.get(id)![1].push(shot);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
|
||||||
|
})()
|
||||||
|
: [];
|
||||||
|
|
||||||
const episodeDueDateMap = new Map(
|
const episodeDueDateMap = new Map(
|
||||||
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
||||||
);
|
);
|
||||||
@@ -605,6 +663,25 @@ export function ShotStatusClient({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{selectedProject && !isEpisodic && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-zinc-500">Group by:</span>
|
||||||
|
{(["flat", "scene", "group"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => handleGroupMode(mode)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 rounded text-xs font-medium transition-colors",
|
||||||
|
groupMode === mode
|
||||||
|
? "bg-zinc-700 text-zinc-100"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No project selected */}
|
{/* No project selected */}
|
||||||
@@ -727,6 +804,104 @@ export function ShotStatusClient({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : groupMode === "scene" ? (
|
||||||
|
<div className="divide-y divide-zinc-800">
|
||||||
|
{sceneGroups.map(([scene, sceneShots]) => {
|
||||||
|
const collapsed = !expandedScenes.has(scene);
|
||||||
|
const sceneIds = sceneShots.map((s) => s.id);
|
||||||
|
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
|
||||||
|
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
|
||||||
|
return (
|
||||||
|
<div key={scene}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||||
|
{canManage && (
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={allScSelected}
|
||||||
|
indeterminate={someScSelected && !allScSelected}
|
||||||
|
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleScene(scene)}
|
||||||
|
className="flex items-center gap-2 flex-1 text-left"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sm text-white">Scene {scene}</span>
|
||||||
|
<span className="text-xs text-zinc-500 font-normal">
|
||||||
|
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<ShotTable
|
||||||
|
shots={sceneShots}
|
||||||
|
canManage={canManage}
|
||||||
|
projectId={selectedProjectId!}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={toggleShot}
|
||||||
|
onToggleAll={toggleMany}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : groupMode === "group" ? (
|
||||||
|
<div className="divide-y divide-zinc-800">
|
||||||
|
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
|
||||||
|
const collapsed = !expandedGroups.has(groupId);
|
||||||
|
const groupIds = groupShots.map((s) => s.id);
|
||||||
|
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
|
||||||
|
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
|
||||||
|
return (
|
||||||
|
<div key={groupId}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||||
|
{canManage && (
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={allGrSelected}
|
||||||
|
indeterminate={someGrSelected && !allGrSelected}
|
||||||
|
onChange={(e) => toggleMany(groupIds, e.target.checked)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(groupId)}
|
||||||
|
className="flex items-center gap-2 flex-1 text-left"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sm text-white">{groupName}</span>
|
||||||
|
<span className="text-xs text-zinc-500 font-normal">
|
||||||
|
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<ShotTable
|
||||||
|
shots={groupShots}
|
||||||
|
canManage={canManage}
|
||||||
|
projectId={selectedProjectId!}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={toggleShot}
|
||||||
|
onToggleAll={toggleMany}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<ShotTable
|
<ShotTable
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ async function getShotsForProject(projectId: string) {
|
|||||||
description: true,
|
description: true,
|
||||||
isKeyShot: true,
|
isKeyShot: true,
|
||||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
shotGroup: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
|
||||||
|
|
||||||
|
export interface ThumbnailPreviewItem {
|
||||||
|
fileName: string;
|
||||||
|
stemName: string;
|
||||||
|
shotCode: string | null;
|
||||||
|
shotId: string | null;
|
||||||
|
status: "match" | "no-match";
|
||||||
|
currentThumbnailUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stem(fileName: string): string {
|
||||||
|
const dot = fileName.lastIndexOf(".");
|
||||||
|
return (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-upload/thumbnails
|
||||||
|
* Body: { projectId: string; fileNames: string[] }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
let body: { projectId?: string; fileNames?: string[] };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, fileNames } = body;
|
||||||
|
if (!projectId || !Array.isArray(fileNames)) {
|
||||||
|
return NextResponse.json({ error: "projectId and fileNames are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only image files
|
||||||
|
const imageFiles = fileNames.filter((f) => {
|
||||||
|
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
return IMAGE_EXTS.has(ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shots = await db.shot.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: { id: true, shotCode: true, thumbnailUrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a lowercase map for case-insensitive matching
|
||||||
|
const shotMap = new Map(shots.map((s) => [s.shotCode.toLowerCase(), s]));
|
||||||
|
|
||||||
|
const items: ThumbnailPreviewItem[] = fileNames.map((fileName) => {
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (!IMAGE_EXTS.has(ext)) {
|
||||||
|
return { fileName, stemName: stem(fileName), shotCode: null, shotId: null, status: "no-match" as const, currentThumbnailUrl: null };
|
||||||
|
}
|
||||||
|
const s = stem(fileName);
|
||||||
|
const shot = shotMap.get(s) ?? null;
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
stemName: s,
|
||||||
|
shotCode: shot?.shotCode ?? null,
|
||||||
|
shotId: shot?.id ?? null,
|
||||||
|
status: shot ? "match" : "no-match",
|
||||||
|
currentThumbnailUrl: shot?.thumbnailUrl ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadToHetzner } from "@/lib/storage";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-upload/thumbnails/upload
|
||||||
|
* FormData: { projectId, shotId, file (image) }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const shotId = formData.get("shotId") as string | null;
|
||||||
|
const projectId = formData.get("projectId") as string | null;
|
||||||
|
|
||||||
|
if (!file || !shotId || !projectId) {
|
||||||
|
return NextResponse.json({ error: "file, shotId and projectId are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shot = await db.shot.findFirst({ where: { id: shotId, projectId }, select: { id: true } });
|
||||||
|
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const { key } = await uploadToHetzner(buffer, file.name, file.type, "image");
|
||||||
|
const thumbnailUrl = `/api/files/${key}`;
|
||||||
|
|
||||||
|
await db.shot.update({ where: { id: shotId }, data: { thumbnailUrl } });
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, thumbnailUrl });
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Root-level error boundary — must include its own <html>/<body>
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (error?.name === "ChunkLoadError") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body className="flex items-center justify-center min-h-screen bg-zinc-950 text-zinc-200">
|
||||||
|
{error?.name === "ChunkLoadError" ? null : (
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-lg font-medium">Application error</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p className="text-sm text-zinc-500">Digest: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,10 +20,12 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ImageIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
|
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
|
||||||
|
import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -54,7 +56,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
|||||||
const [uploadComplete, setUploadComplete] = useState(false);
|
const [uploadComplete, setUploadComplete] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Thumbnail upload state ─────────────────────────────────────────────────
|
||||||
|
const thumbInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [thumbFiles, setThumbFiles] = useState<File[]>([]);
|
||||||
|
const [thumbIsDragging, setThumbIsDragging] = useState(false);
|
||||||
|
const [thumbPreview, setThumbPreview] = useState<ThumbnailPreviewItem[] | null>(null);
|
||||||
|
const [thumbLoadingPreview, setThumbLoadingPreview] = useState(false);
|
||||||
|
const [thumbUploadStates, setThumbUploadStates] = useState<Record<string, UploadState>>({});
|
||||||
|
const [thumbIsUploading, setThumbIsUploading] = useState(false);
|
||||||
|
const [thumbUploadComplete, setThumbUploadComplete] = useState(false);
|
||||||
|
|
||||||
|
const THUMB_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
|
||||||
|
const acceptThumb = (f: File) => THUMB_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? "");
|
||||||
|
|
||||||
|
const resetThumbs = () => {
|
||||||
|
setThumbFiles([]);
|
||||||
|
setThumbPreview(null);
|
||||||
|
setThumbUploadStates({});
|
||||||
|
setThumbUploadComplete(false);
|
||||||
|
};
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
@@ -242,6 +262,68 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
|||||||
setUploadComplete(true);
|
setUploadComplete(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Thumbnail handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleThumbDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setThumbIsDragging(false);
|
||||||
|
const dropped = Array.from(e.dataTransfer.files).filter(acceptThumb);
|
||||||
|
if (dropped.length > 0) { setThumbFiles(dropped); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); }
|
||||||
|
else toast({ title: "No image files", description: "Only JPG, PNG, WebP, TIFF images are accepted." });
|
||||||
|
}, [toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleThumbInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = Array.from(e.target.files ?? []).filter(acceptThumb);
|
||||||
|
if (selected.length > 0) { setThumbFiles(selected); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); }
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchThumbPreview = async () => {
|
||||||
|
if (!projectId || thumbFiles.length === 0) return;
|
||||||
|
setThumbLoadingPreview(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/batch-upload/thumbnails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ projectId, fileNames: thumbFiles.map((f) => f.name) }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Preview failed");
|
||||||
|
const data = await res.json();
|
||||||
|
setThumbPreview(data.items);
|
||||||
|
const states: Record<string, UploadState> = {};
|
||||||
|
for (const item of data.items as ThumbnailPreviewItem[]) states[item.fileName] = { status: "pending" };
|
||||||
|
setThumbUploadStates(states);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Preview failed", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setThumbLoadingPreview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startThumbUpload = async () => {
|
||||||
|
if (!thumbPreview || !projectId) return;
|
||||||
|
setThumbIsUploading(true);
|
||||||
|
const matched = thumbPreview.filter((i) => i.status === "match");
|
||||||
|
for (const item of matched) {
|
||||||
|
const file = thumbFiles.find((f) => f.name === item.fileName);
|
||||||
|
if (!file) continue;
|
||||||
|
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading" } }));
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("shotId", item.shotId!);
|
||||||
|
fd.append("projectId", projectId);
|
||||||
|
const res = await fetch("/api/batch-upload/thumbnails/upload", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) { const d = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(d.error ?? "Upload failed"); }
|
||||||
|
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } }));
|
||||||
|
} catch (err) {
|
||||||
|
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: err instanceof Error ? err.message : "Upload failed" } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setThumbIsUploading(false);
|
||||||
|
setThumbUploadComplete(true);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const uploadable = preview?.filter(
|
const uploadable = preview?.filter(
|
||||||
@@ -474,6 +556,187 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Thumbnail bulk upload ─────────────────────────────────────────── */}
|
||||||
|
{projectId && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-zinc-800 pt-2">
|
||||||
|
<h2 className="text-base font-semibold text-white">Bulk Thumbnail Upload</h2>
|
||||||
|
<p className="text-zinc-400 text-sm mt-1">
|
||||||
|
Drop images here to assign thumbnails to existing shots. Files are matched by filename (without extension) to shot codes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumb drop zone */}
|
||||||
|
{!thumbUploadComplete && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setThumbIsDragging(true); }}
|
||||||
|
onDragLeave={() => setThumbIsDragging(false)}
|
||||||
|
onDrop={handleThumbDrop}
|
||||||
|
onClick={() => thumbInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none",
|
||||||
|
thumbIsDragging
|
||||||
|
? "border-blue-400 bg-blue-400/5"
|
||||||
|
: "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
|
||||||
|
<p className="text-zinc-300 font-medium">Drop thumbnail images here</p>
|
||||||
|
<p className="text-zinc-500 text-sm mt-1">JPG, PNG, WebP, TIFF · or click to browse</p>
|
||||||
|
{thumbFiles.length > 0 && (
|
||||||
|
<p className="text-blue-400 text-sm mt-3 font-medium">
|
||||||
|
{thumbFiles.length} image{thumbFiles.length !== 1 ? "s" : ""} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={thumbInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleThumbInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{thumbFiles.length > 0 && !thumbPreview && (
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<Button onClick={fetchThumbPreview} disabled={thumbLoadingPreview}>
|
||||||
|
{thumbLoadingPreview ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
|
||||||
|
) : (
|
||||||
|
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={resetThumbs} disabled={thumbLoadingPreview}>Clear</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumb preview table */}
|
||||||
|
{thumbPreview && !thumbUploadComplete && (() => {
|
||||||
|
const matched = thumbPreview.filter((i) => i.status === "match");
|
||||||
|
const unmatched = thumbPreview.filter((i) => i.status === "no-match");
|
||||||
|
const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length;
|
||||||
|
const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length;
|
||||||
|
const thumbDone = thumbSuccess + thumbErrors;
|
||||||
|
const thumbProgress = matched.length > 0 ? (thumbDone / matched.length) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="text-base font-medium text-zinc-200">
|
||||||
|
Thumbnail preview —{" "}
|
||||||
|
<span className="text-zinc-400 font-normal">
|
||||||
|
{matched.length} matched · {unmatched.length} unmatched
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 mt-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
|
||||||
|
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
|
||||||
|
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-28">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
|
{thumbPreview.map((item) => {
|
||||||
|
const state = thumbUploadStates[item.fileName];
|
||||||
|
return (
|
||||||
|
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||||
|
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{item.shotCode ? (
|
||||||
|
<span className="font-mono text-xs text-zinc-300">{item.shotCode}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600 italic">no match</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{item.status === "no-match" ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
|
||||||
|
) : !state || state.status === "pending" ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-blue-400"><ArrowRight className="h-3.5 w-3.5" />set thumbnail</span>
|
||||||
|
) : state.status === "uploading" ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-400"><Loader2 className="h-3.5 w-3.5 animate-spin" />uploading</span>
|
||||||
|
) : state.status === "success" ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{thumbIsUploading && (
|
||||||
|
<div className="px-6 py-3 border-t border-zinc-800">
|
||||||
|
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
|
||||||
|
<span>Uploading {Math.min(thumbDone + 1, matched.length)} of {matched.length}…</span>
|
||||||
|
<span>{Math.round(thumbProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={thumbProgress} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-zinc-500 text-xs">
|
||||||
|
{matched.length} thumbnail{matched.length !== 1 ? "s" : ""} will be assigned
|
||||||
|
{unmatched.length > 0 && <span className="text-zinc-600"> · {unmatched.length} skipped (no matching shot)</span>}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 shrink-0">
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetThumbs} disabled={thumbIsUploading}>Change files</Button>
|
||||||
|
<Button size="sm" onClick={startThumbUpload} disabled={thumbIsUploading || matched.length === 0}>
|
||||||
|
{thumbIsUploading ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
|
||||||
|
) : (
|
||||||
|
`Upload ${matched.length} thumbnail${matched.length !== 1 ? "s" : ""}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Thumb done summary */}
|
||||||
|
{thumbUploadComplete && (() => {
|
||||||
|
const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length;
|
||||||
|
const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold text-lg">Thumbnails uploaded</p>
|
||||||
|
<p className="text-zinc-400 text-sm mt-1">
|
||||||
|
{thumbSuccess} assigned{thumbErrors > 0 && <span className="text-red-400"> · {thumbErrors} failed</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={resetThumbs} className="mt-2">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />Upload more thumbnails
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export const HIDE_ARCHIVED_KEY = "app:hideArchivedProjects";
|
||||||
|
|
||||||
|
export function HideArchivedToggle() {
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChecked(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (val: boolean) => {
|
||||||
|
setChecked(val);
|
||||||
|
localStorage.setItem(HIDE_ARCHIVED_KEY, val ? "1" : "0");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => toggle(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-blue-500 w-4 h-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-zinc-200">Hide archived productions</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Archived projects won't appear on the Projects page</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -184,3 +184,145 @@ export function parsePictureTrackerCsv(
|
|||||||
|
|
||||||
return { rows, errors };
|
return { rows, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Simple Shot CSV ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimpleCsvRow {
|
||||||
|
shotCode: string;
|
||||||
|
seqTimecodeStart: string;
|
||||||
|
seqTimecodeEnd: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
action: "create" | "update" | "skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a simple shot CSV: Shot Name, Time Code In, Time Code Out, Description.
|
||||||
|
* Column names are matched case-insensitively with common aliases.
|
||||||
|
* Timecodes are validated but not required.
|
||||||
|
*/
|
||||||
|
export function parseSimpleCsv(
|
||||||
|
csvText: string,
|
||||||
|
existingCodes: Set<string>
|
||||||
|
): { rows: SimpleCsvRow[]; errors: string[] } {
|
||||||
|
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
|
||||||
|
const col = (names: string[]) => {
|
||||||
|
for (const name of names) {
|
||||||
|
const idx = rawHeaders.indexOf(name);
|
||||||
|
if (idx !== -1) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
|
||||||
|
const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]);
|
||||||
|
const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]);
|
||||||
|
const descIdx = col(["description", "desc", "notes", "note"]);
|
||||||
|
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
|
||||||
|
|
||||||
|
if (shotNameIdx === -1) {
|
||||||
|
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: SimpleCsvRow[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = parseCsvLine(lines[i]);
|
||||||
|
const get = (idx: number) =>
|
||||||
|
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
|
||||||
|
|
||||||
|
const shotCode = get(shotNameIdx);
|
||||||
|
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
|
||||||
|
|
||||||
|
const tcIn = get(tcInIdx);
|
||||||
|
const tcOut = get(tcOutIdx);
|
||||||
|
|
||||||
|
if (tcIn && !isValidTimecode(tcIn)) {
|
||||||
|
errors.push(`Row ${i + 1} (${shotCode}): invalid TC In "${tcIn}" — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tcOut && !isValidTimecode(tcOut)) {
|
||||||
|
errors.push(`Row ${i + 1} (${shotCode}): invalid TC Out "${tcOut}" — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
shotCode,
|
||||||
|
seqTimecodeStart: tcIn,
|
||||||
|
seqTimecodeEnd: tcOut,
|
||||||
|
description: get(descIdx),
|
||||||
|
group: get(groupIdx),
|
||||||
|
action: existingCodes.has(shotCode) ? "update" : "create",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group Assignment CSV ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GroupAssignRow {
|
||||||
|
shotCode: string;
|
||||||
|
groupName: string;
|
||||||
|
exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a group-assignment CSV: Shot Name, Group.
|
||||||
|
* Only shots that already exist in the project will be updated.
|
||||||
|
*/
|
||||||
|
export function parseGroupAssignCsv(
|
||||||
|
csvText: string,
|
||||||
|
existingCodes: Set<string>
|
||||||
|
): { rows: GroupAssignRow[]; errors: string[] } {
|
||||||
|
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
|
||||||
|
const col = (names: string[]) => {
|
||||||
|
for (const name of names) {
|
||||||
|
const idx = rawHeaders.indexOf(name);
|
||||||
|
if (idx !== -1) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
|
||||||
|
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
|
||||||
|
|
||||||
|
if (shotNameIdx === -1) {
|
||||||
|
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
|
||||||
|
}
|
||||||
|
if (groupIdx === -1) {
|
||||||
|
return { rows: [], errors: ['CSV must contain a "Group" column'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: GroupAssignRow[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = parseCsvLine(lines[i]);
|
||||||
|
const get = (idx: number) =>
|
||||||
|
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
|
||||||
|
|
||||||
|
const shotCode = get(shotNameIdx);
|
||||||
|
const groupName = get(groupIdx);
|
||||||
|
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
|
||||||
|
if (!groupName) { errors.push(`Row ${i + 1} (${shotCode}): empty Group — skipped`); continue; }
|
||||||
|
if (seen.has(shotCode)) continue;
|
||||||
|
seen.add(shotCode);
|
||||||
|
|
||||||
|
rows.push({ shotCode, groupName, exists: existingCodes.has(shotCode) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/*$';
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user