feat(batch-upload): bulk footage plate upload matched by filename
Deploy / deploy (push) Successful in 6m39s
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:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PLATE_EXTS, parsePlateFilename, plateContentType } from "./batch-upload-parser";
|
||||
|
||||
const SHOTS = ["DH2_VFX_058_120", "DH2_VFX_058_120_B", "DH2_VFX_058_10", "UNG_106_035_010"];
|
||||
|
||||
describe("parsePlateFilename", () => {
|
||||
it("derives the shot and label from a suffixed plate name", () => {
|
||||
const p = parsePlateFilename("DH2_VFX_058_120_REF.mov", SHOTS);
|
||||
expect(p.shotCode).toBe("DH2_VFX_058_120");
|
||||
expect(p.label).toBe("REF");
|
||||
expect(p.ext).toBe("mov");
|
||||
});
|
||||
|
||||
it("returns an empty label when the filename is exactly the shot code", () => {
|
||||
const p = parsePlateFilename("DH2_VFX_058_120.mov", SHOTS);
|
||||
expect(p.shotCode).toBe("DH2_VFX_058_120");
|
||||
expect(p.label).toBe("");
|
||||
});
|
||||
|
||||
it("prefers the longest matching shot code", () => {
|
||||
const p = parsePlateFilename("DH2_VFX_058_120_B_REF.mov", SHOTS);
|
||||
expect(p.shotCode).toBe("DH2_VFX_058_120_B");
|
||||
expect(p.label).toBe("REF");
|
||||
});
|
||||
|
||||
it("does not match a shot code that is only a partial segment", () => {
|
||||
// "DH2_VFX_058_10" must not swallow "DH2_VFX_058_1000"
|
||||
expect(parsePlateFilename("DH2_VFX_058_1000_REF.mov", SHOTS).shotCode).toBeNull();
|
||||
});
|
||||
|
||||
it("matches case-insensitively but reports the canonical shot code", () => {
|
||||
const p = parsePlateFilename("dh2_vfx_058_120_ref.mov", SHOTS);
|
||||
expect(p.shotCode).toBe("DH2_VFX_058_120");
|
||||
expect(p.label).toBe("ref");
|
||||
});
|
||||
|
||||
it("keeps multi-part labels intact", () => {
|
||||
expect(parsePlateFilename("UNG_106_035_010_BG_CLEAN_v002.mov", SHOTS).label).toBe(
|
||||
"BG_CLEAN_v002"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no shot code when nothing matches", () => {
|
||||
const p = parsePlateFilename("random_file.mov", SHOTS);
|
||||
expect(p.shotCode).toBeNull();
|
||||
expect(p.label).toBe("");
|
||||
});
|
||||
|
||||
it("handles filenames with dots in them", () => {
|
||||
const p = parsePlateFilename("DH2_VFX_058_120_REF.v2.mov", SHOTS);
|
||||
expect(p.shotCode).toBe("DH2_VFX_058_120");
|
||||
expect(p.label).toBe("REF.v2");
|
||||
expect(p.ext).toBe("mov");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plateContentType", () => {
|
||||
it("maps every accepted extension to a video/* type", () => {
|
||||
for (const ext of PLATE_EXTS) {
|
||||
expect(plateContentType(`shot.${ext}`)).toMatch(/^video\//);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(plateContentType("SHOT.MOV")).toBe("video/quicktime");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user