Files
pi-imgen/extensions/imgen/render/contrast.ts
T
mozempk c74b3c8b5e fix(render): satisfy the Renderer contract the commands declare
index.ts was written against assumed renderer signatures (render(req)) while
typst.ts exposed render(resolved, jobDir, outPath). It type-checked -- only
typeof === 'function' was ever asserted -- but would have failed at runtime.

typst.ts now exports the request-shaped render()/renderAllFormats() the
commands' Renderer interface declares; the low-level entry points are
renderOne()/renderFormats(). smartCrop() likewise adopts retouch.ts's richer
contract, returning the size read back from the written file.

Verified e2e: a3-portrait -> PNG+PDF, ig-story -> PNG, Italian progress.
2026-08-27 09:55:48 +02:00

178 lines
6.1 KiB
TypeScript

/**
* Contrast analysis — decides the ink colour and whether a scrim is needed, BEFORE
* Typst runs. Keeping this in TypeScript keeps the templates pure functions of the spec.
*
* The rule that matters: text must be legible against what is ACTUALLY behind it. A mean
* luminance check alone is not enough — white text over busy artwork can have a perfectly
* comfortable mean and still be unreadable, so variance forces a scrim independently.
*/
import sharp from "sharp";
import type { TemplateName } from "../design/spec.ts";
/** WCAG AA for large text. Everything a poster sets is large, so this is the honest bar. */
export const MIN_CONTRAST = 3.0;
/** Above this per-channel stdev the region is "busy" and needs a scrim regardless of mean. */
export const BUSY_STDEV = 48;
export interface Region {
/** All fractions of the image, 0..1. */
left: number;
top: number;
width: number;
height: number;
}
export interface RegionAnalysis {
luminance: number;
stdev: number;
busy: boolean;
ink: string;
contrast: number;
needsScrim: boolean;
}
/** sRGB channel (0..255) -> linear 0..1. */
function linearise(v: number): number {
const u = v / 255;
return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
}
export function relativeLuminance(r: number, g: number, b: number): number {
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
}
export function contrastRatio(l1: number, l2: number): number {
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
}
export function hexLuminance(hex: string): number {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return 0;
const n = parseInt(m[1], 16);
return relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255);
}
/**
* Where each template puts its type, as a fraction of the artwork.
*
* These mirror the layouts in templates/*.typ. `framed` and `split` are deliberately
* absent: neither places text over the picture at all (split says so in its own source),
* so there is nothing to measure and no scrim to draw.
*/
export const TEXT_REGIONS: Partial<Record<TemplateName, Region>> = {
"hero-bottom": { left: 0, top: 0.55, width: 1, height: 0.45 },
banded: { left: 0, top: 0.34, width: 1, height: 0.32 },
"centred-stack": { left: 0.06, top: 0.22, width: 0.88, height: 0.56 },
};
/** True when this template sets type over the artwork. */
export function typeSitsOnArt(template: TemplateName): boolean {
return template in TEXT_REGIONS;
}
/**
* Measure the region of `imagePath` that will sit behind the text.
*
* Returns the ink to use over the ARTWORK plus whether a scrim is required. Never throws
* on a missing or unreadable image: a poster that renders with a safe default beats a
* crash, so the fallback is white ink with a scrim.
*/
export async function analyseRegion(imagePath: string, region: Region): Promise<RegionAnalysis> {
try {
const img = sharp(imagePath);
const meta = await img.metadata();
const W = meta.width ?? 0;
const H = meta.height ?? 0;
if (!W || !H) return fallback();
// Clamp: a region partly outside the image must still yield a valid extract box.
const left = Math.max(0, Math.min(W - 1, Math.round(region.left * W)));
const top = Math.max(0, Math.min(H - 1, Math.round(region.top * H)));
const width = Math.max(1, Math.min(W - left, Math.round(region.width * W)));
const height = Math.max(1, Math.min(H - top, Math.round(region.height * H)));
// Flatten onto mid-grey first: an alpha channel would otherwise skew the means, and
// .stats() reports premultiplied values that misrepresent what a viewer sees.
const stats = await sharp(imagePath)
.extract({ left, top, width, height })
.flatten({ background: { r: 128, g: 128, b: 128 } })
.toColourspace("srgb")
.stats();
const ch = stats.channels.slice(0, 3);
if (ch.length < 3) return fallback();
const luminance = relativeLuminance(ch[0].mean, ch[1].mean, ch[2].mean);
const stdev = Math.max(...ch.map((c) => c.stdev));
const busy = stdev > BUSY_STDEV;
const white = 1.0;
const black = 0.0;
const rWhite = contrastRatio(white, luminance);
const rBlack = contrastRatio(black, luminance);
const useWhite = rWhite >= rBlack;
return {
luminance,
stdev,
busy,
ink: useWhite ? "#ffffff" : "#111111",
contrast: Math.max(rWhite, rBlack),
// A scrim goes on when contrast is short OR the region is busy. The busy case is
// the one a mean-only check silently gets wrong.
needsScrim: Math.max(rWhite, rBlack) < MIN_CONTRAST || busy,
};
} catch {
return fallback();
}
}
function fallback(): RegionAnalysis {
return {
luminance: 0.5,
stdev: 0,
busy: false,
ink: "#ffffff",
contrast: 1,
needsScrim: true,
};
}
/**
* Crop `input` to exactly `size` using sharp's attention strategy, which keeps the most
* salient part of the picture. (`smartcrop.js` is five years unmaintained — not used.)
*
* Returns the size actually produced, read back from the written file rather than echoed
* from the request, so a caller is never told a size the image does not have.
*/
export async function smartCrop(
input: string,
out: string,
size: { width: number; height: number },
opts: { signal?: AbortSignal; onProgress?: (message: string) => void } = {},
): Promise<{ path: string; width: number; height: number }> {
opts.signal?.throwIfAborted();
opts.onProgress?.("Ritaglio l'immagine…");
const info = await sharp(input)
.resize({
width: Math.max(1, Math.round(size.width)),
height: Math.max(1, Math.round(size.height)),
fit: "cover",
position: sharp.strategy.attention,
})
.toFile(out);
return { path: out, width: info.width, height: info.height };
}
/** Pixel dimensions of an image, or null when it cannot be read. */
export async function imageSize(path: string): Promise<{ width: number; height: number } | null> {
try {
const m = await sharp(path).metadata();
return m.width && m.height ? { width: m.width, height: m.height } : null;
} catch {
return null;
}
}