187 lines
5.6 KiB
TypeScript
187 lines
5.6 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 };
|
|
}
|