test(studio): measure render drift against a producer frame in the edit accuracy bench (#4764)

* test(studio): measure render drift against a producer frame in the edit accuracy bench

Finds the target's box in a producer-captured frame by its flat colour, with sub-pixel edges from area moments, and compares it with the reloaded preview. The baseline gains the render column from the same full run on main.

* test(studio): mark the pixel box scan as intentionally complex for fallow

* test(studio): regenerate the edit accuracy baseline with render drift on main

Same full run on main 14e87005d as #4760's baseline, with the render column: render passes 618/660, 70/660 pass, 314/660 pass everything but smoothness.

* test(studio): fail only render drift when the producer cannot render a case

A producer capture failure used to turn the case into a harness error that wiped every accuracy metric. It now sets render to null with renderError, fails render alone, and is counted in the table.

* test(studio): shorten the render drift box comment to two lines
This commit is contained in:
Miguel Ángel
2026-09-30 21:01:40 +00:00
committed by GitHub
parent 299670e210
commit ca54631464
7 changed files with 831 additions and 672 deletions
File diff suppressed because it is too large Load Diff
@@ -688,6 +688,7 @@ export async function runCase({ browser, spec, dir, files, url, evidence }) {
undoTimeout: saved && !undo.reached ? "undo" : saved && !redo.reached ? "redo" : null,
smooth: { ...drive.smooth, control },
unsettled: Object.keys(quads).filter((k) => quads[k].unsettled),
reloaded,
diag: {
...drive.diag,
consoleErrors: consoleErrors.slice(0, 5),
@@ -53,6 +53,20 @@ export const quadDistance = (a, b) => Math.max(...a.map((p, i) => dist(p, b[i]))
export const angleOf = (q) => Math.atan2(q[1][1] - q[0][1], q[1][0] - q[0][0]);
export const normalizeAngle = (a) => Math.atan2(Math.sin(a), Math.cos(a));
export function aabb(q) {
const xs = q.map((p) => p[0]);
const ys = q.map((p) => p[1]);
return {
left: Math.min(...xs),
top: Math.min(...ys),
right: Math.max(...xs),
bottom: Math.max(...ys),
};
}
export const boxDistance = (a, b) =>
Math.max(...["left", "top", "right", "bottom"].map((k) => Math.abs(a[k] - b[k])));
/** `inset(t r b l [round ...])` in px, as getComputedStyle reports it; none means no crop. */
export function parseInset(clipPath) {
const zero = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -6,8 +6,8 @@ import { join } from "node:path";
export const COMPOSITION = { width: 1920, height: 1080 };
/** Frame-aligned at 30 fps, inside every tween, so preview and producer sample the same instant. */
export const PLAYHEAD = 1;
const TARGET = { width: 240, height: 160, color: "#f0c020" };
const BACKGROUND = "#202020";
export const TARGET = { width: 240, height: 160, color: "#f0c020" };
export const BACKGROUND = "#202020";
const NESTED_HOST = { left: 160, top: 90, width: 1600, height: 900 };
const GSAP_CDN = "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js";
@@ -0,0 +1,105 @@
/** Render drift: the target's box in a frame the producer captures, found by its flat colour. */
import { mkdirSync } from "node:fs";
import { join } from "node:path";
// Source import, as scripts/preview-capture.ts does: bun runs the producer's TypeScript directly.
import {
captureFrameToBuffer,
closeCaptureSession,
createCaptureSession,
createFileServer,
initializeSession,
} from "../../../../producer/src/index.js";
import { BACKGROUND, COMPOSITION, PLAYHEAD, TARGET } from "./grid.mjs";
const FPS = { num: 30, den: 1 };
// Sub-pixel box from luminance coverage between the two fixture colours (JPEG keeps full-res luminance).
// Area moments give the centre and sides, so rotated and cropped rectangles measure the same way.
function pixelBox(b64, bgY, fgY) {
// fallow-ignore-next-line complexity
return (async () => {
const img = new Image();
img.src = `data:image/jpeg;base64,${b64}`;
await img.decode();
const [w, h] = [img.width, img.height];
const g = new OffscreenCanvas(w, h).getContext("2d");
g.drawImage(img, 0, 0);
const d = g.getImageData(0, 0, w, h).data;
let [a, sx, sy, sxx, syy, sxy] = [0, 0, 0, 0, 0, 0];
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
let k = (0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2] - bgY) / (fgY - bgY);
if (k < 0.02) continue;
k = k > 0.98 ? 1 : k;
const [px, py] = [x + 0.5, y + 0.5];
a += k;
sx += k * px;
sy += k * py;
sxx += k * px * px;
syy += k * py * py;
sxy += k * px * py;
}
if (a === 0) return null;
const [mx, my] = [sx / a, sy / a];
// Pixel coverage is the shape blurred by a 1 px box, which adds 1/12 px² of variance per axis.
const cxx = sxx / a - mx * mx - 1 / 12;
const cyy = syy / a - my * my - 1 / 12;
const cxy = sxy / a - mx * my;
const theta = 0.5 * Math.atan2(2 * cxy, cxx - cyy);
const spread = Math.hypot((cxx - cyy) / 2, cxy);
const side1 = Math.sqrt(12 * ((cxx + cyy) / 2 + spread));
const side2 = Math.sqrt(12 * Math.max(0, (cxx + cyy) / 2 - spread));
const [c, s] = [Math.abs(Math.cos(theta)), Math.abs(Math.sin(theta))];
const hx = (side1 * c + side2 * s) / 2;
const hy = (side1 * s + side2 * c) / 2;
return { left: mx - hx, right: mx + hx, top: my - hy, bottom: my + hy, area: a };
})();
}
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
return 0.299 * r + 0.587 * g + 0.114 * b;
};
/** Renders `dir` at the playhead and measures the target's box in the frame. */
export async function renderBox(dir, decoder) {
const framesDir = join(dir, ".bench-frames");
mkdirSync(framesDir, { recursive: true });
const server = await createFileServer({ projectDir: dir, port: 0, fps: FPS });
let session;
try {
session = await createCaptureSession(
server.url,
framesDir,
{
width: COMPOSITION.width,
height: COMPOSITION.height,
fps: FPS,
format: "jpeg",
quality: 100,
},
null,
// A composition with no GSAP never registers a timeline; the default 45 s wait for one changes no pixel.
{ playerReadyTimeout: 10_000 },
);
await initializeSession(session);
const { buffer } = await captureFrameToBuffer(session, 0, PLAYHEAD);
// Diagnostic only: a DOM rect ignores clip-path, so it cannot score a crop.
const domRect = await session.page.evaluate(() => {
const r = document.querySelector("#target")?.getBoundingClientRect();
return r ? { left: r.left, top: r.top, right: r.right, bottom: r.bottom } : null;
});
const box = await decoder.evaluate(
pixelBox,
buffer.toString("base64"),
luminance(BACKGROUND),
luminance(TARGET.color),
);
if (!box) throw new Error("target colour not found in the producer frame");
return { box, domRect, jpeg: buffer };
} finally {
server.close();
if (session) await closeCaptureSession(session).catch(() => undefined);
}
}
@@ -7,7 +7,7 @@ const LIMIT_PX = 0.5;
// A frame over 1.5 vsyncs is dropped; raw rAF p95 stays reported so a different rule re-scores without a re-run.
const DROPPED_FRAME_MS = 25;
const WORK_MS = 8;
export const METRICS = ["tracking", "press", "drop", "reload", "undo", "smooth"];
export const METRICS = ["tracking", "press", "drop", "reload", "render", "undo", "smooth"];
/** Worst-first value per metric; undo ranks by box distance, and its byte failures are counted apart. */
const worstValue = {
@@ -15,6 +15,7 @@ const worstValue = {
press: (r) => r.pressJump ?? 0,
drop: (r) => r.drop,
reload: (r) => r.reload,
render: (r) => r.render ?? 0,
undo: (r) => Math.max(r.undo.box, r.undo.redoBox ?? 0),
smooth: (r) => r.smooth.dropped - r.smooth.control.dropped,
};
@@ -32,7 +33,7 @@ const FED_BY = {
committed: ["drop", "reload", "undo"],
undone: ["undo"],
redone: ["undo"],
reloaded: ["reload"],
reloaded: ["reload", "render"],
};
const unsettledMetrics = (r) => new Set(r.unsettled.flatMap((k) => FED_BY[k]));
@@ -51,6 +52,7 @@ export function score(spec, r) {
press: r.pressJump === null || r.pressJump <= LIMIT_PX,
drop: r.drop <= LIMIT_PX,
reload: r.reload <= LIMIT_PX,
render: r.render !== null && r.render <= LIMIT_PX,
undo: r.undo.bytes && r.undo.redoBytes && Math.max(r.undo.box, r.undo.redoBox) <= LIMIT_PX,
// Only drops beyond the blank page's, driven the same way in the same Chrome, are the edit's.
smooth:
@@ -114,6 +116,7 @@ function summarize(results, seconds) {
perMetric,
unsettled: measured.filter((r) => r.unsettled.length).length,
undoTimeouts: measured.filter((r) => r.undoTimeout).length,
renderErrors: measured.filter((r) => r.renderError).length,
smooth: smoothSummary(measured),
seconds: Math.round(seconds),
};
@@ -130,11 +133,12 @@ function table(summary, meta, results) {
`Every metric counts except smoothness, which is reported against the blank-page control: ${summary.perMetric.find((m) => m.metric === "smooth").pass}/${summary.total} pass it, and ${summary.passing}/${summary.total} pass everything including it.`,
"",
`Studio ${meta.studio} (build ${meta.build}), bench ${meta.bench}, grid \`${meta.grid}\`, ${meta.date}, ${summary.seconds}s with ${meta.jobs} jobs, ${summary.errors} harness errors, load ${meta.load}.`,
`Pass: tracking, press jump, drop and reload ≤ ${LIMIT_PX} px; undo and redo byte-identical with the box ≤ ${LIMIT_PX} px; no more frames over ${DROPPED_FRAME_MS} ms than the blank-page control, and main-thread work ≤ ${WORK_MS} ms per frame at p95.`,
`Pass: tracking, press jump, drop, reload and render ≤ ${LIMIT_PX} px; undo and redo byte-identical with the box ≤ ${LIMIT_PX} px; no more frames over ${DROPPED_FRAME_MS} ms than the blank-page control, and main-thread work ≤ ${WORK_MS} ms per frame at p95.`,
"",
`Undo or redo left different bytes in ${summary.bytesDiffer.undo} undo and ${summary.bytesDiffer.redo} redo cases.`,
`The preview never held still for 1 s within 15 s in ${summary.unsettled} cases; the metrics that snapshot feeds fail.`,
`An undo or redo write never landed within 15 s in ${summary.undoTimeouts} cases; undo fails there.`,
`The producer failed to render ${summary.renderErrors} cases; render fails there.`,
`Smoothness: ${summary.smooth.unknown} cases with unknown work; dropped frames per case (median/max) ${summary.smooth.dropped}, blank-page control ${summary.smooth.control}; raw rAF p95 (median/max) ${summary.smooth.p95} ms, control ${summary.smooth.controlP95} ms.`,
"",
"| Metric | Pass | Worst | Worst case |",
@@ -157,6 +161,7 @@ function table(summary, meta, results) {
function baseline(meta, results) {
const entries = [...results]
.sort((a, b) => a.id.localeCompare(b.id))
// fallow-ignore-next-line complexity
.map((r) => {
const v = r.error
? { pass: false, error: true }
@@ -166,6 +171,7 @@ function baseline(meta, results) {
pressJump: roundUp(r.pressJump),
drop: roundUp(r.drop),
reload: roundUp(r.reload),
render: roundUp(r.render),
undo: r.checks.undo,
dropped: r.smooth.dropped,
controlDropped: r.smooth.control.dropped,
@@ -173,6 +179,7 @@ function baseline(meta, results) {
frameP95: roundUp(r.smooth.p95),
...(r.unsettled.length && { unsettled: r.unsettled }),
...(r.undoTimeout && { undoTimeout: r.undoTimeout }),
...(r.renderError && { renderError: true }),
};
return ` ${JSON.stringify(r.id)}: ${JSON.stringify(v)}`;
});
@@ -13,6 +13,8 @@ import { resolveHeadlessShellPath } from "../../../../engine/src/index.ts";
import { buildGrid, writeFixture } from "./grid.mjs";
import { killServers, runCase, startServer, stopServer } from "./case.mjs";
import { METRICS, score, writeReport } from "./report.mjs";
import { renderBox } from "./render.mjs";
import { aabb, boxDistance } from "./geometry.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, "../../../../..");
@@ -47,6 +49,8 @@ function saveEvidence(id, evidence) {
writeFileSync(join(caseDir, `${name}.jpg`), jpeg);
for (const [name, text] of Object.entries(evidence.files ?? {}))
writeFileSync(join(caseDir, `saved-${name.replace("/", "-")}`), text);
if (evidence.renderFailed && evidence.frame)
writeFileSync(join(caseDir, "producer.jpg"), evidence.frame);
}
function verdict(r) {
@@ -62,7 +66,31 @@ const errorResult = (error, log) => ({
const liveRoots = new Set();
async function runOne(spec, browser, port) {
/** Render drift: the reloaded preview's visible box against the target's pixel box in a producer frame. */
async function withRender(dir, decoder, { reloaded, ...measured }, evidence) {
const expected = aabb(reloaded.visible);
const render = await renderBox(dir, decoder).catch((error) => ({ error }));
// A producer failure fails render alone; the case's other metrics still count.
if (render.error)
return {
...measured,
render: null,
renderError: `${render.error?.message ?? render.error}`.slice(0, 600),
};
evidence.frame = render.jpeg;
return {
...measured,
render: boxDistance(render.box, expected),
diag: {
...measured.diag,
previewBox: expected,
pixelBox: render.box,
producerDomRect: render.domRect,
},
};
}
async function runOne(spec, browser, decoder, port) {
const started = Date.now();
const root = mkdtempSync(join(tmpdir(), "hf-edit-accuracy-"));
liveRoots.add(root);
@@ -74,7 +102,7 @@ async function runOne(spec, browser, port) {
let server;
try {
server = await startServer(opt.cli, dir, port, log, join(root, "home"));
result = await runCase({
const measured = await runCase({
browser,
spec,
dir,
@@ -82,13 +110,16 @@ async function runOne(spec, browser, port) {
url: `http://127.0.0.1:${port}/#project/case`,
evidence,
});
await stopServer(server);
server = null;
result = await withRender(dir, decoder, measured, evidence);
} catch (error) {
result = errorResult(error, log);
} finally {
if (server) await stopServer(server);
}
const scored = { ...score(spec, result), seconds: (Date.now() - started) / 1000 };
if (!scored.pass) saveEvidence(spec.id, evidence);
if (!scored.pass) saveEvidence(spec.id, { ...evidence, renderFailed: !scored.checks.render });
rmSync(root, { recursive: true, force: true });
liveRoots.delete(root);
console.log(verdict(scored));
@@ -111,7 +142,7 @@ function acquireLock(path) {
const LOCK_CHUNK = 8;
// fallow-ignore-next-line complexity
async function runChunks(queue, browsers, results) {
async function runChunks(queue, browsers, decoders, results) {
for (let start = 0; start < queue.length; start += opt.lock ? LOCK_CHUNK : queue.length) {
const chunk = queue.slice(start, opt.lock ? start + LOCK_CHUNK : queue.length);
const release = opt.lock ? await acquireLock(opt.lock) : () => undefined;
@@ -119,7 +150,7 @@ async function runChunks(queue, browsers, results) {
await Promise.all(
browsers.map(async (browser, i) => {
for (let spec = chunk.shift(); spec; spec = chunk.shift())
results.push(await runOne(spec, browser, Number(opt.port) + i));
results.push(await runOne(spec, browser, decoders[i], Number(opt.port) + i));
}),
);
} finally {
@@ -174,7 +205,8 @@ const browsers = await Promise.all(
),
);
try {
await runChunks([...cases], browsers, results);
const decoders = await Promise.all(browsers.map((b) => b.newPage()));
await runChunks([...cases], browsers, decoders, results);
} finally {
await Promise.all(browsers.map((b) => b.close()));
}