feat(batch-upload): bulk footage plate upload matched by filename
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>
This commit is contained in:
twotalesanimation
2026-09-12 12:15:29 +02:00
parent 469d782b4f
commit 2dee1bf6fc
5 changed files with 761 additions and 0 deletions
+64
View File
@@ -93,3 +93,67 @@ export function findMatchingShotCode(
}
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 };
}