import { useCallback, useEffect, useRef, useState } from 'react'; import { kelvinToRGB } from '../../shared/utils/colorUtils'; import { useI18n } from '../i18n/I18nProvider'; // The histogram overlay: a draggable frame over the photo, reading the render // the user is actually looking at — so every knob shows up in it, exactly like // the panel in Lightroom. No dependency: one downscaled canvas pass to bin the // pixels, then four SVG paths. const BINS = 256; // the whole 0..255 ramp, absolute black to absolute white // The sample is downscaled before it is binned: a histogram is a distribution, // not a measurement, and 320px of the render tells the same story as 3200. const SAMPLE = 320; const W = 256; const H = 96; export interface Histogram { lum: number[]; r: number[]; g: number[]; b: number[]; } // Bins `url` (a blob URL of the rendered preview) into 256 buckets per channel. // `alive()` is checked after the await so a stale render cannot overwrite a // newer one. export async function readHistogram(url: string, alive: () => boolean): Promise { const res = await fetch(url); const bitmap = await createImageBitmap(await res.blob(), { resizeWidth: SAMPLE, resizeQuality: 'low' }); if (!alive()) { bitmap.close(); return null; } const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { bitmap.close(); return null; } ctx.drawImage(bitmap, 0, 0); bitmap.close(); const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); const lum = new Array(BINS).fill(0); const r = new Array(BINS).fill(0); const g = new Array(BINS).fill(0); const b = new Array(BINS).fill(0); for (let i = 0; i < data.length; i += 4) { r[data[i]]++; g[data[i + 1]]++; b[data[i + 2]]++; // The shaders' own luma weights, so the grey curve matches what they see. lum[(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2] + 0.5) | 0]++; } return { lum, r, g, b }; } // AUTO's reach, in stops — the same travel the EXPOSURE knob has (±10 units at // 0.25 EV, see colorUtils.EV_PER_UNIT), so the knob can always express what the // button decided instead of the number being clipped by the UI. export const AUTO_EV_MAX = 2.5; // Lightroom's Auto, as far as a browser can take it: the average luminance off // the binned histogram, asked for the number of stops that puts it on the 0.48 a // grey card lands at in a normal exposure. log2 of the ratio IS that number of // stops, and the engine's EXPOSURE_SKSL takes exactly such an EV value — so this // hands back stops, not a pixel gain. // // `lum` is readHistogram's 256 bins, so bin i sits at i/255 of the ramp. The // guard keeps a black frame from dividing by zero (and from asking for infinity); // the black frame then gets the full +2.5. export function autoExposureStops(lum: number[]): number { let sum = 0; let weighted = 0; for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; } const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0); const stops = Math.log2(0.48 / avg); return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops)); } // How far AUTO may push HIGHLIGHT and SHADOW, in the sliders' own units: the // whole ±10 ruler, so a frame that really is clipped (or really is crushed) // really does come back. It was 5 when a unit of HIGHLIGHT or SHADOW moved its // knot a whole anchor; both now ride HALF an anchor (see a1/a3 in toneShader.ts), // so 5 would have halved AUTO's own correction with the knob's reach. At 10 it // draws the knot as far as the old number did — HIGHLIGHT to 0.625, SHADOW to // 0.375 — where the quarter beside it is still 0.5 of its own slope and never // flat — and the knob then reports the number AUTO chose, the way the EV knob // does. const AUTO_TONE_MAX = 10; // The value `p` of the way up the binned ramp (0.99 for the top 1% of pixels): // walk the cumulative count to the first bin that passes `p * total`, and report // where that bin sits. Bin i holds every pixel worth exactly i, so it sits at // i/(len-1) — the same 0..1 the exposure math uses. Nearest rank, not // interpolated: a bin is 1/255 wide and the thresholds here are 0.1 apart. export function lumaPercentile(lum: number[], p: number): number { const total = lum.reduce((a, n) => a + n, 0); if (total <= 0) return 0; const target = p * total; let seen = 0; for (let i = 0; i < lum.length; i++) { seen += lum[i]; if (lum[i] > 0 && seen >= target) return i / (lum.length - 1); } return 1; } // AUTO's Highlight/Shadow, as Snapseed decides them: the ends are read at the // top and bottom 1% rather than at the average, so a small blown window pulls // the highlights down while the rest of the frame stays put. Only crossed // thresholds move a knob — p99 above 0.9 asks for negative HIGHLIGHT (recover), // p01 below 0.02 for positive SHADOW (open up) — and the ramp reaches // AUTO_TONE_MAX at a frame that is entirely clipped or entirely black. // // ponytail: one linear ramp per end, no scene analysis. Add a curve (or weight // by how much of the frame is clipped) when AUTO starts overshooting on scenes // with a genuine specular. export function autoTone(lum: number[]): { highlight: number; shadow: number } { // Nothing sampled at all (an empty canvas) reads as a frame on the floor, // which would open the shadows the whole way; leave the knobs where they are. if (!lum.some((n) => n > 0)) return { highlight: 0, shadow: 0 }; const p99 = lumaPercentile(lum, 0.99); const p01 = lumaPercentile(lum, 0.01); const highlight = p99 > 0.9 ? -Math.round(((p99 - 0.9) / 0.1) * AUTO_TONE_MAX) : 0; const shadow = p01 < 0.02 ? Math.round(((0.02 - p01) / 0.02) * AUTO_TONE_MAX) : 0; return { highlight, shadow }; } // AUTO's White Balance, gray-world with green as the anchor: the gain that puts // the three channel means on top of each other is G/avgR and G/avgB. Those are // multipliers on LINEAR light (shared/utils/colorUtils takes its ratios there), // so the sRGB means off the bins are linearised first. export function autoWhiteBalance( r: number[], g: number[], b: number[], ): { temperature: number; tint: number } { const mean = (bins: number[]) => { let sum = 0; let weighted = 0; for (let i = 0; i < bins.length; i++) { sum += bins[i]; weighted += (i / (bins.length - 1)) * bins[i]; } const srgb = sum > 0 ? weighted / sum : 0; return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4); }; const R = mean(r); const G = mean(g); const B = mean(b); // A frame with a dead channel has no cast to read — leave the ruler alone. if (R <= 0 || G <= 0 || B <= 0) return { temperature: 5500, tint: 0 }; // The pair of gains the frame is asking for, against G. const wantR = G / R; const wantB = G / B; // TEMPERATURE and TINT are the two knobs that BE this gain: scanning what the // engine would apply (kelvinToRGB, tint's ±0.08 on the green↔magenta axis) // and keeping the closest pair needs no inverse — and cannot drift from the // render, because it asks the renderer's own function. 76 x 21 pairs is a // tenth of a millisecond. Luma normalisation is skipped: it scales all three // channels alike, so it cancels in the ratios being matched. let best = { temperature: 5500, tint: 0 }; let bestErr = Infinity; for (let k = 2500; k <= 10000; k += 100) { const gain = kelvinToRGB(k); for (let tint = -10; tint <= 10; tint++) { const magenta = (tint / 10) * 0.08; const gr = (gain.r * (1 + magenta)) / (gain.g * (1 - magenta)); const gb = (gain.b * (1 + magenta)) / (gain.g * (1 - magenta)); const err = (gr - wantR) ** 2 + (gb - wantB) ** 2; if (err < bestErr) { bestErr = err; best = { temperature: k, tint }; } } } return best; } // One channel across the full width of the ramp. `close` also draws the floor, // which is only wanted for the filled luminance curve. // // The height is log, not linear: a blown sky can put a third of a frame in one // bin (measured on a real P1010256.JPG: 34% of pixels at bin 255), and under a // linear scale that one bar owns 91% of the panel and every tone below it — // the whole photo — reads as a flat line along the floor. log1p keeps an empty // bin exactly on the floor while the tail stays readable. function curve(bins: number[], max: number, close: boolean): string { const step = W / (BINS - 1); const top = Math.log1p(max); let d = ''; for (let i = 0; i < BINS; i++) { d += `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - (Math.log1p(bins[i]) / top) * H).toFixed(1)} `; } return close ? `${d}L${W} ${H} L0 ${H} Z` : d; } export function Histogram({ url, onClose }: { url: string; onClose: () => void }) { const { t } = useI18n(); const [data, setData] = useState(null); const [pos, setPos] = useState<{ x: number; y: number }>({ x: 12, y: 12 }); const [dragging, setDragging] = useState(false); const boxRef = useRef(null); const dragRef = useRef<{ px: number; py: number; x: number; y: number } | null>(null); // Re-read on every repaint of the render. useEffect(() => { let live = true; readHistogram(url, () => live) .then((h) => { if (live && h) setData(h); }) .catch(() => undefined); return () => { live = false; }; }, [url]); // The frame lives inside the photo's own box, so the drag maths is in that // box's coordinates and needs no reference to the stage. const clamp = useCallback((x: number, y: number) => { const wrap = boxRef.current?.parentElement; const box = boxRef.current; if (!wrap || !box) return { x, y }; const b = wrap.getBoundingClientRect(); const m = box.getBoundingClientRect(); const hi = (outer: number, inner: number) => Math.max(8, outer - inner - 8); return { x: Math.min(Math.max(8, x), hi(b.width, m.width)), y: Math.min(Math.max(8, y), hi(b.height, m.height)) }; }, []); // The photo's own box is what the frame is parked against, and it shrinks // when a column opens beside the stage — not only when the window does. So // the frame watches that box, not the window, and is pulled back inside it. useEffect(() => { const onResize = () => setPos((p) => (p ? clamp(p.x, p.y) : p)); window.addEventListener('resize', onResize); const wrap = boxRef.current?.parentElement; const ro = wrap ? new ResizeObserver(onResize) : null; ro?.observe(wrap as Element); return () => { window.removeEventListener('resize', onResize); ro?.disconnect(); }; }, [clamp]); const start = (e: React.PointerEvent) => { if (e.button !== 0) return; // The × sits in this head but is a button, not a grip: preventDefault here // swallows the click that follows the press (measured — a real click on it // did nothing, while el.click() worked), so the press is left to it. if ((e.target as HTMLElement).closest('.hist-close')) return; e.preventDefault(); e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { px: e.clientX, py: e.clientY, x: pos?.x ?? 0, y: pos?.y ?? 0 }; setDragging(true); }; const move = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; e.preventDefault(); setPos(clamp(d.x + (e.clientX - d.px), d.y + (e.clientY - d.py))); }; const end = (e: React.PointerEvent) => { if (!dragRef.current) return; dragRef.current = null; setDragging(false); if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); }; // One scale for all four curves, so their heights are comparable — the log // scale curve() draws on, so a blown sky cannot squash the rest. const max = Math.max(1, ...(data ? [...data.lum, ...data.r, ...data.g, ...data.b] : [1])); // The shape is parked in the photo's top-left corner as soon as it appears — // that is where the eye already is when a frame lands — and the user drags it // wherever they want it from there. return (
{t('hist.title')}
{data ? ( ) : (
)}
0 255
); }