842a8fe036
Deploy / deploy (push) Successful in 3m34s
"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>
181 lines
7.1 KiB
TypeScript
181 lines
7.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|