Files
vfxreview/lib/edl-utils.ts
T
twotalesanimation 98120f3ca8
Deploy / deploy (push) Successful in 2m45s
CSV Group updates
2026-08-06 09:13:16 +02:00

329 lines
10 KiB
TypeScript

export interface EdlImportRow {
outputName: string;
sourceClip: string;
timecodeStart: string;
timecodeEnd: string;
clipDuration: string;
// Derived
shotCode: string;
exrOutput: string;
// Action decided during preview
action: "create" | "update" | "skip";
existingShotId?: string;
}
/** Parse a single CSV line, handling quoted fields and escaped double-quotes. */
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') { current += '"'; i++; } // escaped ""
else { inQuotes = false; }
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
result.push(current.trim());
current = "";
} else {
current += ch;
}
}
}
result.push(current.trim());
return result;
}
/**
* Parse a VFX pull CSV into import rows.
* Output Name format: {showId}_{episode}_{scene}_{shot}_BG01_TT_v001
* shotCode = first 4 underscore-separated segments
* exrOutput = {shotCode}_{sourceClip}
*/
export function parseEdlCsv(csvText: string): { rows: EdlImportRow[]; errors: string[] } {
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase());
const col = (name: string) => rawHeaders.indexOf(name);
const outNameIdx = col("output name");
const srcClipIdx = col("source clip");
const tcStartIdx = col("timecode start");
const tcEndIdx = col("timecode end");
const durIdx = col("duration");
if (outNameIdx === -1 || srcClipIdx === -1) {
return { rows: [], errors: ['CSV must contain "Output Name" and "Source Clip" columns'] };
}
const rows: EdlImportRow[] = [];
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i]);
const get = (idx: number) => (idx !== -1 ? cells[idx] ?? "" : "");
const outputName = get(outNameIdx);
const sourceClip = get(srcClipIdx);
if (!outputName) { errors.push(`Row ${i + 1}: empty Output Name — skipped`); continue; }
const parts = outputName.split("_");
if (parts.length < 4) {
errors.push(`Row ${i + 1}: "${outputName}" — cannot parse shot code (need at least 4 segments)`);
continue;
}
const shotCode = parts.slice(0, 4).join("_");
const exrOutput = sourceClip ? `${shotCode}_${sourceClip}` : shotCode;
rows.push({
outputName,
sourceClip,
timecodeStart: get(tcStartIdx),
timecodeEnd: get(tcEndIdx),
clipDuration: get(durIdx),
shotCode,
exrOutput,
action: "create",
});
}
return { rows, errors };
}
// ── Picture Tracker CSV Parser ────────────────────────────────────────────────
export interface PictureTrackerRow {
shotCode: string;
seqTimecodeStart: string;
seqTimecodeEnd: string;
/** true when shotCode matched an existing shot in the project */
exists: boolean;
}
/**
* TC values exported by some tools contain invalid values like "aN:aN:aN:aN"
* or "#ERROR!". Reject anything that doesn't look like HH:MM:SS:FF.
*/
function isValidTimecode(tc: string): boolean {
return /^\d{2}:\d{2}:\d{2}[:;]\d{2}$/.test(tc.trim());
}
/**
* Parse a picture tracker CSV.
*
* Expected header (col indices may vary, but we find by name):
* #, Thumbnail, Shot Name, TC In, TC Out, Duration, ...
*
* Only the FIRST TC In/Out for each unique Shot Name is used
* (subsequent rows for the same shot are ignored).
*
* @param csvText raw CSV text
* @param existingCodes set of shotCodes already in the project
*/
export function parsePictureTrackerCsv(
csvText: string,
existingCodes: Set<string>
): { rows: PictureTrackerRow[]; errors: string[] } {
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) {
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
}
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
const col = (name: string) => rawHeaders.indexOf(name);
const shotNameIdx = col("shot name");
const tcInIdx = col("tc in");
const tcOutIdx = col("tc out");
if (shotNameIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Shot Name" column'] };
}
if (tcInIdx === -1 || tcOutIdx === -1) {
return { rows: [], errors: ['CSV must contain "TC In" and "TC Out" columns'] };
}
const rows: PictureTrackerRow[] = [];
const errors: string[] = [];
const seen = new Set<string>();
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i]);
const get = (idx: number) => (idx < cells.length ? cells[idx] ?? "" : "").trim();
const shotCode = get(shotNameIdx);
if (!shotCode) continue;
// Only use the first occurrence per shot
if (seen.has(shotCode)) continue;
const tcIn = get(tcInIdx);
const tcOut = get(tcOutIdx);
if (!isValidTimecode(tcIn) || !isValidTimecode(tcOut)) {
errors.push(`Row ${i + 1} (${shotCode}): invalid timecode "${tcIn}" / "${tcOut}" — skipped`);
continue;
}
seen.add(shotCode);
rows.push({
shotCode,
seqTimecodeStart: tcIn,
seqTimecodeEnd: tcOut,
exists: existingCodes.has(shotCode),
});
}
return { rows, errors };
}
// ── Simple Shot CSV ──────────────────────────────────────────────────────────
export interface SimpleCsvRow {
shotCode: string;
seqTimecodeStart: string;
seqTimecodeEnd: string;
description: string;
group: string;
action: "create" | "update" | "skip";
}
/**
* Parse a simple shot CSV: Shot Name, Time Code In, Time Code Out, Description.
* Column names are matched case-insensitively with common aliases.
* Timecodes are validated but not required.
*/
export function parseSimpleCsv(
csvText: string,
existingCodes: Set<string>
): { rows: SimpleCsvRow[]; errors: string[] } {
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) {
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
}
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
const col = (names: string[]) => {
for (const name of names) {
const idx = rawHeaders.indexOf(name);
if (idx !== -1) return idx;
}
return -1;
};
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]);
const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]);
const descIdx = col(["description", "desc", "notes", "note"]);
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
if (shotNameIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
}
const rows: SimpleCsvRow[] = [];
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i]);
const get = (idx: number) =>
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
const shotCode = get(shotNameIdx);
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
const tcIn = get(tcInIdx);
const tcOut = get(tcOutIdx);
if (tcIn && !isValidTimecode(tcIn)) {
errors.push(`Row ${i + 1} (${shotCode}): invalid TC In "${tcIn}" — skipped`);
continue;
}
if (tcOut && !isValidTimecode(tcOut)) {
errors.push(`Row ${i + 1} (${shotCode}): invalid TC Out "${tcOut}" — skipped`);
continue;
}
rows.push({
shotCode,
seqTimecodeStart: tcIn,
seqTimecodeEnd: tcOut,
description: get(descIdx),
group: get(groupIdx),
action: existingCodes.has(shotCode) ? "update" : "create",
});
}
return { rows, errors };
}
// ── Group Assignment CSV ──────────────────────────────────────────────────────
export interface GroupAssignRow {
shotCode: string;
groupName: string;
exists: boolean;
}
/**
* Parse a group-assignment CSV: Shot Name, Group.
* Only shots that already exist in the project will be updated.
*/
export function parseGroupAssignCsv(
csvText: string,
existingCodes: Set<string>
): { rows: GroupAssignRow[]; errors: string[] } {
const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) {
return { rows: [], errors: ["CSV must have a header row and at least one data row"] };
}
const rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
const col = (names: string[]) => {
for (const name of names) {
const idx = rawHeaders.indexOf(name);
if (idx !== -1) return idx;
}
return -1;
};
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
if (shotNameIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
}
if (groupIdx === -1) {
return { rows: [], errors: ['CSV must contain a "Group" column'] };
}
const rows: GroupAssignRow[] = [];
const errors: string[] = [];
const seen = new Set<string>();
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i]);
const get = (idx: number) =>
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
const shotCode = get(shotNameIdx);
const groupName = get(groupIdx);
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
if (!groupName) { errors.push(`Row ${i + 1} (${shotCode}): empty Group — skipped`); continue; }
if (seen.has(shotCode)) continue;
seen.add(shotCode);
rows.push({ shotCode, groupName, exists: existingCodes.has(shotCode) });
}
return { rows, errors };
}