mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +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:
@@ -150,6 +150,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
|
||||
collectRotationSample: vi.fn(async (_time: number) => []),
|
||||
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
|
||||
collectGeometryCandidates: vi.fn(async () => []),
|
||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||
|
||||
@@ -1531,4 +1531,197 @@
|
||||
}
|
||||
return samples;
|
||||
};
|
||||
|
||||
// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
|
||||
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
|
||||
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
|
||||
// center orbits either way), so this records two MATERIAL points on each
|
||||
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
|
||||
// rendered transform is honored regardless of svgOrigin/transform-origin — and
|
||||
// the dial's static hub (the point shared by the most non-rotating circles).
|
||||
// The pipeline fits a rotation to the material-point trajectories to recover
|
||||
// the real center-of-rotation and flags it when it drifts off that hub.
|
||||
function ctmRotationDeg(ctm) {
|
||||
if (!ctm) return null;
|
||||
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function ctmScale(ctm) {
|
||||
return Math.hypot(ctm.a, ctm.b);
|
||||
}
|
||||
|
||||
function mapPoint(svg, ctm, x, y) {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = x;
|
||||
point.y = y;
|
||||
const mapped = point.matrixTransform(ctm);
|
||||
return { x: mapped.x, y: mapped.y };
|
||||
}
|
||||
|
||||
// Walks up to (and including) the composition root, NOT just the owner <svg>:
|
||||
// an element spun by a div ancestor above its svg must not be mistaken for a
|
||||
// static hub anchor (else a lone rotating arc becomes its own dial center).
|
||||
function hasRotatedAncestor(element, root) {
|
||||
let node = element;
|
||||
while (node) {
|
||||
const angle = rotationAngleDeg(getComputedStyle(node).transform);
|
||||
if (angle !== null && Math.abs(angle) > 1) return true;
|
||||
if (node === root) break;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
|
||||
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
|
||||
// string (no import across the puppeteer boundary), so the Kåsa math is
|
||||
// intentionally duplicated per-language. Any change must land in both copies.
|
||||
function fitCirclePoints(points) {
|
||||
const count = points.length;
|
||||
if (count < 3) return null;
|
||||
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
|
||||
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
|
||||
let suu = 0,
|
||||
svv = 0,
|
||||
suv = 0,
|
||||
suuu = 0,
|
||||
svvv = 0,
|
||||
suvv = 0,
|
||||
svuu = 0;
|
||||
for (const point of points) {
|
||||
const u = point.x - meanX;
|
||||
const v = point.y - meanY;
|
||||
suu += u * u;
|
||||
svv += v * v;
|
||||
suv += u * v;
|
||||
suuu += u * u * u;
|
||||
svvv += v * v * v;
|
||||
suvv += u * v * v;
|
||||
svuu += v * u * u;
|
||||
}
|
||||
const det = suu * svv - suv * suv;
|
||||
if (Math.abs(det) < 1e-6) return null;
|
||||
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
|
||||
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
|
||||
const cx = uc + meanX;
|
||||
const cy = vc + meanY;
|
||||
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
|
||||
let squaredError = 0;
|
||||
for (const point of points) {
|
||||
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
|
||||
squaredError += delta * delta;
|
||||
}
|
||||
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
|
||||
}
|
||||
|
||||
// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
|
||||
// the largest static, near-circular path and recover its arc center.
|
||||
function arcHubForSvg(svg, root) {
|
||||
let best = null;
|
||||
for (const path of Array.from(svg.querySelectorAll("path"))) {
|
||||
if (hasRotatedAncestor(path, root)) continue;
|
||||
if (typeof path.getTotalLength !== "function") continue;
|
||||
const total = path.getTotalLength();
|
||||
if (total < 200) continue;
|
||||
const ctm = path.getScreenCTM();
|
||||
if (!ctm) continue;
|
||||
const points = [];
|
||||
for (let i = 0; i <= 16; i++) {
|
||||
const local = path.getPointAtLength((total * i) / 16);
|
||||
points.push(mapPoint(svg, ctm, local.x, local.y));
|
||||
}
|
||||
const fit = fitCirclePoints(points);
|
||||
if (!fit || fit.radius < 40) continue;
|
||||
if (fit.residual > 0.05 * fit.radius) continue;
|
||||
if (!best || fit.radius > best.radius) best = fit;
|
||||
}
|
||||
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
|
||||
}
|
||||
|
||||
function dialHubForSvg(svg, root) {
|
||||
const centers = [];
|
||||
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
|
||||
if (hasRotatedAncestor(circle, root)) continue;
|
||||
const ctm = circle.getScreenCTM();
|
||||
if (!ctm) continue;
|
||||
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
|
||||
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
|
||||
const center = mapPoint(svg, ctm, cx, cy);
|
||||
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
|
||||
centers.push({ x: center.x, y: center.y, radius });
|
||||
}
|
||||
let best = null;
|
||||
for (const anchor of centers) {
|
||||
const cluster = centers.filter(
|
||||
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
|
||||
);
|
||||
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
|
||||
}
|
||||
if (best && best.cluster.length >= 2) {
|
||||
const count = best.cluster.length;
|
||||
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
|
||||
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
|
||||
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
|
||||
return { hx, hy, hr, count };
|
||||
}
|
||||
return arcHubForSvg(svg, root);
|
||||
}
|
||||
|
||||
window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
|
||||
const root =
|
||||
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const samples = [];
|
||||
const hubCache = new Map();
|
||||
const CANDIDATE_CAP = 60;
|
||||
for (const element of Array.from(
|
||||
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
|
||||
)) {
|
||||
if (samples.length >= CANDIDATE_CAP) break;
|
||||
const svg = element.ownerSVGElement;
|
||||
if (!svg || typeof element.getBBox !== "function") continue;
|
||||
if (element.closest("[data-layout-allow-orbit]")) continue;
|
||||
if (!isVisibleElement(element, 0.05)) continue;
|
||||
const ctm = element.getScreenCTM();
|
||||
const angle = ctmRotationDeg(ctm);
|
||||
if (ctm === null || angle === null) continue;
|
||||
let bbox;
|
||||
try {
|
||||
bbox = element.getBBox();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const long = Math.max(bbox.width, bbox.height);
|
||||
const short = Math.min(bbox.width, bbox.height);
|
||||
if (short <= 0 || long / short < 3 || long < 40) continue;
|
||||
const vertical = bbox.height >= bbox.width;
|
||||
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
|
||||
const a = vertical
|
||||
? mapPoint(svg, ctm, midMajor, bbox.y)
|
||||
: mapPoint(svg, ctm, bbox.x, midMajor);
|
||||
const b = vertical
|
||||
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
|
||||
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
|
||||
let hub = hubCache.get(svg);
|
||||
if (hub === undefined) {
|
||||
hub = dialHubForSvg(svg, root);
|
||||
hubCache.set(svg, hub);
|
||||
}
|
||||
samples.push({
|
||||
selector: selectorFor(element),
|
||||
ax: round(a.x),
|
||||
ay: round(a.y),
|
||||
bx: round(b.x),
|
||||
by: round(b.y),
|
||||
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
|
||||
angle: round(angle),
|
||||
hx: hub ? round(hub.hx) : null,
|
||||
hy: hub ? round(hub.hy) : null,
|
||||
hr: hub ? round(hub.hr) : null,
|
||||
hubCount: hub ? hub.count : 0,
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user