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
+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),