feat(connector): resolve footage per project type, anchor parsing on showId

Build Shot only ever worked on UNG. Two assumptions were baked in: that a shot's
plates sit flat in a folder named after the episode, and that a shot code is
three letters followed by three 3-digit tokens. DH2_VFX breaks both -- it groups
a shot's plates under a {shotCode}/ folder, has no episode level, and its showId
contains a digit and an underscore.

- findSequenceFolders prefers {group}/{shotCode}/ and falls back to UNG's flat
  {group}/{shotCode}_* scan, so both layouts work with no migration -- moving
  delivered footage would break the links in every AE project pointing at it. An
  existing-but-empty shot folder returns nothing rather than falling through, so
  stray plate folders sitting beside a shot cannot be read as its footage.
  Underscore-prefixed folders are never shots or plates.
- getFootageGroupFolder replaces getEpisodeRootFolder: the {showId}_{episode}
  folder for episodic shows, the show root otherwise. A scene filters the shot
  list, it is not a directory -- which is why Build Shot on DH2 was looking in
  V:/_FOOTAGE/DH2/027.
- Shot codes are now parsed by stripping the known showId prefix
  ({showId}_{episode}_{scene}_{shot} for episodic, {showId}_{scene}_{shot} for
  standard) rather than matching /[A-Z]{3}_.../ or counting underscores.
  extractShotCode keeps the old pattern as a fallback so a comp from a different
  show than the selected one still reads. Same anchoring in deriveEpisodeCode
  (no more hardcoded "UNG_"), deriveEpisodeAPIValue, isEpisodeCode and the
  getShotExportDir episode fallback.
- Two positional cuts of the same class, found on the way: the DELIVERY path and
  Pull Picture Lock's episode-prefix match both used shotCode.substring(0, 7).
  Both now go through getEpisodePrefix(). Output is identical for UNG and DH2
  (DH2_VFX happens to be 7 characters); the one behaviour change is that
  standard shows no longer get a constant group level under DELIVERY/, and DH2
  has no delivery history to move.

Verified against the live V:/_FOOTAGE tree by running the real functions under a
Folder stub: UNG_102_019_010 resolves its BG01/FG01 plates via the flat scan, and
DH2_VFX_027_010 and DH2_VFX_066_380 resolve theirs via the grouped layout. Not
yet exercised in After Effects.

Initialise Workspace is untouched and still hardcoded to UNG -- next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-12 18:15:42 +02:00
parent 0ce41123c5
commit 7f2f5e1005
2 changed files with 209 additions and 52 deletions
+178 -33
View File
@@ -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);
}
}
+31 -19
View File
@@ -56,20 +56,28 @@ Source EXR plates are read from **`<Footage Root>/{SHOW_ID}/`**. Configure `foot
```
<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
└── {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 `<Footage Root>/{SHOW_ID}/{EPISODE or SCENE}/` and place EXR sequences inside, named `{SHOT_CODE}_{clip}`.
1. Create `<Footage Root>/{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. `<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**.
@@ -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`).
---