From bada122f42d0af5651c86866e7d32988b6dd6633 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:24:40 +0200 Subject: [PATCH] feat(pipeline): per-project AE templates, and a workspace that fits any show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialise Workspace built UNG_101..UNG_117 from a hardcoded range and imported one hardcoded AEP, and fourteen findComp calls named UNG's comps literally. None of it worked for a second show. Comp names now follow a convention derived from the show id — {showId}_VFX_OVERLAY, {showId}_EXPORT_TEMPLATE, _SHOW LUT — which reproduces UNG's existing names exactly, so nothing changes for the show it was written for. A project whose template departs from the pattern can override any of the three. Paths cannot be derived: UNG's show id is UNG while its folder is UNGO_VFX. Those live on Project.pipelineConfig and are edited under Project → Settings → Pipeline. They are resolved server-side and handed to the panel with the project list, and read by buildPreviewManifest for the headless build, so the panel and the farm cannot disagree about which template — or which show LUT — a shot was built with. That mattered more than the folder names: a preview rendered through another show's LUT is quietly the wrong colour rather than obviously broken. Episode folders come from the project's episode list, falling back to the folders under its footage root for a show whose episodes are not entered yet. Panel 2.5.0. Adds migration 20260814010000_project_pipeline_config. Co-Authored-By: Claude Opus 5 --- VFXReviewConnector.jsx | 140 ++++++++++++++---- VFXReviewConnector.md | 37 ++++- actions/projects.ts | 14 +- .../projects/[id]/ProjectTabsClient.tsx | 7 + app/(dashboard)/projects/[id]/page.tsx | 7 + app/api/ext/projects/route.ts | 14 +- components/projects/ProjectSettingsTab.tsx | 94 +++++++++++- lib/render-pipeline/preview.ts | 19 ++- lib/render-pipeline/project-templates.test.ts | 34 +++++ lib/render-pipeline/project-templates.ts | 99 +++++++++++++ .../migration.sql | 5 + prisma/schema.prisma | 5 + 12 files changed, 430 insertions(+), 45 deletions(-) create mode 100644 lib/render-pipeline/project-templates.test.ts create mode 100644 lib/render-pipeline/project-templates.ts create mode 100644 prisma/migrations/20260814010000_project_pipeline_config/migration.sql 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 */}