Files
hyperframes/packages/cli/src/utils/checkFindingFrames.ts
T
Xuanru Li 12ae82aa8a feat(cli): check-review findingFrames saves one plain frame per finding time (#6401)
* feat(cli): skipContrastInMotion moves to HF_TOOL_CONTEXT check-review

The --skip-contrast-in-motion flag and HYPERFRAMES_CHECK_SKIP_CONTRAST_IN_MOTION
env give way to a check-review capability in HF_TOOL_CONTEXT, so only a harness
turns it on. The skill text and manifest go back to main.

* feat(cli): check-review findingFrames saves one plain frame per finding time

With HF_TOOL_CONTEXT check-review.findingFrames, every error/warning finding in
layout or contrast with a real bbox gets frameTime (the middle sample of its
longest held run, by the heldMs gap rule) and frame (the absolute path of an
unannotated full frame shared by every finding at that time). One seek and
screenshot per distinct time, capped at 60 times with errors first, into the
run's snapshot folder; not listed in snapshots, history or events. Without the
capability, output is unchanged.

* docs(cli): finding-frame comments name the check-review capability, not a flag

* fix(cli): validate finding frame times and preserve cancellation

* fix(cli): count finding frame progress toward the check deadline
2026-10-26 08:28:38 +00:00

95 lines
3.4 KiB
TypeScript

import { basename } from "node:path";
import { longestContiguousRun } from "./layoutAudit.js";
import type {
CheckBbox,
CheckFinding,
CheckFindingFrameRequest,
CheckReport,
} from "./checkTypes.js";
export const MAX_FINDING_FRAME_TIMES = 60;
type FramedSection = "layout" | "contrast";
const FRAMED_SECTIONS: readonly FramedSection[] = ["layout", "contrast"];
export interface FramedFinding {
section: FramedSection;
index: number;
time: number;
}
export interface FindingFramePlan {
requests: CheckFindingFrameRequest[];
findings: FramedFinding[];
skipped: number;
}
/** Errors then warnings with a real bbox; one frame per distinct time, at most MAX_FINDING_FRAME_TIMES. */
export function selectFindingFrames(report: CheckReport): FindingFramePlan {
const candidates = FRAMED_SECTIONS.flatMap((section) =>
report[section].findings.flatMap((finding, index) =>
(finding.severity === "error" || finding.severity === "warning") && hasRealBbox(finding.bbox)
? [{ section, index, finding }]
: [],
),
).sort((a, b) => severityRank(a.finding) - severityRank(b.finding));
const times = new Set<number>();
const findings: FramedFinding[] = [];
let skipped = 0;
for (const { section, index, finding } of candidates) {
const time = middleOfLongestRun(finding.times ?? [finding.time]);
if (time === undefined) continue;
if (!times.has(time) && times.size >= MAX_FINDING_FRAME_TIMES) {
skipped += 1;
continue;
}
times.add(time);
findings.push({ section, index, time });
}
const requests = [...times]
.sort((a, b) => a - b)
.map((time, order) => ({ time, filename: findingFrameFilename(order, time) }));
return { requests, findings, skipped };
}
function severityRank(finding: CheckFinding): number {
return finding.severity === "error" ? 0 : 1;
}
function hasRealBbox(bbox: CheckBbox): boolean {
return bbox.width > 0 && bbox.height > 0;
}
/** The middle sample of the longest held run (the `heldMs` gap rule); the earlier of two middles. */
export function middleOfLongestRun(times: number[]): number | undefined {
const run = longestContiguousRun(times.filter((time) => Number.isFinite(time) && time >= 0));
return run[Math.floor((run.length - 1) / 2)];
}
function findingFrameFilename(order: number, time: number): string {
return `finding-frame-${String(order).padStart(2, "0")}-at-${time.toFixed(3)}s.png`;
}
/** Copies each framed finding with its frame path and time; every other field is untouched. */
export function withFindingFrameFields(
report: CheckReport,
plan: FindingFramePlan,
written: string[],
): CheckReport {
const pathOf = new Map(written.map((path) => [basename(path), path]));
const filenameOf = new Map(plan.requests.map((request) => [request.time, request.filename]));
const next: CheckReport = { ...report };
for (const section of FRAMED_SECTIONS) {
const framed = plan.findings.filter((entry) => entry.section === section);
if (framed.length === 0) continue;
const findings: CheckFinding[] = [...report[section].findings];
for (const entry of framed) {
const frame = pathOf.get(filenameOf.get(entry.time) ?? "");
const finding = findings[entry.index];
if (frame && finding) findings[entry.index] = { ...finding, frame, frameTime: entry.time };
}
Object.assign(next, { [section]: { ...report[section], findings } });
}
return next;
}