|
| 1 | +/** |
| 2 | + * CompositeRenderGate — measured quality gate for assembled compositions. |
| 3 | + * |
| 4 | + * The 2026-06 investor audit quantified a composite failure class that code |
| 5 | + * inspection cannot catch: stacked light-direction blends wash the frame to |
| 6 | + * near-white (dusk-bloom) or mud it toward black (reef-pulse) even when every |
| 7 | + * individual layer is good. The cure the hydra-washout investigation settled |
| 8 | + * on is RENDER MEASUREMENT: screenshot the assembled composite, read the |
| 9 | + * pixels, and act on what is actually on screen. |
| 10 | + * |
| 11 | + * The gate renders the composite headless, computes luminance statistics |
| 12 | + * in-page (a data-URL image drawn to a same-origin canvas — no native image |
| 13 | + * dependency), and when the frame measures washed-out or crushed-dark it |
| 14 | + * re-assembles ONCE with the offending blend modes demoted to 'normal' at |
| 15 | + * slightly reduced opacity — same generated art, safer compositing — keeping |
| 16 | + * whichever variant measures closer to a balanced frame. No regeneration, no |
| 17 | + * LLM calls, bounded wall-clock. |
| 18 | + */ |
| 19 | + |
| 20 | +export interface CompositeMeasure { |
| 21 | + /** Mean perceptual luminance of the frame, 0..1 (0.299R+0.587G+0.114B). */ |
| 22 | + meanLuminance: number; |
| 23 | + /** Fraction of pixels with luminance > 0.5 ("bright" content). */ |
| 24 | + brightFraction: number; |
| 25 | + /** Fraction of pixels with luminance < 0.12 (the repo's DARK threshold). */ |
| 26 | + darkFraction: number; |
| 27 | +} |
| 28 | + |
| 29 | +export type CompositeVerdict = 'ok' | 'washout' | 'too-dark'; |
| 30 | + |
| 31 | +export interface CompositeGateReport { |
| 32 | + verdict: CompositeVerdict; |
| 33 | + /** Absent only when the gate was skipped before measuring. */ |
| 34 | + measure?: CompositeMeasure; |
| 35 | + /** Present when a remediation re-assembly was attempted. */ |
| 36 | + remediation?: { |
| 37 | + /** Layer indexes whose blend mode was demoted to 'normal'. */ |
| 38 | + demotedLayers: number[]; |
| 39 | + verdictAfter: CompositeVerdict; |
| 40 | + measureAfter: CompositeMeasure; |
| 41 | + /** True when the demoted variant was kept as the final html. */ |
| 42 | + applied: boolean; |
| 43 | + }; |
| 44 | + /** Set when the gate could not run (e.g. puppeteer unavailable). */ |
| 45 | + skipped?: string; |
| 46 | +} |
| 47 | + |
| 48 | +// A frame this bright is the washout signature: dusk-bloom measured ~0.93 |
| 49 | +// mean, the acceptable composites 0.2–0.6. Symmetric crush guard mirrors the |
| 50 | +// gallery audit's calibrated DARK threshold (luminance.mjs): a very dark mean |
| 51 | +// is only a failure when there is no bright focal content either. |
| 52 | +const WASHOUT_MEAN_LUMINANCE = 0.8; |
| 53 | +const DARK_MEAN_LUMINANCE = 0.1; |
| 54 | +const DARK_MIN_BRIGHT_FRACTION = 0.02; |
| 55 | + |
| 56 | +/** Blends that can only brighten the stack — the washout drivers. */ |
| 57 | +const LIGHTENING_BLENDS = new Set(['screen', 'lighten']); |
| 58 | +/** Blends that can only darken the stack — the mud/crush drivers. */ |
| 59 | +const DARKENING_BLENDS = new Set(['multiply', 'darken']); |
| 60 | +/** Direction-dependent blends: demoted for washout only (overlay brightened |
| 61 | + * dusk-bloom past white; it rarely crushes a frame to black on its own). */ |
| 62 | +const WASHOUT_ALSO = new Set(['overlay']); |
| 63 | + |
| 64 | +export function verdictFromMeasure(measure: CompositeMeasure): CompositeVerdict { |
| 65 | + if (measure.meanLuminance >= WASHOUT_MEAN_LUMINANCE) return 'washout'; |
| 66 | + if (measure.meanLuminance <= DARK_MEAN_LUMINANCE && measure.brightFraction < DARK_MIN_BRIGHT_FRACTION) { |
| 67 | + return 'too-dark'; |
| 68 | + } |
| 69 | + return 'ok'; |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * Pick the NON-BASE layer indexes whose blend mode drives the failed verdict. |
| 74 | + * The base layer (index 0) is never demoted — it is the scene; remediating it |
| 75 | + * would replace the artwork rather than fix the compositing. |
| 76 | + */ |
| 77 | +export function layersToDemote( |
| 78 | + verdict: CompositeVerdict, |
| 79 | + blendModes: ReadonlyArray<string>, |
| 80 | +): number[] { |
| 81 | + if (verdict === 'ok') return []; |
| 82 | + const offending = verdict === 'washout' |
| 83 | + ? (mode: string) => LIGHTENING_BLENDS.has(mode) || WASHOUT_ALSO.has(mode) |
| 84 | + : (mode: string) => DARKENING_BLENDS.has(mode); |
| 85 | + const picks: number[] = []; |
| 86 | + for (let i = 1; i < blendModes.length; i++) { |
| 87 | + if (offending(blendModes[i])) picks.push(i); |
| 88 | + } |
| 89 | + return picks; |
| 90 | +} |
| 91 | + |
| 92 | +/** Opacity multiplier applied alongside a blend demotion: 'normal' at full |
| 93 | + * opacity would hide the layers underneath; keep the layer translucent. */ |
| 94 | +export const DEMOTED_OPACITY_FACTOR = 0.75; |
| 95 | + |
| 96 | +/** |
| 97 | + * Measure an assembled composite's frame statistics in headless Chrome. |
| 98 | + * Pixel math runs in-page on a same-origin canvas fed by the screenshot's |
| 99 | + * data URL, so no native image library is required. |
| 100 | + */ |
| 101 | +export async function measureCompositeHtml( |
| 102 | + html: string, |
| 103 | + options: { width?: number; height?: number; settleMs?: number } = {}, |
| 104 | +): Promise<CompositeMeasure> { |
| 105 | + const { width = 900, height = 600, settleMs = 2500 } = options; |
| 106 | + const { default: puppeteer } = await import('puppeteer'); |
| 107 | + const browser = await puppeteer.launch({ headless: true }); |
| 108 | + try { |
| 109 | + const page = await browser.newPage(); |
| 110 | + await page.setViewport({ width, height }); |
| 111 | + await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30_000 }); |
| 112 | + await new Promise((resolve) => setTimeout(resolve, settleMs)); |
| 113 | + const shot = (await page.screenshot({ encoding: 'base64' })) as string; |
| 114 | + return await page.evaluate(async (b64: string) => { |
| 115 | + const img = new Image(); |
| 116 | + img.src = `data:image/png;base64,${b64}`; |
| 117 | + await new Promise<void>((resolve, reject) => { |
| 118 | + img.onload = () => resolve(); |
| 119 | + img.onerror = () => reject(new Error('screenshot decode failed')); |
| 120 | + }); |
| 121 | + const canvas = document.createElement('canvas'); |
| 122 | + canvas.width = img.width; |
| 123 | + canvas.height = img.height; |
| 124 | + const ctx = canvas.getContext('2d'); |
| 125 | + if (!ctx) throw new Error('2d context unavailable'); |
| 126 | + ctx.drawImage(img, 0, 0); |
| 127 | + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; |
| 128 | + let sum = 0; |
| 129 | + let bright = 0; |
| 130 | + let dark = 0; |
| 131 | + const pixels = data.length / 4; |
| 132 | + for (let i = 0; i < data.length; i += 4) { |
| 133 | + const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255; |
| 134 | + sum += lum; |
| 135 | + if (lum > 0.5) bright++; |
| 136 | + if (lum < 0.12) dark++; |
| 137 | + } |
| 138 | + return { meanLuminance: sum / pixels, brightFraction: bright / pixels, darkFraction: dark / pixels }; |
| 139 | + }, shot); |
| 140 | + } finally { |
| 141 | + await browser.close(); |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +export function gateEnabled(): boolean { |
| 146 | + return process.env.SINTER_COMPOSITE_GATE !== '0'; |
| 147 | +} |
0 commit comments