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:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 7d6d41361b
commit 7ab6c2b7a2
9 changed files with 1376 additions and 27 deletions
+110
View File
@@ -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);
}),
});
}
+71
View File
@@ -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",
+192 -3
View File
@@ -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),
+37 -1
View File
@@ -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[],
+10
View File
@@ -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);