mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
feat(lint): add off_pivot_rotation hub-referenced layout check (#2744)
## What it catches A gauge needle / clock hand / dial pointer / radar sweep that rotates about the **wrong pivot** — the recovered center-of-rotation sits far from the dial hub (e.g. `transform-origin` at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub. This is a genuine gap in the current checks: `rotation_pivot_drift` (#2741) provably **cannot** catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a **dial-hub reference** distinguishes them. This is the separate hub-referenced check that analysis called for. ## How it works - Sampler maps 2 material endpoints per frame via `getScreenCTM` (honors the actual rendered transform, independent of `svgOrigin`). - Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit). - Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift `> 0.35 * pointer_length`. One warning per hub. - Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the `<svg>`) so a pointer rotated by a `div` ancestor is measured correctly. - Multi-body guard: `>= 2` bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed. ## Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams) - **7 / 7 true positives, 0 false positives across all 81 samples.** - Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080. - **The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs** (`vlm_has_defects: false`) — the deterministic hub-reference check beats the VLM on this defect class. - FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a `div` ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard. - fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly. ## Validation - Autonomous Gemini-3.6 **video**-judge fuzz run to surface candidate defects, then a **deterministic FP sweep** across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class). - 9 unit tests (`checkPipeline.offPivotRotation.test.ts`) + full check suite pass; `bun run build` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -42,6 +42,8 @@ import type {
|
||||
ContrastCapture,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
OffPivotFrame,
|
||||
OffPivotRotationSample,
|
||||
RotationSample,
|
||||
RunAuditGrid,
|
||||
} from "./checkTypes.js";
|
||||
@@ -349,6 +351,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||
collectLayoutGeometry: () => collectLayoutGeometry(page),
|
||||
collectRotationSample: (time) => collectRotationSample(page, time),
|
||||
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
|
||||
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
||||
collectMotionFrame: (time, selectors, scopes) =>
|
||||
collectMotionFrame(page, time, selectors, scopes),
|
||||
@@ -470,13 +473,20 @@ async function collectLayoutGeometry(page: Page): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
|
||||
const raw = await page.evaluate(() => {
|
||||
const sample = Reflect.get(window, "__hyperframesRotationSample");
|
||||
/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
|
||||
* and return its array result (or [] when absent / non-array). Shared by the
|
||||
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
|
||||
async function evaluateSampler(page: Page, globalName: string): Promise<unknown[]> {
|
||||
return page.evaluate((name) => {
|
||||
const sample = Reflect.get(window, name);
|
||||
if (typeof sample !== "function") return [];
|
||||
const result = Reflect.apply(sample, window, []);
|
||||
return Array.isArray(result) ? result : [];
|
||||
});
|
||||
}, globalName);
|
||||
}
|
||||
|
||||
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
|
||||
const raw = await evaluateSampler(page, "__hyperframesRotationSample");
|
||||
return raw.flatMap((value) => parseRotationSample(value, time));
|
||||
}
|
||||
|
||||
@@ -494,6 +504,51 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
|
||||
return [{ time, selector, cx, cy, w, h, angle }];
|
||||
}
|
||||
|
||||
async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
|
||||
const raw = await evaluateSampler(page, "__hyperframesOffPivotRotationSample");
|
||||
return { time, samples: raw.flatMap(parseOffPivotRotationSample) };
|
||||
}
|
||||
|
||||
/** Read every named key as a finite number; null if ANY is missing/non-finite.
|
||||
* The mapped return type keeps each field a plain `number` (not `number |
|
||||
* undefined`) so callers read `nums.ax` without re-narrowing. */
|
||||
function requiredNumbers<K extends string>(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly K[],
|
||||
): { [P in K]: number } | null {
|
||||
const out = {} as { [P in K]: number };
|
||||
for (const key of keys) {
|
||||
const num = numberValue(value, key);
|
||||
if (num === null) return null;
|
||||
out[key] = num;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const OFF_PIVOT_REQUIRED_NUMBERS = ["ax", "ay", "bx", "by", "len", "angle", "hubCount"] as const;
|
||||
|
||||
function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const selector = stringValue(value, "selector");
|
||||
const nums = requiredNumbers(value, OFF_PIVOT_REQUIRED_NUMBERS);
|
||||
if (!selector || !nums) return [];
|
||||
return [
|
||||
{
|
||||
selector,
|
||||
ax: nums.ax,
|
||||
ay: nums.ay,
|
||||
bx: nums.bx,
|
||||
by: nums.by,
|
||||
len: nums.len,
|
||||
angle: nums.angle,
|
||||
hx: numberValue(value, "hx"),
|
||||
hy: numberValue(value, "hy"),
|
||||
hr: numberValue(value, "hr"),
|
||||
hubCount: nums.hubCount,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function collectGeometryCandidates(
|
||||
page: Page,
|
||||
time: number,
|
||||
@@ -1078,6 +1133,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
|
||||
"panel_out_of_canvas",
|
||||
"connector_detached",
|
||||
"rotation_pivot_drift",
|
||||
"off_pivot_rotation",
|
||||
"motion_appears_late",
|
||||
"motion_out_of_order",
|
||||
"motion_off_frame",
|
||||
|
||||
Reference in New Issue
Block a user