2dee1bf6fc
Deploy / deploy (push) Successful in 6m39s
Adds a third section to the Batch Upload page that attaches plate movies to shots derived from the filename: DH2_VFX_058_120_REF.mov becomes the plate "REF" on shot DH2_VFX_058_120. Shot matching reuses findMatchingShotCode, so it behaves like the rest of the page (case-insensitive, longest code wins, underscore-boundary only) and the filename remainder becomes the label. Bytes go straight to Hetzner via the existing presign endpoint since plates are multi-GB, with XHR for per-file progress. Files whose shot already has a plate of the same name are skipped so re-running a batch does not stack duplicates. Also resets the thumbnail and plate previews when the project changes — they are resolved against a specific project, but only the version-upload flow was being cleared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
/**
|
||
* Utilities for parsing VFX filenames into shot / task metadata.
|
||
*
|
||
* Expected naming convention (any separator count is fine):
|
||
* {SHOW}_{EP}_{SCENE}_{CUT}_{TASK}_{ARTIST}_v{NNN}.{ext}
|
||
* e.g. UNG_106_035_010_cmp_TT_v002.mp4
|
||
*
|
||
* The shot code is matched against the database – the parser itself
|
||
* does NOT hard-code how many underscore segments belong to the shot.
|
||
*/
|
||
|
||
export interface ParsedFilename {
|
||
originalName: string;
|
||
/** Filename without the extension */
|
||
nameWithoutExt: string;
|
||
/** Lowercased extension without the leading dot (e.g. "mp4", "mov") */
|
||
ext: string;
|
||
/**
|
||
* Task name base – the full name without the version suffix.
|
||
* e.g. "UNG_106_035_010_cmp_TT"
|
||
*/
|
||
taskNameBase: string;
|
||
/**
|
||
* Full task title including the version suffix (no extension).
|
||
* This is what the task should be named after upload.
|
||
* e.g. "UNG_106_035_010_cmp_TT_v002"
|
||
*/
|
||
taskNameWithVersion: string;
|
||
/**
|
||
* Version string extracted from the filename, e.g. "v002".
|
||
* Empty string if no version pattern was found.
|
||
*/
|
||
version: string;
|
||
type: "mp4" | "mov" | "other";
|
||
}
|
||
|
||
/**
|
||
* Parse a VFX filename into its constituent parts.
|
||
*/
|
||
export function parseFilename(filename: string): ParsedFilename {
|
||
const lastDot = filename.lastIndexOf(".");
|
||
const ext = lastDot >= 0 ? filename.slice(lastDot + 1).toLowerCase() : "";
|
||
const nameWithoutExt = lastDot >= 0 ? filename.slice(0, lastDot) : filename;
|
||
|
||
// Version pattern: _v followed by one or more digits at the end (case insensitive)
|
||
const versionMatch = nameWithoutExt.match(/_v(\d+)$/i);
|
||
const version = versionMatch ? `v${versionMatch[1]}` : "";
|
||
const taskNameBase = versionMatch
|
||
? nameWithoutExt.slice(0, nameWithoutExt.length - versionMatch[0].length)
|
||
: nameWithoutExt;
|
||
|
||
const type: "mp4" | "mov" | "other" =
|
||
ext === "mp4" ? "mp4" : ext === "mov" ? "mov" : "other";
|
||
|
||
return {
|
||
originalName: filename,
|
||
nameWithoutExt,
|
||
ext,
|
||
taskNameBase,
|
||
taskNameWithVersion: nameWithoutExt,
|
||
version,
|
||
type,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Strip a trailing version suffix (_v###) from a task title.
|
||
* e.g. "UNG_106_035_010_cmp_TT_v001" → "UNG_106_035_010_cmp_TT"
|
||
*/
|
||
export function stripVersionFromTitle(title: string): string {
|
||
return title.replace(/_v\d+$/i, "");
|
||
}
|
||
|
||
/**
|
||
* Given a taskNameBase and a list of shot codes, return the shot code that
|
||
* is a prefix of the task name base (shot code + "_" separator).
|
||
*
|
||
* e.g. taskNameBase "UNG_106_035_010_cmp_TT" matches shot "UNG_106_035_010"
|
||
*/
|
||
export function findMatchingShotCode(
|
||
taskNameBase: string,
|
||
shotCodes: string[]
|
||
): string | null {
|
||
// Sort by length descending so longer (more specific) codes are matched first
|
||
const sorted = [...shotCodes].sort((a, b) => b.length - a.length);
|
||
for (const code of sorted) {
|
||
if (
|
||
taskNameBase === code ||
|
||
taskNameBase.toLowerCase().startsWith(code.toLowerCase() + "_")
|
||
) {
|
||
return code;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Footage plates ────────────────────────────────────────────────────────────
|
||
|
||
/** Video extensions accepted by the footage plate batch upload. */
|
||
export const PLATE_EXTS = new Set(["mov", "mp4", "m4v", "mxf", "avi", "mkv", "webm"]);
|
||
|
||
const PLATE_CONTENT_TYPES: Record<string, string> = {
|
||
mov: "video/quicktime",
|
||
mp4: "video/mp4",
|
||
m4v: "video/mp4",
|
||
mxf: "video/mxf",
|
||
avi: "video/x-msvideo",
|
||
mkv: "video/x-matroska",
|
||
webm: "video/webm",
|
||
};
|
||
|
||
/**
|
||
* Derive a `video/*` content type from a plate filename's extension.
|
||
*
|
||
* Browsers routinely report an empty MIME type for .mov / .mxf files
|
||
* (especially on Windows), and the presign endpoint rejects anything that
|
||
* isn't `video/*`, so never trust `File.type` for these.
|
||
*/
|
||
export function plateContentType(fileName: string): string {
|
||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||
return PLATE_CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||
}
|
||
|
||
export interface ParsedPlateFilename {
|
||
/** Lowercased extension without the leading dot */
|
||
ext: string;
|
||
/** Filename without the extension */
|
||
nameWithoutExt: string;
|
||
/** Shot code this file belongs to, or null when nothing matched */
|
||
shotCode: string | null;
|
||
/**
|
||
* The filename remainder after the shot code, used as the plate label.
|
||
* e.g. "DH2_VFX_058_120_REF.mov" → "REF".
|
||
* Empty when the filename is exactly the shot code.
|
||
*/
|
||
label: string;
|
||
}
|
||
|
||
/**
|
||
* Resolve a footage plate filename to a shot code and a plate label.
|
||
*
|
||
* e.g. "DH2_VFX_058_120_REF.mov" against shot "DH2_VFX_058_120"
|
||
* → { shotCode: "DH2_VFX_058_120", label: "REF" }
|
||
*/
|
||
export function parsePlateFilename(
|
||
fileName: string,
|
||
shotCodes: string[]
|
||
): ParsedPlateFilename {
|
||
const lastDot = fileName.lastIndexOf(".");
|
||
const ext = lastDot >= 0 ? fileName.slice(lastDot + 1).toLowerCase() : "";
|
||
const nameWithoutExt = lastDot >= 0 ? fileName.slice(0, lastDot) : fileName;
|
||
|
||
const shotCode = findMatchingShotCode(nameWithoutExt, shotCodes);
|
||
const label = shotCode
|
||
? nameWithoutExt.slice(shotCode.length).replace(/^[_\-\s]+/, "").trim()
|
||
: "";
|
||
|
||
return { ext, nameWithoutExt, shotCode, label };
|
||
}
|