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 ): { 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(); 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 ): { 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 ): { 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(); 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 }; }