mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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:
@@ -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,
|
||||
CheckBrowserResult,
|
||||
CheckFinding,
|
||||
CheckGeometryCandidate,
|
||||
CheckOptions,
|
||||
CheckSeverity,
|
||||
ContrastAuditEntry,
|
||||
ContrastCapture,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
RunAuditGrid,
|
||||
} from "./checkTypes.js";
|
||||
@@ -193,6 +195,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
||||
await seekCompositionTimeline(page, time, SEEK_OPTIONS);
|
||||
},
|
||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
||||
collectMotionFrame: (time, selectors, scopes) =>
|
||||
collectMotionFrame(page, time, selectors, scopes),
|
||||
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
|
||||
@@ -304,6 +307,24 @@ async function collectLayout(
|
||||
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(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
@@ -590,6 +611,54 @@ function parseLayoutIssue(value: unknown): LayoutIssue[] {
|
||||
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 {
|
||||
assignOptionalString(issue, value, "containerSelector");
|
||||
assignOptionalString(issue, value, "text");
|
||||
@@ -721,6 +790,8 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
|
||||
"container_overflow",
|
||||
"content_overlap",
|
||||
"text_occluded",
|
||||
"caption_zone_collision",
|
||||
"frame_out_of_frame",
|
||||
"motion_appears_late",
|
||||
"motion_out_of_order",
|
||||
"motion_off_frame",
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
type LayoutIssue,
|
||||
type LayoutRect,
|
||||
} 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 { normalizeErrorMessage } from "./errorMessage.js";
|
||||
import {
|
||||
@@ -29,12 +34,14 @@ import type {
|
||||
CheckContrastFinding,
|
||||
CheckDependencies,
|
||||
CheckFinding,
|
||||
CheckGeometryCandidate,
|
||||
CheckOptions,
|
||||
CheckReport,
|
||||
CheckScreenshot,
|
||||
CheckSection,
|
||||
CheckSeverity,
|
||||
ContrastAuditEntry,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
} from "./checkTypes.js";
|
||||
|
||||
@@ -55,6 +62,9 @@ export type {
|
||||
const MOTION_FPS = 20;
|
||||
const MOTION_MAX_SAMPLES = 300;
|
||||
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 = {
|
||||
samples: 9,
|
||||
@@ -87,11 +97,23 @@ function buildMotionSampleTimes(duration: number): number[] {
|
||||
interface SampleGrid {
|
||||
duration: number;
|
||||
layoutSamples: number[];
|
||||
captionSamples: number[];
|
||||
frameSamples: number[];
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: 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(
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
@@ -109,16 +131,25 @@ async function buildSampleGrid(
|
||||
cap: options.maxTransitionSamples,
|
||||
})
|
||||
: { 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) {
|
||||
throw new Error("Could not determine composition duration — no layout samples run");
|
||||
}
|
||||
return {
|
||||
duration,
|
||||
layoutSamples,
|
||||
captionSamples,
|
||||
frameSamples,
|
||||
transitionSamples: transitions.times,
|
||||
transitionSamplesDropped: transitions.dropped,
|
||||
contrastSamples: options.contrast ? selectContrastTimes(layoutSamples) : [],
|
||||
contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,6 +182,156 @@ interface GridSamples {
|
||||
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(
|
||||
driver: CheckAuditDriver,
|
||||
options: CheckOptions,
|
||||
@@ -160,6 +341,9 @@ async function collectGridSamples(
|
||||
const layoutSet = new Set(grid.layoutSamples);
|
||||
const motionSet = new Set(motion.times);
|
||||
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 = {
|
||||
layoutIssues: [],
|
||||
motionFrames: [],
|
||||
@@ -171,6 +355,11 @@ async function collectGridSamples(
|
||||
if (layoutSet.has(time)) {
|
||||
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)) {
|
||||
collected.motionFrames.push(
|
||||
await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { MotionSpec } from "./motionSpec.js";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
@@ -16,6 +16,23 @@ export interface CheckOptions {
|
||||
contrast: boolean;
|
||||
strict: 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";
|
||||
@@ -70,6 +87,21 @@ export interface ContrastCapture {
|
||||
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 =
|
||||
| { kind: "none" }
|
||||
| { kind: "valid"; path: string; spec: MotionSpec }
|
||||
@@ -83,6 +115,10 @@ export interface CheckAuditDriver {
|
||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||
seek(time: number): Promise<void>;
|
||||
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
||||
collectGeometryCandidates(
|
||||
time: number,
|
||||
request: GeometryCandidateRequest,
|
||||
): Promise<CheckGeometryCandidate[]>;
|
||||
collectMotionFrame(
|
||||
time: number,
|
||||
selectors: string[],
|
||||
|
||||
@@ -16,6 +16,8 @@ export type LayoutIssueCode =
|
||||
| "container_overflow"
|
||||
| "content_overlap"
|
||||
| "text_occluded"
|
||||
| "caption_zone_collision"
|
||||
| "frame_out_of_frame"
|
||||
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
||||
| "motion_appears_late"
|
||||
| "motion_out_of_order"
|
||||
@@ -152,6 +154,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
||||
issue.containerSelector ?? "",
|
||||
issue.text ?? "",
|
||||
issue.overflow ? formatOverflow(issue.overflow) : "",
|
||||
framePositionKey(issue),
|
||||
].join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -230,9 +233,16 @@ function staticIssueKey(issue: LayoutIssue): string {
|
||||
issue.containerSelector ?? "",
|
||||
issue.text ?? "",
|
||||
issue.overflow ? formatOverflow(issue.overflow) : "",
|
||||
framePositionKey(issue),
|
||||
].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[] {
|
||||
const rounded = times.map(roundTime);
|
||||
return [...new Set(rounded)].sort((a, b) => a - b);
|
||||
|
||||
Reference in New Issue
Block a user