diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index 5ebc175..8dea799 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg"; // Bump on every change deployed to a workstation. The panel reports this to // the server on load, so Pipeline → Machines shows which version each machine // is actually running and flags the ones left behind. -var PANEL_VERSION = "2.4.0"; +var PANEL_VERSION = "2.5.0"; (function VFXReviewConnector(thisObj) { var uiState = { @@ -701,6 +701,37 @@ var PANEL_VERSION = "2.4.0"; return CURRENT_PROJECT ? trimText(CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; } + // ── Per-project templates ───────────────────────────────────────────────── + // + // The server resolves these (convention from the show id, overridden per + // project where a template departs from it) and hands them over with the + // project list, so the panel and the headless preview build cannot disagree + // about which template — or which show LUT — a shot was built with. + + function getTemplates() { + return (CURRENT_PROJECT && CURRENT_PROJECT.templates) ? CURRENT_PROJECT.templates : {}; + } + + function getOverlayCompName() { + return trimText(getTemplates().overlayComp) || (getShowId() ? getShowId() + "_VFX_OVERLAY" : "VFX_OVERLAY"); + } + + function getPreviewTemplateCompName() { + return trimText(getTemplates().previewComp) || (getShowId() ? getShowId() + "_EXPORT_TEMPLATE" : "EXPORT_TEMPLATE"); + } + + function getLutCompName() { + return trimText(getTemplates().lutComp) || "_SHOW LUT"; + } + + function getTemplateAepPath() { + return trimText(getTemplates().templateAep); + } + + function getRendersRoot() { + return trimText(getTemplates().rendersRoot); + } + function isEpisodicProject() { return !!(CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"); } @@ -1340,7 +1371,7 @@ var PANEL_VERSION = "2.4.0"; layer = comp.layer(i); try { - if (layer.source && layer.source instanceof CompItem && layer.source.name === "UNG_VFX_OVERLAY") { + if (layer.source && layer.source instanceof CompItem && layer.source.name === getOverlayCompName()) { return layer; } } catch (layerError) { @@ -1508,7 +1539,7 @@ var PANEL_VERSION = "2.4.0"; } function updateOverlayForComp(comp, shotCode, apiData) { - var overlayComp = findComp("UNG_VFX_OVERLAY"); + var overlayComp = findComp(getOverlayCompName()); var overlayLayer; var essentialProperties; var overlayShotCode; @@ -1597,8 +1628,8 @@ var PANEL_VERSION = "2.4.0"; shotCode = shot.shotCode; exrOutput = shot.exrOutput; - disableLayerByName(comp, "UNG_VFX_OVERLAY"); - disableLayerByName(comp, "_SHOW LUT"); + disableLayerByName(comp, getOverlayCompName()); + disableLayerByName(comp, getLutCompName()); folderPath = getShotExportDir(shot); if (!ensureFolder(folderPath)) { @@ -1621,7 +1652,7 @@ var PANEL_VERSION = "2.4.0"; function addOverlay() { var comps = getSelectedComps(); - var overlayComp = findComp("UNG_VFX_OVERLAY"); + var overlayComp = findComp(getOverlayCompName()); var overlayLayer; var added = 0; var updated = 0; @@ -1642,7 +1673,7 @@ var PANEL_VERSION = "2.4.0"; } if (!overlayComp) { - updateStatus(comps.length, "UNG_VFX_OVERLAY not found."); + updateStatus(comps.length, getOverlayCompName() + " not found."); return; } @@ -1682,7 +1713,7 @@ var PANEL_VERSION = "2.4.0"; } function buildPreviewForShot(shotComp, shotCode, apiData) { - var templateComp = findComp("UNG_EXPORT_TEMPLATE"); + var templateComp = findComp(getPreviewTemplateCompName()); var previewsFolder = findFolder("_PREVIEWS"); var shot; var previewComp; @@ -1796,7 +1827,7 @@ var PANEL_VERSION = "2.4.0"; if (existingShotComp) { diagLog("comp " + shotCode + " already exists — refreshing its overlay only"); if (!updateOverlayForComp(existingShotComp, shotCode, apiData)) { - diagLog("overlay refresh failed (non-fatal): UNG_VFX_OVERLAY comp or its Essential Properties are missing"); + diagLog("overlay refresh failed (non-fatal): " + getOverlayCompName() + " comp or its Essential Properties are missing"); } return existingShotComp; } @@ -1909,16 +1940,16 @@ var PANEL_VERSION = "2.4.0"; return null; } - showLut = findComp("_SHOW LUT"); + showLut = findComp(getLutCompName()); if (showLut) { lutLayer = shotComp.layers.add(showLut); lutLayer.collapseTransformation = true; } else { - diagLog("no _SHOW LUT comp in this project — shot built without it"); + diagLog("no " + getLutCompName() + " comp in this project — shot built without it"); } if (!updateOverlayForComp(shotComp, shotCode, apiData)) { - diagLog("overlay not applied (non-fatal): UNG_VFX_OVERLAY comp missing, or its DATE/SHOT NAME Essential Properties are not exposed"); + diagLog("overlay not applied (non-fatal): " + getOverlayCompName() + " comp missing, or its DATE/SHOT NAME Essential Properties are not exposed"); } diagLog("built comp " + shotComp.name + " (" + width + "x" + height + " @ " + fps + " fps, " + Math.round(maxDuration * fps) + " frames, " + footageItems.length + " plate layer(s))"); @@ -2018,7 +2049,7 @@ var PANEL_VERSION = "2.4.0"; var exportRoot; var names; var missingItems = []; - var requiredComps = ["UNG_VFX_OVERLAY", "UNG_EXPORT_TEMPLATE", "_SHOW LUT"]; + var requiredComps = [getOverlayCompName(), getPreviewTemplateCompName(), getLutCompName()]; var requiredFolders = ["__SHOTS", "_FOOTAGE_4K", "_PRECOMPS", "_PREVIEWS"]; var i; @@ -2156,7 +2187,7 @@ var PANEL_VERSION = "2.4.0"; function buildPreviews() { var comps = getTargetComps(); - var templateComp = findComp("UNG_EXPORT_TEMPLATE"); + var templateComp = findComp(getPreviewTemplateCompName()); var created = 0; var skipped = 0; var i; @@ -2171,7 +2202,7 @@ var PANEL_VERSION = "2.4.0"; } if (!templateComp) { - updateStatus(comps.length, "UNG_EXPORT_TEMPLATE not found."); + updateStatus(comps.length, getPreviewTemplateCompName() + " not found."); return; } @@ -2293,8 +2324,8 @@ var PANEL_VERSION = "2.4.0"; } shotCode = shot.shotCode; - disableLayerByName(comp, "UNG_VFX_OVERLAY"); - disableLayerByName(comp, "_SHOW LUT"); + disableLayerByName(comp, getOverlayCompName()); + disableLayerByName(comp, getLutCompName()); folderPath = getShotExportDir(shot); if (!ensureFolder(folderPath)) { @@ -2448,7 +2479,14 @@ var PANEL_VERSION = "2.4.0"; // The comp being imported into is already at the show's rate; conforming // renders to anything else would resample them. renderFps = resolveFps(comp.frameRate); - rendersRoot = new Folder("X:/shared_projects_2026/UNGO_VFX/production/renders/" + shotCode); + + if (!getRendersRoot()) { + updateStatus(getSelectedComps().length, + "No renders root configured for " + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "this project") + + " — set it on the project's Pipeline settings in the web app."); + return; + } + rendersRoot = new Folder(getRendersRoot().replace(/\/+$/, "") + "/" + shotCode); if (!rendersRoot.exists) { updateStatus(getSelectedComps().length, "Renders folder not found: " + rendersRoot.fsName); @@ -2905,12 +2943,38 @@ var PANEL_VERSION = "2.4.0"; return newFolder; } + /** + * Episode folder names for __SHOTS. The API is the authority; the footage + * root is the fallback for a show whose episodes have not been entered yet, + * so Initialise Workspace still produces something useful on day one. + */ + function getEpisodeCodesForWorkspace() { + var codes = []; + var episodes = getEpisodesFromAPI(); + var label; + var i; + + for (i = 0; i < episodes.length; i += 1) { + label = trimText(getEpisodeLabel(episodes[i])); + if (label) { + addUniqueText(codes, deriveEpisodeCode(label)); + } + } + + if (codes.length < 1) { + codes = getEpisodeCodesFromDisk(); + diagLog("no episodes from the API — using " + codes.length + " folder(s) found under the footage root"); + } + + codes.sort(); + return codes; + } + function initialiseWorkspace() { var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"]; - var templatePath = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep"; + var templatePath = getTemplateAepPath(); var shotsFolder; - var epNum; - var epCode; + var episodes; var templateFile; var importOptions; var importStatus; @@ -2920,14 +2984,20 @@ var PANEL_VERSION = "2.4.0"; updateStatus(0, "No project open."); return; } + if (!CURRENT_PROJECT) { + updateStatus(0, "Select a project first — the workspace is built from its episodes and template."); + return; + } app.beginUndoGroup("VFXReview Connector - Initialise Workspace"); - // Create __SHOTS with UNG_101 – UNG_117 subfolders + // __SHOTS holds one folder per episode, taken from the project itself + // rather than a hardcoded range: a show's episode list is the server's + // to know, and standard (non-episodic) shows have none at all. shotsFolder = findOrCreateFolder("__SHOTS", null); - for (epNum = 101; epNum <= 117; epNum += 1) { - epCode = "UNG_" + epNum; - findOrCreateFolder(epCode, shotsFolder); + episodes = isEpisodicProject() ? getEpisodeCodesForWorkspace() : []; + for (i = 0; i < episodes.length; i += 1) { + findOrCreateFolder(episodes[i], shotsFolder); } // Create remaining top-level folders @@ -2935,10 +3005,17 @@ var PANEL_VERSION = "2.4.0"; findOrCreateFolder(topFolderNames[i], null); } - // Import AEP template + // Import the show's own template AEP — its overlay, slate template and + // show LUT. Another show's would be quietly the wrong colour. importStatus = ""; - templateFile = new File(templatePath); - if (templateFile.exists) { + if (!templatePath) { + importStatus = " No template AEP configured for " + CURRENT_PROJECT.code + + " — set it on the project's Pipeline settings in the web app."; + templateFile = null; + } else { + templateFile = new File(templatePath); + } + if (templateFile && templateFile.exists) { try { importOptions = new ImportOptions(templateFile); importOptions.importAs = ImportAsType.PROJECT; @@ -2996,7 +3073,10 @@ var PANEL_VERSION = "2.4.0"; colorStatus += " OCIO config must be set manually in Project Settings."; } - updateStatus(getSelectedComps().length, "Workspace initialised." + importStatus + colorStatus); + updateStatus(getSelectedComps().length, + "Workspace initialised for " + CURRENT_PROJECT.code + + (episodes.length ? " (" + episodes.length + " episode folder(s))" : "") + + "." + importStatus + colorStatus); } function clearDropdown(dropdown) { @@ -3264,7 +3344,7 @@ var PANEL_VERSION = "2.4.0"; data.shot.shotVersion = newVersion; versionName = shotCode + "_cmp_TT_" + newVersion; - // 1. Update SHOT NAME on UNG_VFX_OVERLAY inside the selected comp + // 1. Update SHOT NAME on the show overlay inside the selected comp updateOverlayForComp(comps[i], shotCode, data); // 2. Update {shotCode}_PREVIEW slate VersionName if the comp exists diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index 2bc7472..61714db 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -373,17 +373,37 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ ## Workspace +### Per-project templates + +Each show has its own template AEP — its overlay, slate/export template and, +most importantly, its **show LUT**. These are configured per project on the web +app under **Project → Settings → Pipeline**, not in the panel, because the +render farm's headless preview build reads the same values: if the two +disagreed, a farm preview would be quietly the wrong colour. + +| Setting | Purpose | +|---|---| +| **Template project (.aep)** | Imported by Initialise Workspace and by every headless preview build | +| **Renders root** | Where per-shot pre-renders live; the panel appends the shot code (Import Renders) | +| **Overlay / Slate / LUT comp** | Only needed when a template departs from the naming convention | + +Comp names default to a convention derived from the show ID: +`{showId}_VFX_OVERLAY`, `{showId}_EXPORT_TEMPLATE`, `_SHOW LUT`. A show whose +template follows the pattern needs no comp configuration at all — leave the +fields blank and they follow the convention, including any later change to it. + ### Initialise Workspace One-time setup that creates the standard project folder hierarchy, imports the slate/overlay template AEP, and configures color settings. +Works for any project: the episode folders and the template both come from the +selected project rather than being fixed to one show. + **Folder structure created:** ``` __SHOTS/ - UNG_101/ - UNG_102/ - … - UNG_117/ + {EPISODE_CODE}/ ← one per episode, from the project's episode list + … (episodic shows only; standard shows get none) _PREVIEWS/ _PRECOMPS/ _PICLOCKS/ @@ -393,8 +413,15 @@ _RENDERS/ _UTILITIES/ ``` +Episodes come from `GET /api/ext/projects/{code}/episodes`. If a show has none +entered yet, the folders found under its footage root are used instead, so the +button still does something useful on day one. + **Template import:** -Imports `X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep` as a project merge. +Imports the project's **Template project (.aep)** as a project merge — see +[Per-project templates](#per-project-templates). If none is configured the +folders and colour settings are still applied, and the status line says the +template was not set. **Color settings (applied outside the undo group):** - Sets project bit depth to **32-bit**. diff --git a/actions/projects.ts b/actions/projects.ts index 77f2ae1..743ca7f 100644 --- a/actions/projects.ts +++ b/actions/projects.ts @@ -4,7 +4,8 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { ProjectStatus } from "@prisma/client"; +import { Prisma, ProjectStatus } from "@prisma/client"; +import { pipelineConfigSchema } from "@/lib/render-pipeline/project-templates"; const createProjectSchema = z.object({ name: z.string().min(1).max(100), @@ -76,6 +77,8 @@ const updateProjectSchema = z.object({ startDate: z.string().optional().nullable(), slackWebhook: z.string().url().optional().or(z.literal("")).nullable(), slackChannel: z.string().optional().nullable(), + /** Per-production AE workspace: template AEP, renders root, comp overrides. */ + pipelineConfig: pipelineConfigSchema.optional().nullable(), }); export async function updateProject(data: z.infer) { @@ -103,6 +106,15 @@ export async function updateProject(data: z.infer) { ...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }), ...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }), ...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }), + // Blank fields are dropped rather than stored as empty strings, so a comp + // name left blank keeps following the convention instead of pinning "". + ...(rest.pipelineConfig !== undefined && { + pipelineConfig: rest.pipelineConfig + ? (Object.fromEntries( + Object.entries(rest.pipelineConfig).filter(([, v]) => typeof v === "string" && v.trim()) + ) as Prisma.InputJsonValue) + : Prisma.DbNull, + }), }, }); diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index e1f66a3..62fd94c 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -58,6 +58,13 @@ interface ProjectTabsClientProps { startDate: Date | null; slackWebhook: string | null; slackChannel: string | null; + pipelineConfig: { + templateAep?: string | null; + rendersRoot?: string | null; + overlayComp?: string | null; + previewComp?: string | null; + lutComp?: string | null; + } | null; }; clients: Client[]; teamMembers: TeamMember[]; diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index e76c90a..a6bc1fc 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -201,6 +201,13 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st startDate: project.startDate, slackWebhook: project.slackWebhook, slackChannel: project.slackChannel, + pipelineConfig: project.pipelineConfig as { + templateAep?: string | null; + rendersRoot?: string | null; + overlayComp?: string | null; + previewComp?: string | null; + lutComp?: string | null; + } | null, }} clients={clients} teamMembers={teamMembers} diff --git a/app/api/ext/projects/route.ts b/app/api/ext/projects/route.ts index 36d4498..4706449 100644 --- a/app/api/ext/projects/route.ts +++ b/app/api/ext/projects/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; +import { resolveProjectTemplates } from "@/lib/render-pipeline/project-templates"; // ── Auth ───────────────────────────────────────────────────────────────────── @@ -50,11 +51,22 @@ export async function GET(req: NextRequest) { status: true, startDate: true, dueDate: true, + pipelineConfig: true, _count: { select: { shots: true }, }, }, }); - return NextResponse.json({ projects, total: projects.length }); + // Templates are resolved here rather than in the panel so that the panel and + // the headless preview build cannot disagree about which template, or which + // show LUT, a shot was built with. + const withTemplates = await Promise.all( + projects.map(async ({ pipelineConfig, ...project }) => ({ + ...project, + templates: await resolveProjectTemplates({ ...project, pipelineConfig }), + })) + ); + + return NextResponse.json({ projects: withTemplates, total: withTemplates.length }); } diff --git a/components/projects/ProjectSettingsTab.tsx b/components/projects/ProjectSettingsTab.tsx index 8ae9ad9..c3bcb5d 100644 --- a/components/projects/ProjectSettingsTab.tsx +++ b/components/projects/ProjectSettingsTab.tsx @@ -19,7 +19,7 @@ import { } from "@/components/ui/select"; import { updateProject } from "@/actions/projects"; import { useToast } from "@/components/ui/use-toast"; -import { Settings, Building2, Users, Webhook } from "lucide-react"; +import { Settings, Building2, Users, Webhook, Clapperboard } from "lucide-react"; const settingsSchema = z.object({ name: z.string().min(1, "Name is required").max(100), @@ -35,6 +35,12 @@ const settingsSchema = z.object({ startDate: z.string().optional(), slackWebhook: z.string().optional(), slackChannel: z.string().optional(), + // Pipeline — see the Pipeline section for why only paths are required here. + templateAep: z.string().optional(), + rendersRoot: z.string().optional(), + overlayComp: z.string().optional(), + previewComp: z.string().optional(), + lutComp: z.string().optional(), }); type SettingsFormValues = z.infer; @@ -67,6 +73,13 @@ interface ProjectSettingsTabProps { startDate: Date | null; slackWebhook: string | null; slackChannel: string | null; + pipelineConfig: { + templateAep?: string | null; + rendersRoot?: string | null; + overlayComp?: string | null; + previewComp?: string | null; + lutComp?: string | null; + } | null; }; clients: Client[]; teamMembers: TeamMember[]; @@ -109,9 +122,19 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet startDate: toDateInput(project.startDate), slackWebhook: project.slackWebhook ?? "", slackChannel: project.slackChannel ?? "", + templateAep: project.pipelineConfig?.templateAep ?? "", + rendersRoot: project.pipelineConfig?.rendersRoot ?? "", + overlayComp: project.pipelineConfig?.overlayComp ?? "", + previewComp: project.pipelineConfig?.previewComp ?? "", + lutComp: project.pipelineConfig?.lutComp ?? "", }, }); + // Blank comp-name fields mean "use the convention", so they are shown as + // placeholders rather than pre-filled: a project that follows the pattern + // should have nothing stored, and inherit any future change to it. + const showId = watch("showId") || project.showId; + const onSubmit = async (data: SettingsFormValues) => { setIsSubmitting(true); try { @@ -130,6 +153,13 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet startDate: data.startDate || null, slackWebhook: data.slackWebhook || null, slackChannel: data.slackChannel || null, + pipelineConfig: { + templateAep: data.templateAep?.trim() || null, + rendersRoot: data.rendersRoot?.trim() || null, + overlayComp: data.overlayComp?.trim() || null, + previewComp: data.previewComp?.trim() || null, + lutComp: data.lutComp?.trim() || null, + }, }); toast({ title: "Project settings saved" }); router.refresh(); @@ -338,6 +368,68 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet + {/* ── Pipeline ── */} +
+
+ +

Pipeline

+
+ +

+ Where this show's After Effects template lives. Used by the connector panel and by the + render farm's preview build alike, so both work from the same template — and, above + all, the same show LUT. +

+ +
+
+ + +

+ Holds the overlay, the slate/export template and the show LUT. Imported by + Initialise Workspace and by every headless preview build. +

+
+ +
+ + +

+ Where per-shot pre-renders live; the panel appends the shot code. Used by Import Renders. +

+
+
+ +
+

+ Comp names default to the convention below and only need filling in when this show's + template departs from it. Leave blank to follow the convention. +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {/* Save */}