/** * Client feedback pasted from WhatsApp (or an email, or the old doc) split * into rounds and per-shot notes. * * [07/10/2026, 09:12] Stephen: FEEDBACK 113 DUE Oct 7, 2026 12:00 PM * UNG_113_018_040 there is a hard blue edge on her finger, * along her thumb, which is not in the OCF * UNG_113_018_050 At frame 1018, the mask is still slipping * * - A line that starts with a shot code starts a note. Several codes before * the text ("UNG_113_032_034 / UNG_113_032_036 same fix") give one note per * shot. Lines without a code continue the note above them — WhatsApp * messages wrap. * - A line with no code that names feedback/notes/a round, mentions a due * date, or ends in ":" starts a new round. "DUE …" is split off as the * round's due text; turning it into a date is left to the browser * (parseDue) so "12:00 PM" means the studio's noon, not the server's. * - WhatsApp's copy prefix ("[07/10, 09:12] Name: ") and bold, italic * and strike markers are stripped. * * Codes are kept as typed; matching them to shots is the server's job. */ export interface FeedbackNote { codes: string[]; text: string; /** Frame number as the client quoted it ("at frame 1018"); null = none given */ frame: number | null; } export interface FeedbackRoundDraft { title: string; dueText: string | null; notes: FeedbackNote[]; } export interface ParsedFeedback { rounds: FeedbackRoundDraft[]; /** Lines before any note that were not a round header ("Hi team") */ ignored: string[]; } // "[07/10/2026, 09:12]" / "[9:12 AM, 10/7/2026]" then "Sender Name: " const WHATSAPP_PREFIX = /^\[[^\]]*\d[^\]]*\]\s*(?:[^:]{1,60}:\s)?/; const BULLET = /^(?:[-*•–]|\d+[.)])\s+/; // One code: underscore-joined segments. Clients sometimes drop the show // prefix ("113_018_050"), so two segments are enough here. const CODE = "[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+"; const LEADING_CODES = new RegExp(`^(${CODE})((?:\\s*(?:,|/|&|\\+|and)?\\s*${CODE})*)`, "i"); const HEADER_START = /^(feedback|notes|round|comments)\b/i; const DUE = /\bdue\b[:\s]*(.*)$/i; const DUE_WITH_DATE = /\bdue\b[:\s]+\S*\d/i; const FRAME = /\b(?:frames?|fr\.?|frm)\s*#?\s*(\d{1,6})\b/i; function clean(line: string): string { return line .replace(WHATSAPP_PREFIX, "") .replace(/[*~]/g, "") // *bold*, ~strike~ // _italic_ only around whole words: shot codes are full of underscores .replace(/(^|\s)_([^_\s][^_]*?)_(?=[\s.,!?]|$)/g, "$1$2") .replace(BULLET, "") .trim(); } // Strict on purpose: a wrapped note line ("lighting changes, due to the // grade") must continue the note, not start a round. function isHeader(line: string): boolean { if (HEADER_START.test(line)) return true; return line.length <= 60 && (line.endsWith(":") || DUE_WITH_DATE.test(line)); } export function parseFeedback(input: string): ParsedFeedback { const rounds: FeedbackRoundDraft[] = []; const ignored: string[] = []; let round: FeedbackRoundDraft | null = null; let note: FeedbackNote[] | null = null; // the note (one per code) being continued const startRound = (title: string, dueText: string | null) => { round = { title, dueText, notes: [] }; rounds.push(round); note = null; }; for (const raw of input.split(/\r?\n/)) { const line = clean(raw); if (!line) continue; const lead = LEADING_CODES.exec(line); if (lead) { const codes = (lead[0].match(new RegExp(CODE, "g")) ?? []).map((c) => c.toUpperCase()); const text = line.slice(lead[0].length).replace(/^[\s:\-–,.]+/, "").trim(); if (!round) startRound("", null); note = codes.map((code) => ({ codes: [code], text, frame: null })); round!.notes.push(...note); continue; } if (isHeader(line)) { const due = DUE.exec(line); const title = (due ? line.slice(0, due.index) : line).replace(/[\s:\-–]+$/, "").trim(); startRound(title, due?.[1].trim() || null); continue; } if (note) { for (const n of note as FeedbackNote[]) n.text = n.text ? `${n.text} ${line}` : line; } else { ignored.push(line); } } for (const r of rounds as FeedbackRoundDraft[]) { for (const n of r.notes) { const f = FRAME.exec(n.text); n.frame = f ? Number(f[1]) : null; } } return { rounds: rounds.filter((r) => r.notes.length > 0), ignored }; } /** * The round's due time from the text the client wrote ("Oct 7, 2026 12:00 PM", * "7 Oct 12:00", "Friday" is not attempted). Run in the browser so the time is * read in the studio's zone. A date without a year takes `now`'s year. */ export function parseDue(dueText: string | null, now = new Date()): Date | null { if (!dueText) return null; const text = dueText.replace(/\b(at|@)\b/gi, " ").replace(/\s+/g, " ").trim(); // V8's Date parser accepts almost anything ("end of day 2026"); insist on // a month name or a numeric date before handing it over. if (!/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b|\d{1,2}[/.-]\d{1,2}/i.test(text)) return null; const direct = new Date(text); if (!isNaN(direct.getTime()) && /\d{4}/.test(text)) return direct; const withYear = new Date(`${text} ${now.getFullYear()}`); if (!isNaN(withYear.getTime())) return withYear; // "7 Oct 12:00" → move the year before the time so Date can read it const m = /^(.*?)(\d{1,2}:\d{2}\s*(?:am|pm)?)$/i.exec(text); if (m) { const d = new Date(`${m[1].trim()} ${now.getFullYear()} ${m[2]}`); if (!isNaN(d.getTime())) return d; } return null; } /** First comp frame by house convention, for shots with no frameStart recorded */ export const HOUSE_FRAME_START = 1001; /** * Slate frames before the picture in a version's review media. Recorded on * newer versions; otherwise pipeline previews (the ones that record an EXR * frameCount) open with a 1-frame slate and other uploads with none. * Version.duration is not stored, so the length-based inference in * lib/handles.ts is not available here. */ export function slateFrames(v: { pictureStartFrame: number | null; frameCount: number | null }): number { if (v.pictureStartFrame != null && v.pictureStartFrame >= 0) return v.pictureStartFrame; return v.frameCount != null ? 1 : 0; } /** * The player frame (0-based, in the review media) for a frame the client * quoted. Clients read the burned-in comp frame (1001-based), so a quote at * or past the shot's first frame is mapped through it and the slate; a * smaller number is taken as the player's own counter. */ export function toPlayerFrame( quoted: number, opts: { shotFrameStart: number | null; pictureStart: number; frameCount: number | null }, ): { frame: number; fromComp: boolean } { const { pictureStart, frameCount } = opts; const compStart = opts.shotFrameStart && opts.shotFrameStart > 0 ? opts.shotFrameStart : HOUSE_FRAME_START; const last = frameCount && frameCount > 0 ? pictureStart + frameCount - 1 : null; const clamp = (f: number, min: number) => Math.max(min, last != null ? Math.min(last, f) : f); if (quoted >= compStart) { return { frame: clamp(quoted - compStart + pictureStart, pictureStart), fromComp: true }; } return { frame: clamp(quoted, 0), fromComp: false }; }