diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index a5be521..5476fb2 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -33,6 +33,12 @@ var SETTINGS_KEY = "cfg"; var CURRENT_PROJECT = null; // { code, name, showId, projectType } + // Only used when no project is selected, or for a comp belonging to another + // show than the selected one. Assumes a three-letter show code, which is why + // it cannot be the primary pattern any more -- see getShotCodeRegex. + var LEGACY_SHOT_CODE_REGEX = /([A-Z]{3}_\d{3}_\d{3}_\d{3})/; + var LEGACY_EPISODE_CODE_REGEX = /^[A-Z]{3}_\d{3}$/; + var pipelineState = { pollShotCode: null, pollTaskId: 0, @@ -182,24 +188,38 @@ var SETTINGS_KEY = "cfg"; return text; } + // Episode folders on disk are named {showId}_{episode} (UNG/UNG_102/...), so + // the prefix comes from the selected project rather than a hardcoded "UNG_". function deriveEpisodeCode(value) { - var text = String(value); + var text = trimText(value); + var showId = getShowId(); - if (/^[A-Z]{3}_\d{3}$/.test(text)) { + if (showId && text.indexOf(showId + "_") === 0) { return text; } if (/^\d+$/.test(text)) { - return "UNG_" + padEpisodeNumber(text); + return (showId ? showId + "_" : "") + padEpisodeNumber(text); } return text; } function deriveEpisodeAPIValue(value) { - var text = String(value); - var match = text.match(/[A-Z]{3}_(\d{3})/); + var text = trimText(value); + var showId = getShowId(); + var match; + if (showId && text.indexOf(showId + "_") === 0) { + text = text.substring(showId.length + 1); + } + + match = text.match(/^\d+/); + if (match) { + return String(parseInt(match[0], 10)); + } + + match = text.match(/[A-Z]{3}_(\d{3})/); if (match && match.length > 1) { return String(parseInt(match[1], 10)); } @@ -250,8 +270,7 @@ var SETTINGS_KEY = "cfg"; } function getShotsFromAPI(value) { - var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; - var param = isEpisodic ? "episode" : "scene"; + var param = isEpisodicProject() ? "episode" : "scene"; var url = CONFIG.baseUrl + "/api/ext/projects/" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "") + "/shots?" + param + "=" + encodeURIComponent(value); var data = getAPIData(url); return getListFromResponse(data, "shots"); @@ -305,28 +324,103 @@ var SETTINGS_KEY = "cfg"; return null; } + // The showId is the single source of truth for both the on-disk show folder + // and the shot-code prefix: UNG (episodic) and DH2_VFX (standard) both follow + // {showId}_{...}, so anything parsing a code anchors on this rather than + // counting underscores or assuming a three-letter code. + function getShowId() { + return CURRENT_PROJECT ? trimText(CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; + } + + function isEpisodicProject() { + return !!(CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"); + } + + function escapeRegexText(text) { + return String(text).replace(/([.*+?^${}()|[\]\\\-])/g, "\\$1"); + } + + // Shot codes are {showId}_{episode}_{scene}_{shot} on episodic shows and + // {showId}_{scene}_{shot} on standard ones -- so the number of 3-digit tokens + // after the showId is what distinguishes them. + function getShotCodeRegex() { + var showId = getShowId(); + + if (!showId) { + return LEGACY_SHOT_CODE_REGEX; + } + + return new RegExp("(" + escapeRegexText(showId) + + "(?:_\\d{3}){" + (isEpisodicProject() ? 3 : 2) + "})"); + } + + function isEpisodeCode(value) { + var showId = getShowId(); + + if (!showId) { + return LEGACY_EPISODE_CODE_REGEX.test(String(value)); + } + + return new RegExp("^" + escapeRegexText(showId) + "_\\d{3}$").test(String(value)); + } + function getShowExportRoot() { - var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; + var showId = getShowId(); return CONFIG.exportRoot + (showId ? "/" + showId : ""); } function getShowFootageRoot() { - var showId = CURRENT_PROJECT ? (CURRENT_PROJECT.showId || CURRENT_PROJECT.code) : ""; + var showId = getShowId(); return CONFIG.footageRoot + (showId ? "/" + showId : ""); } + // First 3-digit token after the showId, for episodic shots whose API record + // is missing `episode`. + function deriveEpisodeFromShotCode(shotCode) { + var showId = getShowId(); + var text = String(shotCode); + var match; + + if (showId && text.indexOf(showId + "_") === 0) { + match = text.substring(showId.length + 1).match(/^(\d{3})_/); + return match ? match[1] : ""; + } + + match = text.match(/^[A-Z]{3}_(\d{3})_/); + return match ? match[1] : ""; + } + + // The prefix episode-wide files share: "UNG_102" for episodic shows, plain + // "DH2_VFX" for standard ones, which have no episode level. Replaces + // shotCode.substring(0, 7), which only produced this for a 3-letter showId. + function getEpisodePrefix(shotCode) { + var showId = getShowId(); + var episode; + + if (!isEpisodicProject()) { + return showId; + } + + episode = deriveEpisodeFromShotCode(shotCode); + return episode ? showId + "_" + episode : showId; + } + + // Deliveries are grouped per episode ("DELIVERY/UNG_102/..."). Standard shows + // have nothing to group by, so they get no segment at all. + function getDeliveryGroupSegment(shotCode) { + return isEpisodicProject() ? "/" + getEpisodePrefix(shotCode) : ""; + } + // Mirrors the server's export layout so farm and local renders land together: // {exportRoot}/{showId}/[{episode} for episodic]/{shotCode}/{version} function getShotExportDir(shot) { var path = getShowExportRoot(); var episode; - var match; - if (CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC") { + if (isEpisodicProject()) { episode = trimText(shot.episode); if (!episode) { - match = String(shot.shotCode).match(/^[A-Z]{3}_(\d{3})_/); - episode = match ? match[1] : ""; + episode = deriveEpisodeFromShotCode(shot.shotCode); } if (episode) { path += "/" + episode; @@ -527,8 +621,23 @@ var SETTINGS_KEY = "cfg"; return findFolder(episodeCode); } - function getEpisodeRootFolder(episodeCode) { - return new Folder(getShowFootageRoot() + "/" + episodeCode); + // The folder a shot's plates are grouped under: the {showId}_{episode} folder + // on episodic shows, the show root on standard ones -- a scene never gets its + // own folder on disk, so the scene value from the dropdown is not a path. + function getFootageGroupFolder(groupCode) { + var root = getShowFootageRoot(); + + if (isEpisodicProject() && trimText(groupCode)) { + return new Folder(root + "/" + trimText(groupCode)); + } + + return new Folder(root); + } + + // Underscore-prefixed folders are never shots or plates: UNG already uses the + // convention for _ARCHIVE, _GFX, _PICLOCKS and _PULLS. + function isNonShotFolderName(name) { + return String(name).indexOf("_") === 0; } function getEpisodeCodesFromDisk() { @@ -565,7 +674,7 @@ var SETTINGS_KEY = "cfg"; } function getShotCodesForEpisode(episodeCode) { - var root = getEpisodeRootFolder(episodeCode); + var root = getFootageGroupFolder(episodeCode); var folders; var shotCodes = []; var i; @@ -589,8 +698,20 @@ var SETTINGS_KEY = "cfg"; return shotCodes; } - function findSequenceFolders(shotCode, episodeCode) { - var root = getEpisodeRootFolder(episodeCode); + // Two layouts in the wild and neither is being migrated -- moving delivered + // footage would break the file links in every AE project already pointing at + // it. DH2 groups a shot's plates under a {shotCode}/ folder; UNG scatters them + // directly in the episode folder: + // + // {show}/[{show}_{episode}/]{shotCode}/{shotCode}_{ELEM}_{VER}/ (standard) + // {show}/{show}_{episode}/{shotCode}_{ELEM}_{VER}/ (UNG) + // + // The grouped form wins outright where it exists -- an empty shot folder means + // "no plates yet", not "go look at the show root", where stray plate folders + // for the same shot code would otherwise be picked up as its footage. + function findSequenceFolders(shotCode, groupCode) { + var root = getFootageGroupFolder(groupCode); + var shotFolder; var folders; var matches = []; var i; @@ -599,6 +720,18 @@ var SETTINGS_KEY = "cfg"; return matches; } + shotFolder = new Folder(root.fsName + "/" + shotCode); + if (shotFolder.exists) { + folders = shotFolder.getFiles(); + for (i = 0; i < folders.length; i += 1) { + if (folders[i] instanceof Folder && !isNonShotFolderName(folders[i].name)) { + matches.push(folders[i]); + } + } + matches.sort(); + return matches; + } + folders = root.getFiles(); for (i = 0; i < folders.length; i += 1) { if (folders[i] instanceof Folder && folders[i].name.indexOf(shotCode + "_") === 0) { @@ -783,10 +916,20 @@ var SETTINGS_KEY = "cfg"; } function extractShotCode(name) { - var match = String(name).match(/([A-Z]{3}_\d{3}_\d{3}_\d{3})/); + var text = String(name); + var match = text.match(getShotCodeRegex()); + if (match && match.length > 1) { return match[1]; } + + // A comp from a different show than the one selected in the panel: read it + // with the old pattern rather than reporting no shot code at all. + match = text.match(LEGACY_SHOT_CODE_REGEX); + if (match && match.length > 1) { + return match[1]; + } + return null; } @@ -1074,13 +1217,15 @@ var SETTINGS_KEY = "cfg"; } } - function buildShotFromCode(episodeCode, shotCode) { + // groupCode is the episode code for episodic shows and the scene for standard + // ones -- see getFootageGroupFolder for how each resolves on disk. + function buildShotFromCode(groupCode, shotCode) { var existingShotComp = findComp(shotCode); var sequenceFolders; var footageItems = []; var footageFolder = findFolder("_FOOTAGE_4K"); var precompFolder = findFolder("_PRECOMPS"); - var episodeProjectFolder = findEpisodeProjectFolder(episodeCode); + var episodeProjectFolder = findEpisodeProjectFolder(groupCode); var apiData; var footage; var footageComp; @@ -1105,7 +1250,7 @@ var SETTINGS_KEY = "cfg"; return existingShotComp; } - sequenceFolders = findSequenceFolders(shotCode, episodeCode); + sequenceFolders = findSequenceFolders(shotCode, groupCode); if (sequenceFolders.length < 1) { return null; } @@ -1171,10 +1316,10 @@ var SETTINGS_KEY = "cfg"; } function buildShotFromDropdown() { - var episodeCode; + var groupCode; var shotCode; var shotComp; - var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; + var isEpisodic = isEpisodicProject(); if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) { updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first."); @@ -1183,15 +1328,15 @@ var SETTINGS_KEY = "cfg"; // 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); + groupCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); } else { - episodeCode = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text; + groupCode = uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text; } shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text; app.beginUndoGroup("VFXReview Connector - Build Shot"); updateStatus(getSelectedComps().length, "Building shot " + shotCode); - shotComp = buildShotFromCode(episodeCode, shotCode); + shotComp = buildShotFromCode(groupCode, shotCode); app.endUndoGroup(); if (shotComp) { @@ -1657,9 +1802,9 @@ var SETTINGS_KEY = "cfg"; exrFiles.sort(); - var episodeCode = shotCode.substring(0, 7); var deliveryDate = getDateString().replace(/\//g, ""); - deliveryFolderPath = getShowExportRoot() + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode; + deliveryFolderPath = getShowExportRoot() + "/DELIVERY" + getDeliveryGroupSegment(shotCode) + + "/" + deliveryDate + "_Delivery/" + shotCode; if (!ensureFolder(deliveryFolderPath)) { addFailure(result, comp.name, "Could not create delivery folder"); continue; @@ -1769,7 +1914,7 @@ var SETTINGS_KEY = "cfg"; comp = comps[0]; shotCode = getShotCodeFromComp(comp); - episodePrefix = shotCode.substring(0, 7); // e.g. "UNG_106" + episodePrefix = getEpisodePrefix(shotCode); // e.g. "UNG_106" // Fetch API data for seq timecodes updateStatus(getSelectedComps().length, "Looking up " + shotCode + "..."); @@ -1795,7 +1940,7 @@ var SETTINGS_KEY = "cfg"; item = app.project.item(i); if (item instanceof FootageItem && item.parentFolder === piclocksProjectFolder && - item.name.substring(0, 7) === episodePrefix) { + item.name.indexOf(episodePrefix + "_") === 0) { piclockFootage = item; break; } @@ -2082,7 +2227,7 @@ var SETTINGS_KEY = "cfg"; } function populateEpisodeOrSceneDropdown() { - var isEpisodic = CURRENT_PROJECT && CURRENT_PROJECT.projectType === "EPISODIC"; + var isEpisodic = isEpisodicProject(); var i; var item; @@ -2104,7 +2249,7 @@ var SETTINGS_KEY = "cfg"; item = uiState.episodeDropdown.add("item", label); item.filterValue = episodeValue; item.episodeCode = deriveEpisodeCode(label); - if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) { + if (!isEpisodeCode(item.episodeCode)) { item.episodeCode = deriveEpisodeCode(episodeValue); } } diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index 71a97fe..a42e92f 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -56,20 +56,28 @@ Source EXR plates are read from **`/{SHOW_ID}/`**. Configure `foot ``` / -└── {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 +└── {SHOW_ID}/ # e.g. UNG/ or DH2_VFX/ + ├── {EPISODE_CODE}/ # episodic only: e.g. UNG_108/ + │ └── {SHOT_CODE}/ # standard layout: plates grouped per shot + │ └── {SHOT_CODE}_{ELEM}_{VER}/ + │ └── *.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. +**Scenes are not folders.** Standard projects have no level between the show +folder and the shot folder — the scene picker filters the shot list only. + +**Two layouts are supported, and neither is migrated.** The connector prefers the +grouped layout above; where a `{SHOT_CODE}/` folder does not exist it falls back +to scanning the parent for folders named `{SHOT_CODE}_*` (the original UNG +layout, where plates sit directly in the episode folder). All matching folders +are imported as separate footage layers. If a `{SHOT_CODE}/` folder exists but +holds no plate subfolders, that is reported as no footage — the fallback scan is +not attempted, so stray plate folders sitting beside the shot cannot be picked up +as its footage. Underscore-prefixed folders (`_ARCHIVE`, `_GFX`, `_PULLS`, …) are +never treated as shots or plates. --- @@ -95,7 +103,7 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`. ## New production checklist -1. Create `/{SHOW_ID}/{EPISODE or SCENE}/` and place EXR sequences inside, named `{SHOT_CODE}_{clip}`. +1. Create `/{SHOW_ID}/[{EPISODE_CODE}/]{SHOT_CODE}/` — episode level for episodic shows only, never a scene level — and place each plate in its own `{SHOT_CODE}_{ELEM}_{VER}/` folder inside it. 2. `/{SHOW_ID}/` is created automatically on first render. 3. Add the project to the VFXReview server with the correct **Show ID** set. 4. Open the panel in After Effects, click **⚙**, enter the Server URL, API Token, bare `exportRoot`, and bare `footageRoot`, then click **Save**. @@ -158,7 +166,7 @@ Updates the **Status** panel to show the current count of selected `CompItem`s. ### Fix Comp Names Renames selected comps so their names match the shot code extracted from their current name. -- Shot codes follow the pattern `XXX_000_000_000` (e.g. `UNG_106_010_020`). +- Shot codes are read by anchoring on the selected project's showId — see [Shot Code Convention](#shot-code-convention). - If a comp name contains a valid shot code but has extra text, it is trimmed to the code only. - Wrapped in an undo group. @@ -254,7 +262,7 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ **Process:** 1. Calls the API to retrieve `seqTimecodeStart` and `seqTimecodeEnd` for the shot. -2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix (first 7 characters of the shot code, e.g. `UNG_106`). +2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix — `{showId}_{episode}` on episodic shows (e.g. `UNG_106`), plain `{showId}` on standard ones. 3. If not found, scans `FOOTAGE_ROOT/_PICLOCKS/` for a file matching `{episodePrefix}_*` and imports it. 4. Calculates the in/out offsets from the sequence timecodes relative to the piclock's start (assumed `01:00:00:00` / 3590 s unless embedded timecode is available), with **±8 frame handles**. 5. Adds the piclock footage as a layer at the top of the comp, scales it to fill the frame, and sets `startTime`, `inPoint`, and `outPoint` to isolate the correct range. @@ -340,15 +348,19 @@ Accept: application/json ## Shot Code Convention -Shot codes follow the pattern `AAA_NNN_NNN_NNN` where: -- `AAA` = three-letter show prefix (e.g. `UNG`) -- First `NNN` = episode number (e.g. `106`) -- Second `NNN` = sequence number (e.g. `010`) -- Third `NNN` = shot number (e.g. `020`) +Shot codes are the project's **showId** followed by three-digit tokens: -Example: `UNG_106_010_020` +| Project type | Pattern | Example | +|---|---|---| +| Episodic | `{showId}_{episode}_{scene}_{shot}` | `UNG_106_010_020` (showId `UNG`) | +| Standard | `{showId}_{scene}_{shot}` | `DH2_VFX_027_010` (showId `DH2_VFX`) | -Episode codes follow the pattern `AAA_NNN` (e.g. `UNG_106`). +The showId is **not** always three letters and may itself contain underscores, so +codes are parsed by stripping the known showId prefix — never by counting +underscores or matching a fixed `AAA_` pattern. Everything after the showId is +three digits per token. + +Episode codes follow `{showId}_{episode}` (e.g. `UNG_106`). ---