mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(cli): caption-zone and frame-check gates on check
Ports the EF bridge's captionZone and frameCheck semantics as opt-in flags so the bespoke bridge can be retired: --caption-zone takes fractional band geometry (x0;y0;x1;y1) with optional severity routing and seek points, defaults matching the bridge (caption seek [1], frame seek [0.5], 2px tolerance, 0.05 opacity floor, 4px minimum size, 0.95 full-frame exclusion, center-in-band comparison, tag|text dedup). --frame-check adds media bounds detection (img/svg/video/canvas) the always-on text canvas_overflow never covered, reusing overflowFor. Breach floor: max(120px, 6% of min canvas dimension). Band math derives from the composition's own canvas, portrait included. Both gates off by default; plain check output unchanged.
This commit is contained in:
@@ -20,8 +20,9 @@ import {
|
|||||||
type ContrastAuditEntry,
|
type ContrastAuditEntry,
|
||||||
type MotionSpecResolution,
|
type MotionSpecResolution,
|
||||||
} from "../utils/checkPipeline.js";
|
} from "../utils/checkPipeline.js";
|
||||||
|
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||||
import type { LayoutIssue } from "../utils/layoutAudit.js";
|
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js";
|
||||||
import type { ProjectDir } from "../utils/project.js";
|
import type { ProjectDir } from "../utils/project.js";
|
||||||
|
|
||||||
const PROJECT: ProjectDir = {
|
const PROJECT: ProjectDir = {
|
||||||
@@ -30,6 +31,12 @@ const PROJECT: ProjectDir = {
|
|||||||
indexPath: "/project/index.html",
|
indexPath: "/project/index.html",
|
||||||
};
|
};
|
||||||
const PNG_BASE64 = Buffer.from("png-bytes").toString("base64");
|
const PNG_BASE64 = Buffer.from("png-bytes").toString("base64");
|
||||||
|
const ORIGINAL_EXIT_CODE = process.exitCode;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exitCode = ORIGINAL_EXIT_CODE;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
function cleanLint(): ProjectLintResult {
|
function cleanLint(): ProjectLintResult {
|
||||||
return {
|
return {
|
||||||
@@ -118,6 +125,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
|||||||
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
||||||
seek: vi.fn(async (_time: number) => undefined),
|
seek: vi.fn(async (_time: number) => undefined),
|
||||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||||
|
collectGeometryCandidates: vi.fn(async () => []),
|
||||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||||
issues.map((issue) => ({
|
issues.map((issue) => ({
|
||||||
@@ -130,6 +138,76 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeometryFixture {
|
||||||
|
kind: "text" | "media";
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
selector: string;
|
||||||
|
rect: LayoutRect;
|
||||||
|
elementRect?: LayoutRect;
|
||||||
|
time: number;
|
||||||
|
overflow?: LayoutOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryCandidate(fixture: GeometryFixture) {
|
||||||
|
return {
|
||||||
|
...anchor(fixture.selector, fixture.time),
|
||||||
|
kind: fixture.kind,
|
||||||
|
tag: fixture.tag,
|
||||||
|
text: fixture.text,
|
||||||
|
rect: fixture.rect,
|
||||||
|
elementRect: fixture.elementRect ?? fixture.rect,
|
||||||
|
bbox: {
|
||||||
|
x: fixture.rect.left,
|
||||||
|
y: fixture.rect.top,
|
||||||
|
width: fixture.rect.width,
|
||||||
|
height: fixture.rect.height,
|
||||||
|
},
|
||||||
|
overflow: fixture.overflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect {
|
||||||
|
return { left, top, right: left + width, bottom: top + height, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBrowserSource(): string {
|
||||||
|
return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gateCandidates(
|
||||||
|
time: number,
|
||||||
|
request: { text: boolean; media: boolean; tolerance: number },
|
||||||
|
) {
|
||||||
|
const candidates = [];
|
||||||
|
if (request.text) {
|
||||||
|
candidates.push(
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "h2",
|
||||||
|
text: "Repeated heading",
|
||||||
|
selector: time === 2 ? "#first-heading" : "#later-heading",
|
||||||
|
rect: fixtureRect(800, 880, 320, 60),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.media) {
|
||||||
|
candidates.push(
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "video",
|
||||||
|
text: "video",
|
||||||
|
selector: "#midpoint-video",
|
||||||
|
rect: fixtureRect(-140, 100, 100, 100),
|
||||||
|
overflow: { left: 140 },
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
function noMotion(): MotionSpecResolution {
|
function noMotion(): MotionSpecResolution {
|
||||||
return { kind: "none" };
|
return { kind: "none" };
|
||||||
}
|
}
|
||||||
@@ -200,6 +278,361 @@ describe("contrast sample selection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses the caption-zone grammar and enables the frame gate", async () => {
|
||||||
|
const { report } = await runScenario(fakeDriver());
|
||||||
|
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const command = createCheckCommand({
|
||||||
|
resolveProject: () => PROJECT,
|
||||||
|
runPipeline,
|
||||||
|
withMeta: (value) => value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCommand(command, {
|
||||||
|
rawArgs: [
|
||||||
|
"--json",
|
||||||
|
"--caption-zone",
|
||||||
|
"x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1",
|
||||||
|
"--frame-check",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runPipeline).toHaveBeenCalledWith(
|
||||||
|
PROJECT,
|
||||||
|
expect.objectContaining({
|
||||||
|
captionZone: {
|
||||||
|
x0: 0,
|
||||||
|
y0: 0.82,
|
||||||
|
x1: 1,
|
||||||
|
y1: 1,
|
||||||
|
severity: "error",
|
||||||
|
seek: [0.25, 1],
|
||||||
|
},
|
||||||
|
frameCheck: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => {
|
||||||
|
const { report } = await runScenario(fakeDriver());
|
||||||
|
const runPipeline = vi.fn(async () => report);
|
||||||
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const command = createCheckCommand({
|
||||||
|
resolveProject: () => PROJECT,
|
||||||
|
runPipeline,
|
||||||
|
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCommand(command, {
|
||||||
|
rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runPipeline).not.toHaveBeenCalled();
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: expect.stringContaining("Invalid --caption-zone"),
|
||||||
|
_meta: { version: "test" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags only text whose center is inside the caption band at the default end seek", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "div",
|
||||||
|
text: "Centered title",
|
||||||
|
selector: "#centered",
|
||||||
|
rect: fixtureRect(860, 870, 200, 60),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "div",
|
||||||
|
text: "Overlap only",
|
||||||
|
selector: "#overlap-only",
|
||||||
|
rect: fixtureRect(860, 830, 200, 60),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 10),
|
||||||
|
collectGeometryCandidates,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
samples: 1,
|
||||||
|
contrast: false,
|
||||||
|
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collectGeometryCandidates).toHaveBeenCalledTimes(1);
|
||||||
|
expect(collectGeometryCandidates).toHaveBeenCalledWith(10, {
|
||||||
|
text: true,
|
||||||
|
media: false,
|
||||||
|
tolerance: 2,
|
||||||
|
});
|
||||||
|
expect(report.layout.samples).toEqual([5, 10]);
|
||||||
|
expect(report.layout.findings).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "caption_zone_collision",
|
||||||
|
severity: "warning",
|
||||||
|
selector: "#centered",
|
||||||
|
text: "Centered title",
|
||||||
|
time: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(report.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters caption candidates by the element box while centering the text rect", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "div",
|
||||||
|
text: "Full-frame wrapper copy",
|
||||||
|
selector: "#full-frame-wrapper",
|
||||||
|
rect: fixtureRect(860, 870, 200, 60),
|
||||||
|
elementRect: fixtureRect(0, 0, 1920, 1080),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "span",
|
||||||
|
text: "Tiny wrapper copy",
|
||||||
|
selector: "#tiny-wrapper",
|
||||||
|
rect: fixtureRect(860, 870, 200, 60),
|
||||||
|
elementRect: fixtureRect(860, 870, 3, 3),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
|
||||||
|
contrast: false,
|
||||||
|
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.layout.findings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks media overflow at the default midpoint and applies warning severity", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "img",
|
||||||
|
text: "img",
|
||||||
|
selector: "#hero-image",
|
||||||
|
rect: fixtureRect(1840, 100, 220, 200),
|
||||||
|
overflow: { right: 140 },
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }),
|
||||||
|
{ samples: 1, contrast: false, frameCheck: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collectGeometryCandidates).toHaveBeenCalledWith(5, {
|
||||||
|
text: false,
|
||||||
|
media: true,
|
||||||
|
tolerance: 2,
|
||||||
|
});
|
||||||
|
expect(report.layout.findings[0]).toMatchObject({
|
||||||
|
code: "frame_out_of_frame",
|
||||||
|
severity: "warning",
|
||||||
|
selector: "#hero-image",
|
||||||
|
overflow: { right: 140 },
|
||||||
|
time: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(gateCandidates);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 8),
|
||||||
|
collectGeometryCandidates,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
samples: 1,
|
||||||
|
contrast: false,
|
||||||
|
captionZone: {
|
||||||
|
x0: 0,
|
||||||
|
y0: 0.8,
|
||||||
|
x1: 1,
|
||||||
|
y1: 0.9,
|
||||||
|
severity: "error",
|
||||||
|
seek: [0.25, 0.75],
|
||||||
|
},
|
||||||
|
frameCheck: { severity: "error" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.layout.samples).toEqual([2, 4, 6]);
|
||||||
|
expect(collectGeometryCandidates.mock.calls).toEqual([
|
||||||
|
[2, { text: true, media: false, tolerance: 2 }],
|
||||||
|
[4, { text: false, media: true, tolerance: 2 }],
|
||||||
|
[6, { text: true, media: false, tolerance: 2 }],
|
||||||
|
]);
|
||||||
|
expect(report.layout.findings).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "caption_zone_collision",
|
||||||
|
severity: "error",
|
||||||
|
selector: "#first-heading",
|
||||||
|
time: 2,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }),
|
||||||
|
]);
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collect or emit opt-in geometry findings when both flags are off", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async () => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "video",
|
||||||
|
text: "video",
|
||||||
|
selector: "#video",
|
||||||
|
rect: fixtureRect(1900, 0, 200, 200),
|
||||||
|
overflow: { right: 180 },
|
||||||
|
time: 4.5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
|
||||||
|
contrast: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collectGeometryCandidates).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.stringify(report)).not.toContain("caption_zone_collision");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("frame_out_of_frame");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes caption bands from a portrait composition viewport", async () => {
|
||||||
|
const viewport = resolveCompositionViewportFromHtml(
|
||||||
|
'<div data-composition-id="portrait" data-width="1080" data-height="1920"></div>',
|
||||||
|
);
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "text",
|
||||||
|
tag: "p",
|
||||||
|
text: "Portrait caption collision",
|
||||||
|
selector: "#portrait-copy",
|
||||||
|
rect: fixtureRect(480, 1570, 120, 60),
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const getCanvas = vi.fn(async () => viewport);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 4),
|
||||||
|
getCanvas,
|
||||||
|
collectGeometryCandidates,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
samples: 1,
|
||||||
|
contrast: false,
|
||||||
|
captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(viewport).toEqual({ width: 1080, height: 1920 });
|
||||||
|
expect(getCanvas).toHaveBeenCalled();
|
||||||
|
expect(report.layout.findings).toEqual([
|
||||||
|
expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "canvas",
|
||||||
|
text: "canvas",
|
||||||
|
selector: "#under-floor",
|
||||||
|
rect: fixtureRect(3980, 100, 199, 100),
|
||||||
|
overflow: { right: 179 },
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "canvas",
|
||||||
|
text: "canvas",
|
||||||
|
selector: "#over-floor",
|
||||||
|
rect: fixtureRect(3980, 300, 201, 100),
|
||||||
|
overflow: { right: 181 },
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })),
|
||||||
|
collectGeometryCandidates,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
contrast: false,
|
||||||
|
frameCheck: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.layout.findings).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "frame_out_of_frame",
|
||||||
|
selector: "#over-floor",
|
||||||
|
overflow: { right: 181 },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps frame findings at distinct rounded positions across requested seeks", async () => {
|
||||||
|
const collectGeometryCandidates = vi.fn(async (time: number) => [
|
||||||
|
geometryCandidate({
|
||||||
|
kind: "media",
|
||||||
|
tag: "img",
|
||||||
|
text: "img",
|
||||||
|
selector: "#moving-image",
|
||||||
|
rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100),
|
||||||
|
overflow: { right: 130 },
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 8),
|
||||||
|
collectGeometryCandidates,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
samples: 1,
|
||||||
|
contrast: false,
|
||||||
|
frameCheck: { seek: [0.25, 0.75] },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.layout.findings).toEqual([
|
||||||
|
expect.objectContaining({ code: "frame_out_of_frame", time: 2 }),
|
||||||
|
expect.objectContaining({ code: "frame_out_of_frame", time: 6 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => {
|
||||||
|
const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 }));
|
||||||
|
const { report } = await runScenario(
|
||||||
|
fakeDriver({
|
||||||
|
getDuration: vi.fn(async () => 10),
|
||||||
|
collectContrast,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
samples: 1,
|
||||||
|
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.layout.samples).toEqual([5, 10]);
|
||||||
|
expect(report.contrast.samples).toEqual([5]);
|
||||||
|
expect(collectContrast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(collectContrast).toHaveBeenCalledWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
describe("check pipeline", () => {
|
describe("check pipeline", () => {
|
||||||
const originalExitCode = process.exitCode;
|
const originalExitCode = process.exitCode;
|
||||||
|
|
||||||
@@ -403,6 +836,16 @@ describe("check pipeline", () => {
|
|||||||
await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow(
|
await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow(
|
||||||
"Could not determine composition duration — no layout samples run",
|
"Could not determine composition duration — no layout samples run",
|
||||||
);
|
);
|
||||||
|
await expect(
|
||||||
|
runAuditGrid(
|
||||||
|
driver,
|
||||||
|
{
|
||||||
|
...DEFAULT_CHECK_OPTIONS,
|
||||||
|
captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
|
||||||
|
},
|
||||||
|
noMotion(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Could not determine composition duration — no layout samples run");
|
||||||
|
|
||||||
const { report, browser } = await runScenario(driver);
|
const { report, browser } = await runScenario(driver);
|
||||||
expect(browser).toHaveBeenCalledTimes(1);
|
expect(browser).toHaveBeenCalledTimes(1);
|
||||||
@@ -415,7 +858,7 @@ describe("check pipeline", () => {
|
|||||||
|
|
||||||
describe("contrast candidate round-trip", () => {
|
describe("contrast candidate round-trip", () => {
|
||||||
it("passes the browser script's raw candidates back to finish, never the normalized copies", () => {
|
it("passes the browser script's raw candidates back to finish, never the normalized copies", () => {
|
||||||
const source = readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8");
|
const source = checkBrowserSource();
|
||||||
|
|
||||||
// __contrastAuditFinish samples pixels via the page script's own bbox
|
// __contrastAuditFinish samples pixels via the page script's own bbox
|
||||||
// shape ({x, y, w, h}); sending the Node-normalized candidate
|
// shape ({x, y, w, h}); sending the Node-normalized candidate
|
||||||
@@ -426,3 +869,14 @@ describe("contrast candidate round-trip", () => {
|
|||||||
expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/);
|
expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("geometry candidate plumbing", () => {
|
||||||
|
it("wires the opt-in browser primitive without round-tripping normalized candidates", () => {
|
||||||
|
const source = checkBrowserSource();
|
||||||
|
|
||||||
|
expect(source).toMatch(/collectGeometryCandidates: \(time, request\) =>/);
|
||||||
|
expect(source).toMatch(/__hyperframesGeometryCandidates/);
|
||||||
|
expect(source).toMatch(/raw\.flatMap\(\(value\) => parseGeometryCandidate\(value, time\)\)/);
|
||||||
|
expect(source).not.toMatch(/resolveAnchors\(page, raw/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type CheckReport,
|
type CheckReport,
|
||||||
type CheckSection,
|
type CheckSection,
|
||||||
} from "../utils/checkPipeline.js";
|
} from "../utils/checkPipeline.js";
|
||||||
|
import type { CaptionZoneOptions } from "../utils/checkTypes.js";
|
||||||
|
|
||||||
export const examples: Example[] = [
|
export const examples: Example[] = [
|
||||||
["Run the full verification gate", "hyperframes check"],
|
["Run the full verification gate", "hyperframes check"],
|
||||||
@@ -102,16 +103,27 @@ export function createCheckCommand(
|
|||||||
description: "Save the five contrast-pass PNGs under snapshots/",
|
description: "Save the five contrast-pass PNGs under snapshots/",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
"caption-zone": {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)',
|
||||||
|
},
|
||||||
|
"frame-check": {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Use as --frame-check (boolean/no value; tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge))",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const project = dependencies.resolveProject(args.dir);
|
|
||||||
const options = parseCheckOptions(args);
|
|
||||||
const asJson = args.json === true;
|
const asJson = args.json === true;
|
||||||
if (!asJson) {
|
|
||||||
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const project = dependencies.resolveProject(args.dir);
|
||||||
|
const options = parseCheckOptions(args);
|
||||||
|
if (!asJson) {
|
||||||
|
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
|
||||||
|
}
|
||||||
const report = await dependencies.runPipeline(project, options);
|
const report = await dependencies.runPipeline(project, options);
|
||||||
if (asJson) {
|
if (asJson) {
|
||||||
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
|
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
|
||||||
@@ -151,9 +163,99 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
|
|||||||
contrast: args.contrast !== false,
|
contrast: args.contrast !== false,
|
||||||
strict: args.strict === true,
|
strict: args.strict === true,
|
||||||
snapshots: args.snapshots === true,
|
snapshots: args.snapshots === true,
|
||||||
|
captionZone: parseCaptionZone(args["caption-zone"]),
|
||||||
|
frameCheck: args["frame-check"] === true ? {} : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
|
||||||
|
|
||||||
|
function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const fields = parseCaptionFields(captionZoneString(value));
|
||||||
|
const { x0, y0, x1, y1 } = parseCaptionBounds(fields);
|
||||||
|
const severity = captionSeverity(fields.get("severity"));
|
||||||
|
const seek = captionSeeks(fields.get("seek"));
|
||||||
|
return {
|
||||||
|
x0,
|
||||||
|
y0,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
...(severity ? { severity } : {}),
|
||||||
|
...(seek ? { seek } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionZoneString(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") throw captionZoneError();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaptionFields(value: string): Map<string, string> {
|
||||||
|
const fields = new Map<string, string>();
|
||||||
|
for (const part of value.split(";")) {
|
||||||
|
const { key, entry } = parseCaptionField(part);
|
||||||
|
if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError();
|
||||||
|
fields.set(key, entry);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaptionField(part: string): { key: string; entry: string } {
|
||||||
|
const separator = part.indexOf("=");
|
||||||
|
if (separator <= 0) throw captionZoneError();
|
||||||
|
return {
|
||||||
|
key: part.slice(0, separator).trim(),
|
||||||
|
entry: part.slice(separator + 1).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaptionBounds(fields: Map<string, string>): {
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
} {
|
||||||
|
const x0 = requiredCaptionFraction(fields, "x0");
|
||||||
|
const y0 = requiredCaptionFraction(fields, "y0");
|
||||||
|
const x1 = requiredCaptionFraction(fields, "x1");
|
||||||
|
const y1 = requiredCaptionFraction(fields, "y1");
|
||||||
|
if (x0 > x1 || y0 > y1) throw captionZoneError();
|
||||||
|
return { x0, y0, x1, y1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredCaptionFraction(fields: Map<string, string>, key: string): number {
|
||||||
|
const value = captionFraction(fields.get(key));
|
||||||
|
if (value === null) throw captionZoneError();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionFraction(value: string | undefined): number | null {
|
||||||
|
if (value === undefined || value === "") return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionSeverity(value: string | undefined): "error" | "warning" | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === "error" || value === "warning") return value;
|
||||||
|
throw captionZoneError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionSeeks(value: string | undefined): number[] | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === "") return [];
|
||||||
|
const values = value.split(",").map(captionFraction);
|
||||||
|
if (values.some((entry) => entry === null)) throw captionZoneError();
|
||||||
|
return values.flatMap((entry) => (entry === null ? [] : entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionZoneError(): Error {
|
||||||
|
return new Error(
|
||||||
|
'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function positiveInteger(value: unknown, fallback: number): number {
|
function positiveInteger(value: unknown, fallback: number): number {
|
||||||
const parsed = parseInt(String(value ?? ""), 10);
|
const parsed = parseInt(String(value ?? ""), 10);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
|||||||
@@ -81,6 +81,22 @@
|
|||||||
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueSelectorFor(element) {
|
||||||
|
const preferred = selectorFor(element);
|
||||||
|
try {
|
||||||
|
if (document.querySelectorAll(preferred).length === 1) return preferred;
|
||||||
|
} catch {
|
||||||
|
// Fall through to a structural selector.
|
||||||
|
}
|
||||||
|
const parent = element.parentElement;
|
||||||
|
if (!parent) return preferred;
|
||||||
|
const siblings = Array.from(parent.children).filter(
|
||||||
|
(child) => child.tagName === element.tagName,
|
||||||
|
);
|
||||||
|
const index = siblings.indexOf(element) + 1;
|
||||||
|
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
||||||
|
}
|
||||||
|
|
||||||
function hasIgnoreFlag(element) {
|
function hasIgnoreFlag(element) {
|
||||||
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
|
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
|
||||||
}
|
}
|
||||||
@@ -98,6 +114,14 @@
|
|||||||
return opacity;
|
return opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOpacityBelow(element, floor) {
|
||||||
|
for (let current = element; current; current = current.parentElement) {
|
||||||
|
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
||||||
|
if (Number.isFinite(parsed) && parsed < floor) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
||||||
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
||||||
// its layout box, opacity, visibility and display all still read as present.
|
// its layout box, opacity, visibility and display all still read as present.
|
||||||
@@ -142,9 +166,20 @@
|
|||||||
return !paintsAnyProbePoint(element, rect);
|
return !paintsAnyProbePoint(element, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleElement(element) {
|
function isVisibleElement(element, opacityFloor, probeClipPath) {
|
||||||
if (IGNORE_TAGS.has(element.tagName)) return false;
|
if (IGNORE_TAGS.has(element.tagName)) return false;
|
||||||
if (hasIgnoreFlag(element)) return false;
|
if (hasIgnoreFlag(element)) return false;
|
||||||
|
if (
|
||||||
|
opacityFloor != null &&
|
||||||
|
typeof element.checkVisibility === "function" &&
|
||||||
|
!element.checkVisibility({
|
||||||
|
opacityProperty: true,
|
||||||
|
visibilityProperty: true,
|
||||||
|
contentVisibilityAuto: true,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
if (
|
if (
|
||||||
style.display === "none" ||
|
style.display === "none" ||
|
||||||
@@ -153,32 +188,57 @@
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (opacityChain(element) < 0.2) return false;
|
if (
|
||||||
|
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
||||||
return !isClippedAway(element);
|
return probeClipPath === false || !isClippedAway(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
function textContentFor(element) {
|
function directTextNodes(element) {
|
||||||
return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
|
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasOwnTextCandidate(element) {
|
function textContentFor(element, ownTextOnly) {
|
||||||
const text = textContentFor(element);
|
const content = ownTextOnly
|
||||||
|
? directTextNodes(element)
|
||||||
|
.map((node) => node.textContent || "")
|
||||||
|
.join("")
|
||||||
|
: element.innerText || element.textContent || "";
|
||||||
|
return content.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnTextCandidate(element, directOnly) {
|
||||||
|
const text = textContentFor(element, directOnly);
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
|
if (directOnly) return true;
|
||||||
for (const child of Array.from(element.children)) {
|
for (const child of Array.from(element.children)) {
|
||||||
if (isVisibleElement(child) && textContentFor(child)) return false;
|
if (isVisibleElement(child) && textContentFor(child)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textRectFor(element) {
|
function textClientRects(element, directOnly) {
|
||||||
const range = document.createRange();
|
const subjects = directOnly ? directTextNodes(element) : [element];
|
||||||
range.selectNodeContents(element);
|
const rects = [];
|
||||||
const rects = Array.from(range.getClientRects()).filter(
|
for (const subject of subjects) {
|
||||||
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
const range = document.createRange();
|
||||||
);
|
range.selectNodeContents(subject);
|
||||||
range.detach();
|
rects.push(
|
||||||
|
...Array.from(range.getClientRects()).filter(
|
||||||
|
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
range.detach();
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textRectFor(element, directOnly) {
|
||||||
|
const rects = textClientRects(element, directOnly);
|
||||||
if (rects.length === 0) return null;
|
if (rects.length === 0) return null;
|
||||||
|
|
||||||
const union = rects.reduce(
|
const union = rects.reduce(
|
||||||
@@ -602,6 +662,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
||||||
|
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
|
||||||
|
|
||||||
// An element hides text beneath it when it paints opaque pixels at near-full
|
// An element hides text beneath it when it paints opaque pixels at near-full
|
||||||
// opacity: raster content (img/video/canvas), a background image, or a solid
|
// opacity: raster content (img/video/canvas), a background image, or a solid
|
||||||
@@ -703,6 +764,70 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function candidateAnchor(element) {
|
||||||
|
const dataAttributes = {};
|
||||||
|
for (const attribute of Array.from(element.attributes)) {
|
||||||
|
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
||||||
|
}
|
||||||
|
const source = element
|
||||||
|
.closest("[data-composition-file]")
|
||||||
|
?.getAttribute("data-composition-file");
|
||||||
|
return {
|
||||||
|
selector: uniqueSelectorFor(element),
|
||||||
|
dataAttributes,
|
||||||
|
sourceFile: source || "index.html",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
const text = kind === "text" ? textContentFor(element, true) : tag;
|
||||||
|
const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
tag,
|
||||||
|
text,
|
||||||
|
rect,
|
||||||
|
elementRect,
|
||||||
|
...candidateAnchor(element),
|
||||||
|
...(overflow ? { overflow } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
|
||||||
|
const includeText = options?.text === true;
|
||||||
|
const includeMedia = options?.media === true;
|
||||||
|
if (!includeText && !includeMedia) return [];
|
||||||
|
const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
|
||||||
|
const root =
|
||||||
|
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||||
|
document.querySelector("[data-composition-id]") ||
|
||||||
|
document.body;
|
||||||
|
const rootRect = rootRectFor(root);
|
||||||
|
const candidates = [];
|
||||||
|
for (const element of Array.from(document.querySelectorAll("body *"))) {
|
||||||
|
if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isVisibleElement(element, 0.05, false)) continue;
|
||||||
|
const elementRect = toRect(element.getBoundingClientRect());
|
||||||
|
if (includeText && hasOwnTextCandidate(element, true)) {
|
||||||
|
const rect = textRectFor(element, true);
|
||||||
|
if (rect) {
|
||||||
|
candidates.push(
|
||||||
|
geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
|
||||||
|
candidates.push(
|
||||||
|
geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
};
|
||||||
|
|
||||||
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
||||||
const time = options && typeof options.time === "number" ? options.time : 0;
|
const time = options && typeof options.time === "number" ? options.time : 0;
|
||||||
const tolerance =
|
const tolerance =
|
||||||
@@ -712,7 +837,9 @@
|
|||||||
document.querySelector("[data-composition-id]") ||
|
document.querySelector("[data-composition-id]") ||
|
||||||
document.body;
|
document.body;
|
||||||
const rootRect = rootRectFor(root);
|
const rootRect = rootRectFor(root);
|
||||||
const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement);
|
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
||||||
|
isVisibleElement(element),
|
||||||
|
);
|
||||||
const issues = [];
|
const issues = [];
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ interface RectInput {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
Reflect.deleteProperty(document, "elementFromPoint");
|
||||||
|
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
|
||||||
|
clearGeometryCollector();
|
||||||
|
});
|
||||||
|
|
||||||
describe("layout-audit.browser", () => {
|
describe("layout-audit.browser", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||||
|
clearGeometryCollector();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
|
it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
|
||||||
@@ -135,6 +144,206 @@ describe("layout-audit.browser", () => {
|
|||||||
|
|
||||||
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
|
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps auditing visible descendants beyond the second element", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<div id="first"></div>
|
||||||
|
<div id="second"></div>
|
||||||
|
<div id="third"></div>
|
||||||
|
<div id="late">Late visible copy</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry({
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
late: rect({ left: 700, top: 100, width: 140, height: 40 }),
|
||||||
|
text: rect({ left: 700, top: 100, width: 140, height: 40 }),
|
||||||
|
});
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
expect(runAudit()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "canvas_overflow", selector: "#late" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is inert unless text or media candidates are explicitly requested", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<div id="copy">Visible copy</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry({
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
copy: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||||
|
text: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||||
|
});
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns own-text rects and media overflow while excluding caption layers", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<section data-composition-file="scenes/hero.html">
|
||||||
|
<div id="copy" data-layout-name="copy">Own copy <span id="nested">Nested</span></div>
|
||||||
|
<img id="image" src="data:image/png;base64,AA==" />
|
||||||
|
<svg id="vector"></svg>
|
||||||
|
</section>
|
||||||
|
<div class="caption-layer"><p id="caption">Authored captions</p></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry({
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
copy: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||||
|
headline: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||||
|
nested: rect({ left: 220, top: 260, width: 60, height: 40 }),
|
||||||
|
image: rect({ left: 600, top: 40, width: 200, height: 100 }),
|
||||||
|
vector: rect({ left: -130, top: 160, width: 100, height: 100 }),
|
||||||
|
caption: rect({ left: 200, top: 300, width: 240, height: 40 }),
|
||||||
|
text: rect({ left: 100, top: 260, width: 100, height: 40 }),
|
||||||
|
});
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
|
||||||
|
|
||||||
|
expect(candidates).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "text",
|
||||||
|
tag: "div",
|
||||||
|
text: "Own copy",
|
||||||
|
selector: "#copy",
|
||||||
|
sourceFile: "scenes/hero.html",
|
||||||
|
rect: { left: 100, top: 260, right: 200, bottom: 300, width: 100, height: 40 },
|
||||||
|
elementRect: { left: 100, top: 260, right: 280, bottom: 300, width: 180, height: 40 },
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "media",
|
||||||
|
tag: "img",
|
||||||
|
selector: "#image",
|
||||||
|
overflow: { right: 160 },
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "media",
|
||||||
|
tag: "svg",
|
||||||
|
selector: "#vector",
|
||||||
|
overflow: { left: 130 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scans body-level composition siblings and includes a media boundary root", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<p id="portal-copy">Portal copy</p>
|
||||||
|
</div>
|
||||||
|
<img id="portal-image" src="data:image/png;base64,AA==" />
|
||||||
|
`;
|
||||||
|
installGeometry({
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
"portal-copy": rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||||
|
"portal-image": rect({ left: 600, top: 80, width: 180, height: 100 }),
|
||||||
|
text: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||||
|
});
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
|
||||||
|
|
||||||
|
expect(candidates.map((candidate) => candidate.selector)).toEqual(
|
||||||
|
expect.arrayContaining(["#boundary", "#portal-copy", "#portal-image"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unique structural selectors for repeated class-only media", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<img class="tile" src="data:image/png;base64,AA==" />
|
||||||
|
<img class="tile" src="data:image/png;base64,AA==" />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry({
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
"": rect({ left: 100, top: 100, width: 100, height: 100 }),
|
||||||
|
});
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
const candidates = runGeometryCandidates({ text: false, media: true, tolerance: 2 });
|
||||||
|
const images = Array.from(document.querySelectorAll("img"));
|
||||||
|
|
||||||
|
expect(candidates).toHaveLength(2);
|
||||||
|
expect(new Set(candidates.map((candidate) => candidate.selector)).size).toBe(2);
|
||||||
|
expect(document.querySelector(candidates[0]?.selector ?? "")).toBe(images[0]);
|
||||||
|
expect(document.querySelector(candidates[1]?.selector ?? "")).toBe(images[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps visible clip-path text when pointer events do not participate in hit testing", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<p id="clipped-copy">Visible clipped copy</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry(
|
||||||
|
{
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
"clipped-copy": rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||||
|
text: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||||
|
},
|
||||||
|
{ "clipped-copy": { clipPath: "inset(0 10% 0 0)", pointerEvents: "none" } },
|
||||||
|
);
|
||||||
|
Reflect.set(
|
||||||
|
document,
|
||||||
|
"elementFromPoint",
|
||||||
|
vi.fn(() => document.getElementById("root")),
|
||||||
|
);
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
|
||||||
|
|
||||||
|
expect(candidates).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ selector: "#clipped-copy" })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bridge opacity floor across the ancestor chain", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||||
|
<div id="faint-parent"><p id="hidden-copy">Hidden copy</p></div>
|
||||||
|
<div id="soft-parent"><p id="visible-copy">Visible copy</p></div>
|
||||||
|
<div id="stacked-parent"><p id="stacked-copy">Stacked opacity copy</p></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
installGeometry(
|
||||||
|
{
|
||||||
|
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||||
|
"faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }),
|
||||||
|
"hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }),
|
||||||
|
"soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||||
|
"visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||||
|
"stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }),
|
||||||
|
"stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }),
|
||||||
|
text: rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"faint-parent": { opacity: "0.04" },
|
||||||
|
"soft-parent": { opacity: "0.1" },
|
||||||
|
"stacked-parent": { opacity: "0.2" },
|
||||||
|
"stacked-copy": { opacity: "0.2" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
installAuditScript();
|
||||||
|
|
||||||
|
const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
|
||||||
|
|
||||||
|
expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false);
|
||||||
|
expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true);
|
||||||
|
expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("layout-audit.browser content overlap", () => {
|
describe("layout-audit.browser content overlap", () => {
|
||||||
@@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => {
|
|||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||||
|
clearGeometryCollector();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags two solid text blocks that overlap", () => {
|
it("flags two solid text blocks that overlap", () => {
|
||||||
@@ -404,6 +614,7 @@ describe("layout-audit.browser occlusion", () => {
|
|||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||||
|
clearGeometryCollector();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags text painted over by an opaque sibling overlay", () => {
|
it("flags text painted over by an opaque sibling overlay", () => {
|
||||||
@@ -622,7 +833,10 @@ function runAudit(): Array<{
|
|||||||
return audit({ time: 1, tolerance: 2 });
|
return audit({ time: 1, tolerance: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function installGeometry(rects: Record<string, DOMRect>): void {
|
function installGeometry(
|
||||||
|
rects: Record<string, DOMRect>,
|
||||||
|
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
|
||||||
|
): void {
|
||||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||||
const el = element as Element;
|
const el = element as Element;
|
||||||
const isBubble = el.id === "bubble";
|
const isBubble = el.id === "bubble";
|
||||||
@@ -648,6 +862,7 @@ function installGeometry(rects: Record<string, DOMRect>): void {
|
|||||||
paddingBottom: isBubble ? "16px" : "0px",
|
paddingBottom: isBubble ? "16px" : "0px",
|
||||||
paddingLeft: isBubble ? "16px" : "0px",
|
paddingLeft: isBubble ? "16px" : "0px",
|
||||||
fontSize: "36px",
|
fontSize: "36px",
|
||||||
|
...styleOverrides[el.id],
|
||||||
} as unknown as CSSStyleDeclaration;
|
} as unknown as CSSStyleDeclaration;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -678,6 +893,41 @@ function installGeometry(rects: Record<string, DOMRect>): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeometryCandidateResult {
|
||||||
|
kind: "text" | "media";
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
selector: string;
|
||||||
|
sourceFile: string;
|
||||||
|
rect: Record<string, number>;
|
||||||
|
elementRect: Record<string, number>;
|
||||||
|
overflow?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__hyperframesGeometryCandidates?: (options: {
|
||||||
|
text: boolean;
|
||||||
|
media: boolean;
|
||||||
|
tolerance: number;
|
||||||
|
}) => GeometryCandidateResult[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGeometryCandidates(options: {
|
||||||
|
text: boolean;
|
||||||
|
media: boolean;
|
||||||
|
tolerance: number;
|
||||||
|
}): GeometryCandidateResult[] {
|
||||||
|
const collector = window.__hyperframesGeometryCandidates;
|
||||||
|
if (!collector) throw new Error("Geometry collector was not installed");
|
||||||
|
return collector(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGeometryCollector(): void {
|
||||||
|
delete window.__hyperframesGeometryCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
function rect({ left, top, width, height }: RectInput): DOMRect {
|
function rect({ left, top, width, height }: RectInput): DOMRect {
|
||||||
return {
|
return {
|
||||||
left,
|
left,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
openSettledCompositionPage,
|
||||||
|
type OpenSettledCompositionPageOptions,
|
||||||
|
} from "../capture/captureCompositionFrame.js";
|
||||||
|
import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js";
|
||||||
|
import { runBrowserCheck } from "./checkBrowser.js";
|
||||||
|
import type { ProjectDir } from "./project.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
serverClose: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@hyperframes/core/compiler", () => ({
|
||||||
|
bundleToSingleHtml: vi.fn(async () => "<html></html>"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../capture/captureCompositionFrame.js", () => ({
|
||||||
|
openSettledCompositionPage: vi.fn(),
|
||||||
|
resolveCliChromeGpuMode: vi.fn(() => "hardware"),
|
||||||
|
seekCompositionTimeline: vi.fn(async () => undefined),
|
||||||
|
waitForPreferredSeekTarget: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./staticProjectServer.js", () => ({
|
||||||
|
serveStaticProjectHtml: vi.fn(async () => ({
|
||||||
|
url: "http://127.0.0.1:3000",
|
||||||
|
close: mocks.serverClose,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PROJECT: ProjectDir = {
|
||||||
|
dir: "/project",
|
||||||
|
name: "project",
|
||||||
|
indexPath: "/project/index.html",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
Reflect.deleteProperty(window, "__hyperframesGeometryCandidates");
|
||||||
|
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries raw browser geometry through the page driver and pipeline", async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">
|
||||||
|
<section data-composition-file="scenes/hero.html">
|
||||||
|
<img id="hero-image" data-layout-name="hero" src="data:image/png;base64,AA==" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
|
||||||
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
|
||||||
|
installRects();
|
||||||
|
const page = fakePage();
|
||||||
|
const browser = Object.assign(Object.create(null), {
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
vi.mocked(openSettledCompositionPage).mockImplementation(
|
||||||
|
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
|
||||||
|
await options.beforeNavigate?.(page);
|
||||||
|
return { page, browser, renderReadyTimedOut: false };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runBrowserCheck(
|
||||||
|
PROJECT,
|
||||||
|
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} },
|
||||||
|
{ kind: "none" },
|
||||||
|
runAuditGrid,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.layoutIssues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "frame_out_of_frame",
|
||||||
|
severity: "warning",
|
||||||
|
selector: "#hero-image",
|
||||||
|
sourceFile: "scenes/hero.html",
|
||||||
|
dataAttributes: { "data-layout-name": "hero" },
|
||||||
|
bbox: { x: 600, y: 80, width: 200, height: 100 },
|
||||||
|
rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 },
|
||||||
|
overflow: { right: 160 },
|
||||||
|
time: 5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(mocks.serverClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
function installRects(): void {
|
||||||
|
const root = document.querySelector("[data-composition-id]");
|
||||||
|
const image = document.querySelector("#hero-image");
|
||||||
|
if (!root || !image) throw new Error("Geometry fixture failed to mount");
|
||||||
|
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
|
||||||
|
vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakePage() {
|
||||||
|
return Object.assign(Object.create(null), {
|
||||||
|
on: vi.fn(),
|
||||||
|
addScriptTag: vi.fn(async ({ content }: { content: string }) => {
|
||||||
|
window.eval(content);
|
||||||
|
}),
|
||||||
|
evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => {
|
||||||
|
if (typeof callback !== "function") throw new Error("Expected an evaluate callback");
|
||||||
|
return Reflect.apply(callback, window, args);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,10 +18,12 @@ import type {
|
|||||||
CheckBbox,
|
CheckBbox,
|
||||||
CheckBrowserResult,
|
CheckBrowserResult,
|
||||||
CheckFinding,
|
CheckFinding,
|
||||||
|
CheckGeometryCandidate,
|
||||||
CheckOptions,
|
CheckOptions,
|
||||||
CheckSeverity,
|
CheckSeverity,
|
||||||
ContrastAuditEntry,
|
ContrastAuditEntry,
|
||||||
ContrastCapture,
|
ContrastCapture,
|
||||||
|
GeometryCandidateRequest,
|
||||||
MotionSpecResolution,
|
MotionSpecResolution,
|
||||||
RunAuditGrid,
|
RunAuditGrid,
|
||||||
} from "./checkTypes.js";
|
} from "./checkTypes.js";
|
||||||
@@ -193,6 +195,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
|||||||
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
||||||
},
|
},
|
||||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||||
|
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
||||||
collectMotionFrame: (time, selectors, scopes) =>
|
collectMotionFrame: (time, selectors, scopes) =>
|
||||||
collectMotionFrame(page, time, selectors, scopes),
|
collectMotionFrame(page, time, selectors, scopes),
|
||||||
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
|
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
|
||||||
@@ -304,6 +307,24 @@ async function collectLayout(
|
|||||||
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
|
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectGeometryCandidates(
|
||||||
|
page: Page,
|
||||||
|
time: number,
|
||||||
|
request: GeometryCandidateRequest,
|
||||||
|
): Promise<CheckGeometryCandidate[]> {
|
||||||
|
try {
|
||||||
|
const raw = await page.evaluate((options: GeometryCandidateRequest) => {
|
||||||
|
const collect = Reflect.get(window, "__hyperframesGeometryCandidates");
|
||||||
|
if (typeof collect !== "function") return [];
|
||||||
|
const result = Reflect.apply(collect, window, [options]);
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}, request);
|
||||||
|
return raw.flatMap((value) => parseGeometryCandidate(value, time));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findAmbiguousSelectors(
|
async function findAmbiguousSelectors(
|
||||||
page: Page,
|
page: Page,
|
||||||
selectors: string[],
|
selectors: string[],
|
||||||
@@ -590,6 +611,54 @@ function parseLayoutIssue(value: unknown): LayoutIssue[] {
|
|||||||
return [issue];
|
return [issue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] {
|
||||||
|
if (!isRecord(value)) return [];
|
||||||
|
const rect = parseRect(Reflect.get(value, "rect"));
|
||||||
|
const elementRect = parseRect(Reflect.get(value, "elementRect"));
|
||||||
|
if (!rect || !elementRect) return [];
|
||||||
|
const identity = parseGeometryIdentity(value);
|
||||||
|
if (!identity) return [];
|
||||||
|
const anchor = parseGeometryAnchor(value, rect, time);
|
||||||
|
if (!anchor) return [];
|
||||||
|
const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect };
|
||||||
|
const overflow = parseOverflow(Reflect.get(value, "overflow"));
|
||||||
|
if (overflow) candidate.overflow = overflow;
|
||||||
|
return [candidate];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeometryIdentity(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
): Pick<CheckGeometryCandidate, "kind" | "tag" | "text"> | null {
|
||||||
|
const kindValue = Reflect.get(value, "kind");
|
||||||
|
const kind = kindValue === "text" || kindValue === "media" ? kindValue : null;
|
||||||
|
if (!kind) return null;
|
||||||
|
const tag = stringValue(value, "tag");
|
||||||
|
if (!tag) return null;
|
||||||
|
const text = stringValue(value, "text");
|
||||||
|
return text === null ? null : { kind, tag, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeometryAnchor(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
rect: LayoutRect,
|
||||||
|
time: number,
|
||||||
|
): CheckAnchor | null {
|
||||||
|
const selector = stringValue(value, "selector");
|
||||||
|
if (!selector) return null;
|
||||||
|
const sourceFile = stringValue(value, "sourceFile");
|
||||||
|
if (!sourceFile) return null;
|
||||||
|
const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes"));
|
||||||
|
return dataAttributes
|
||||||
|
? {
|
||||||
|
selector,
|
||||||
|
sourceFile,
|
||||||
|
dataAttributes,
|
||||||
|
bbox: rectToBbox(rect),
|
||||||
|
time,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, unknown>): void {
|
function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, unknown>): void {
|
||||||
assignOptionalString(issue, value, "containerSelector");
|
assignOptionalString(issue, value, "containerSelector");
|
||||||
assignOptionalString(issue, value, "text");
|
assignOptionalString(issue, value, "text");
|
||||||
@@ -721,6 +790,8 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
|
|||||||
"container_overflow",
|
"container_overflow",
|
||||||
"content_overlap",
|
"content_overlap",
|
||||||
"text_occluded",
|
"text_occluded",
|
||||||
|
"caption_zone_collision",
|
||||||
|
"frame_out_of_frame",
|
||||||
"motion_appears_late",
|
"motion_appears_late",
|
||||||
"motion_out_of_order",
|
"motion_out_of_order",
|
||||||
"motion_off_frame",
|
"motion_off_frame",
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
type LayoutIssue,
|
type LayoutIssue,
|
||||||
type LayoutRect,
|
type LayoutRect,
|
||||||
} from "./layoutAudit.js";
|
} from "./layoutAudit.js";
|
||||||
import { collectSamplingTargets, evaluateMotion, type MotionFrame } from "./motionAudit.js";
|
import {
|
||||||
|
collectSamplingTargets,
|
||||||
|
evaluateMotion,
|
||||||
|
type Canvas,
|
||||||
|
type MotionFrame,
|
||||||
|
} from "./motionAudit.js";
|
||||||
import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
|
import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
|
||||||
import { normalizeErrorMessage } from "./errorMessage.js";
|
import { normalizeErrorMessage } from "./errorMessage.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,12 +34,14 @@ import type {
|
|||||||
CheckContrastFinding,
|
CheckContrastFinding,
|
||||||
CheckDependencies,
|
CheckDependencies,
|
||||||
CheckFinding,
|
CheckFinding,
|
||||||
|
CheckGeometryCandidate,
|
||||||
CheckOptions,
|
CheckOptions,
|
||||||
CheckReport,
|
CheckReport,
|
||||||
CheckScreenshot,
|
CheckScreenshot,
|
||||||
CheckSection,
|
CheckSection,
|
||||||
CheckSeverity,
|
CheckSeverity,
|
||||||
ContrastAuditEntry,
|
ContrastAuditEntry,
|
||||||
|
GeometryCandidateRequest,
|
||||||
MotionSpecResolution,
|
MotionSpecResolution,
|
||||||
} from "./checkTypes.js";
|
} from "./checkTypes.js";
|
||||||
|
|
||||||
@@ -55,6 +62,9 @@ export type {
|
|||||||
const MOTION_FPS = 20;
|
const MOTION_FPS = 20;
|
||||||
const MOTION_MAX_SAMPLES = 300;
|
const MOTION_MAX_SAMPLES = 300;
|
||||||
const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 };
|
const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
// Ignore normal in/out slide travel; only substantive frame breaches are actionable.
|
||||||
|
const FRAME_BREACH_FLOOR_PX = 120;
|
||||||
|
const FRAME_BREACH_FLOOR_FRACTION = 0.06;
|
||||||
|
|
||||||
export const DEFAULT_CHECK_OPTIONS: CheckOptions = {
|
export const DEFAULT_CHECK_OPTIONS: CheckOptions = {
|
||||||
samples: 9,
|
samples: 9,
|
||||||
@@ -87,11 +97,23 @@ function buildMotionSampleTimes(duration: number): number[] {
|
|||||||
interface SampleGrid {
|
interface SampleGrid {
|
||||||
duration: number;
|
duration: number;
|
||||||
layoutSamples: number[];
|
layoutSamples: number[];
|
||||||
|
captionSamples: number[];
|
||||||
|
frameSamples: number[];
|
||||||
transitionSamples: number[];
|
transitionSamples: number[];
|
||||||
transitionSamplesDropped: number;
|
transitionSamplesDropped: number;
|
||||||
contrastSamples: number[];
|
contrastSamples: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gateSampleTimes(
|
||||||
|
duration: number,
|
||||||
|
seeks: number[] | undefined,
|
||||||
|
fallback: number,
|
||||||
|
): number[] {
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return [];
|
||||||
|
const fractions = seeks && seeks.length > 0 ? seeks : [fallback];
|
||||||
|
return mergeSampleTimes(fractions.map((fraction) => fraction * duration));
|
||||||
|
}
|
||||||
|
|
||||||
async function buildSampleGrid(
|
async function buildSampleGrid(
|
||||||
driver: CheckAuditDriver,
|
driver: CheckAuditDriver,
|
||||||
options: CheckOptions,
|
options: CheckOptions,
|
||||||
@@ -109,16 +131,25 @@ async function buildSampleGrid(
|
|||||||
cap: options.maxTransitionSamples,
|
cap: options.maxTransitionSamples,
|
||||||
})
|
})
|
||||||
: { times: [], dropped: 0 };
|
: { times: [], dropped: 0 };
|
||||||
const layoutSamples = mergeSampleTimes(baseSamples, transitions.times);
|
const captionSamples = options.captionZone
|
||||||
|
? gateSampleTimes(duration, options.captionZone.seek, 1)
|
||||||
|
: [];
|
||||||
|
const frameSamples = options.frameCheck
|
||||||
|
? gateSampleTimes(duration, options.frameCheck.seek, 0.5)
|
||||||
|
: [];
|
||||||
|
const auditSamples = mergeSampleTimes(baseSamples, transitions.times);
|
||||||
|
const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples);
|
||||||
if (layoutSamples.length === 0) {
|
if (layoutSamples.length === 0) {
|
||||||
throw new Error("Could not determine composition duration — no layout samples run");
|
throw new Error("Could not determine composition duration — no layout samples run");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
duration,
|
duration,
|
||||||
layoutSamples,
|
layoutSamples,
|
||||||
|
captionSamples,
|
||||||
|
frameSamples,
|
||||||
transitionSamples: transitions.times,
|
transitionSamples: transitions.times,
|
||||||
transitionSamplesDropped: transitions.dropped,
|
transitionSamplesDropped: transitions.dropped,
|
||||||
contrastSamples: options.contrast ? selectContrastTimes(layoutSamples) : [],
|
contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +182,156 @@ interface GridSamples {
|
|||||||
screenshots: CheckScreenshot[];
|
screenshots: CheckScreenshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeometrySeen {
|
||||||
|
caption: Set<string>;
|
||||||
|
frame: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryRequest(
|
||||||
|
time: number,
|
||||||
|
grid: SampleGrid,
|
||||||
|
options: CheckOptions,
|
||||||
|
): GeometryCandidateRequest | null {
|
||||||
|
const text = grid.captionSamples.includes(time);
|
||||||
|
const media = grid.frameSamples.includes(time);
|
||||||
|
if (!text && !media) return null;
|
||||||
|
const configuredTolerance = options.frameCheck?.tol;
|
||||||
|
const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2;
|
||||||
|
return { text, media, tolerance };
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean {
|
||||||
|
if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false;
|
||||||
|
return !(
|
||||||
|
candidate.elementRect.width >= 0.95 * canvas.width &&
|
||||||
|
candidate.elementRect.height >= 0.95 * canvas.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
|
||||||
|
return {
|
||||||
|
selector: candidate.selector,
|
||||||
|
dataAttributes: candidate.dataAttributes,
|
||||||
|
sourceFile: candidate.sourceFile,
|
||||||
|
bbox: candidate.bbox,
|
||||||
|
time,
|
||||||
|
rect: candidate.rect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionFinding(
|
||||||
|
candidate: CheckGeometryCandidate,
|
||||||
|
options: CheckOptions,
|
||||||
|
canvas: Canvas,
|
||||||
|
time: number,
|
||||||
|
): { key: string; issue: AnchoredLayoutIssue } | null {
|
||||||
|
const zone = options.captionZone;
|
||||||
|
if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
|
||||||
|
const cx = candidate.rect.left + candidate.rect.width / 2;
|
||||||
|
const cy = candidate.rect.top + candidate.rect.height / 2;
|
||||||
|
const inside =
|
||||||
|
cx >= zone.x0 * canvas.width &&
|
||||||
|
cx <= zone.x1 * canvas.width &&
|
||||||
|
cy >= zone.y0 * canvas.height &&
|
||||||
|
cy <= zone.y1 * canvas.height;
|
||||||
|
if (!inside) return null;
|
||||||
|
const text = candidate.text.slice(0, 48);
|
||||||
|
const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100);
|
||||||
|
return {
|
||||||
|
key: `${candidate.tag}|${text}`,
|
||||||
|
issue: {
|
||||||
|
...geometryIssueAnchor(candidate, time),
|
||||||
|
code: "caption_zone_collision",
|
||||||
|
severity: zone.severity === "error" ? "error" : "warning",
|
||||||
|
text,
|
||||||
|
message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`,
|
||||||
|
fixHint: "Keep main content outside the configured caption band.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxOverflow(candidate: CheckGeometryCandidate): number {
|
||||||
|
if (!candidate.overflow) return 0;
|
||||||
|
return Math.max(
|
||||||
|
candidate.overflow.left ?? 0,
|
||||||
|
candidate.overflow.top ?? 0,
|
||||||
|
candidate.overflow.right ?? 0,
|
||||||
|
candidate.overflow.bottom ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overflowMessage(candidate: CheckGeometryCandidate): string {
|
||||||
|
const overflow = candidate.overflow ?? {};
|
||||||
|
const edges: string[] = [];
|
||||||
|
if (overflow.left) edges.push(`${overflow.left}px past the left`);
|
||||||
|
if (overflow.top) edges.push(`${overflow.top}px past the top`);
|
||||||
|
if (overflow.right) edges.push(`${overflow.right}px past the right`);
|
||||||
|
if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`);
|
||||||
|
return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameFinding(
|
||||||
|
candidate: CheckGeometryCandidate,
|
||||||
|
options: CheckOptions,
|
||||||
|
canvas: Canvas,
|
||||||
|
time: number,
|
||||||
|
): { key: string; issue: AnchoredLayoutIssue } | null {
|
||||||
|
if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const floor = Math.max(
|
||||||
|
FRAME_BREACH_FLOOR_PX,
|
||||||
|
FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height),
|
||||||
|
);
|
||||||
|
if (maxOverflow(candidate) < floor) return null;
|
||||||
|
const text = candidate.text.slice(0, 48);
|
||||||
|
return {
|
||||||
|
key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`,
|
||||||
|
issue: {
|
||||||
|
...geometryIssueAnchor(candidate, time),
|
||||||
|
code: "frame_out_of_frame",
|
||||||
|
severity: options.frameCheck.severity === "error" ? "error" : "warning",
|
||||||
|
text,
|
||||||
|
overflow: candidate.overflow,
|
||||||
|
message: overflowMessage(candidate),
|
||||||
|
fixHint: "Keep media within the composition frame's safe area.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendGeometryFinding(
|
||||||
|
result: { key: string; issue: AnchoredLayoutIssue } | null,
|
||||||
|
seen: Set<string>,
|
||||||
|
issues: AnchoredLayoutIssue[],
|
||||||
|
): void {
|
||||||
|
if (!result || seen.has(result.key)) return;
|
||||||
|
seen.add(result.key);
|
||||||
|
issues.push(result.issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectGeometryAt(
|
||||||
|
driver: CheckAuditDriver,
|
||||||
|
options: CheckOptions,
|
||||||
|
grid: SampleGrid,
|
||||||
|
canvas: Canvas,
|
||||||
|
time: number,
|
||||||
|
seen: GeometrySeen,
|
||||||
|
): Promise<AnchoredLayoutIssue[]> {
|
||||||
|
const request = geometryRequest(time, grid, options);
|
||||||
|
if (!request) return [];
|
||||||
|
const candidates = await driver.collectGeometryCandidates(time, request);
|
||||||
|
const issues: AnchoredLayoutIssue[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (request.text) {
|
||||||
|
appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues);
|
||||||
|
}
|
||||||
|
if (request.media) {
|
||||||
|
appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
async function collectGridSamples(
|
async function collectGridSamples(
|
||||||
driver: CheckAuditDriver,
|
driver: CheckAuditDriver,
|
||||||
options: CheckOptions,
|
options: CheckOptions,
|
||||||
@@ -160,6 +341,9 @@ async function collectGridSamples(
|
|||||||
const layoutSet = new Set(grid.layoutSamples);
|
const layoutSet = new Set(grid.layoutSamples);
|
||||||
const motionSet = new Set(motion.times);
|
const motionSet = new Set(motion.times);
|
||||||
const contrastSet = new Set(grid.contrastSamples);
|
const contrastSet = new Set(grid.contrastSamples);
|
||||||
|
const geometryEnabled = grid.captionSamples.length > 0 || grid.frameSamples.length > 0;
|
||||||
|
const canvas = geometryEnabled ? await driver.getCanvas() : null;
|
||||||
|
const geometrySeen: GeometrySeen = { caption: new Set(), frame: new Set() };
|
||||||
const collected: GridSamples = {
|
const collected: GridSamples = {
|
||||||
layoutIssues: [],
|
layoutIssues: [],
|
||||||
motionFrames: [],
|
motionFrames: [],
|
||||||
@@ -171,6 +355,11 @@ async function collectGridSamples(
|
|||||||
if (layoutSet.has(time)) {
|
if (layoutSet.has(time)) {
|
||||||
collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance)));
|
collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance)));
|
||||||
}
|
}
|
||||||
|
if (canvas) {
|
||||||
|
collected.layoutIssues.push(
|
||||||
|
...(await collectGeometryAt(driver, options, grid, canvas, time, geometrySeen)),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (motionSet.has(time)) {
|
if (motionSet.has(time)) {
|
||||||
collected.motionFrames.push(
|
collected.motionFrames.push(
|
||||||
await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes),
|
await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ProjectLintResult } from "./lintProject.js";
|
import type { ProjectLintResult } from "./lintProject.js";
|
||||||
import type { LayoutIssue } from "./layoutAudit.js";
|
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js";
|
||||||
import type { Canvas, MotionFrame } from "./motionAudit.js";
|
import type { Canvas, MotionFrame } from "./motionAudit.js";
|
||||||
import type { MotionSpec } from "./motionSpec.js";
|
import type { MotionSpec } from "./motionSpec.js";
|
||||||
import type { ProjectDir } from "./project.js";
|
import type { ProjectDir } from "./project.js";
|
||||||
@@ -16,6 +16,23 @@ export interface CheckOptions {
|
|||||||
contrast: boolean;
|
contrast: boolean;
|
||||||
strict: boolean;
|
strict: boolean;
|
||||||
snapshots: boolean;
|
snapshots: boolean;
|
||||||
|
captionZone?: CaptionZoneOptions;
|
||||||
|
frameCheck?: FrameCheckOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptionZoneOptions {
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
severity?: "error" | "warning";
|
||||||
|
seek?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameCheckOptions {
|
||||||
|
tol?: number;
|
||||||
|
severity?: "error" | "warning";
|
||||||
|
seek?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckSeverity = "error" | "warning" | "info";
|
export type CheckSeverity = "error" | "warning" | "info";
|
||||||
@@ -70,6 +87,21 @@ export interface ContrastCapture {
|
|||||||
pngBase64: string;
|
pngBase64: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeometryCandidateRequest {
|
||||||
|
text: boolean;
|
||||||
|
media: boolean;
|
||||||
|
tolerance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckGeometryCandidate extends CheckAnchor {
|
||||||
|
kind: "text" | "media";
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
rect: LayoutRect;
|
||||||
|
elementRect: LayoutRect;
|
||||||
|
overflow?: LayoutOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
export type MotionSpecResolution =
|
export type MotionSpecResolution =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
| { kind: "valid"; path: string; spec: MotionSpec }
|
| { kind: "valid"; path: string; spec: MotionSpec }
|
||||||
@@ -83,6 +115,10 @@ export interface CheckAuditDriver {
|
|||||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||||
seek(time: number): Promise<void>;
|
seek(time: number): Promise<void>;
|
||||||
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
||||||
|
collectGeometryCandidates(
|
||||||
|
time: number,
|
||||||
|
request: GeometryCandidateRequest,
|
||||||
|
): Promise<CheckGeometryCandidate[]>;
|
||||||
collectMotionFrame(
|
collectMotionFrame(
|
||||||
time: number,
|
time: number,
|
||||||
selectors: string[],
|
selectors: string[],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type LayoutIssueCode =
|
|||||||
| "container_overflow"
|
| "container_overflow"
|
||||||
| "content_overlap"
|
| "content_overlap"
|
||||||
| "text_occluded"
|
| "text_occluded"
|
||||||
|
| "caption_zone_collision"
|
||||||
|
| "frame_out_of_frame"
|
||||||
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
||||||
| "motion_appears_late"
|
| "motion_appears_late"
|
||||||
| "motion_out_of_order"
|
| "motion_out_of_order"
|
||||||
@@ -152,6 +154,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
|||||||
issue.containerSelector ?? "",
|
issue.containerSelector ?? "",
|
||||||
issue.text ?? "",
|
issue.text ?? "",
|
||||||
issue.overflow ? formatOverflow(issue.overflow) : "",
|
issue.overflow ? formatOverflow(issue.overflow) : "",
|
||||||
|
framePositionKey(issue),
|
||||||
].join("|");
|
].join("|");
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@@ -230,9 +233,16 @@ function staticIssueKey(issue: LayoutIssue): string {
|
|||||||
issue.containerSelector ?? "",
|
issue.containerSelector ?? "",
|
||||||
issue.text ?? "",
|
issue.text ?? "",
|
||||||
issue.overflow ? formatOverflow(issue.overflow) : "",
|
issue.overflow ? formatOverflow(issue.overflow) : "",
|
||||||
|
framePositionKey(issue),
|
||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function framePositionKey(issue: LayoutIssue): string {
|
||||||
|
return issue.code === "frame_out_of_frame"
|
||||||
|
? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueSortedTimes(times: number[]): number[] {
|
function uniqueSortedTimes(times: number[]): number[] {
|
||||||
const rounded = times.map(roundTime);
|
const rounded = times.map(roundTime);
|
||||||
return [...new Set(rounded)].sort((a, b) => a - b);
|
return [...new Set(rounded)].sort((a, b) => a - b);
|
||||||
|
|||||||
Reference in New Issue
Block a user