Files
vfxreview/lib/feedback-parse.ts
twotalesanimation 842a8fe036
Deploy / deploy (push) Successful in 3m34s
feat(board): paste client feedback from WhatsApp
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.

A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.

Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:31:34 +02:00

181 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}