Files
vfxreview/lib/batch-upload-parser.ts
T
twotalesanimation 2dee1bf6fc
Deploy / deploy (push) Successful in 6m39s
feat(batch-upload): bulk footage plate upload matched by filename
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>
2026-09-12 12:15:29 +02:00

160 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}