319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
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<Histogram | null> {
|
||
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<number>(BINS).fill(0);
|
||
const r = new Array<number>(BINS).fill(0);
|
||
const g = new Array<number>(BINS).fill(0);
|
||
const b = new Array<number>(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<Histogram | null>(null);
|
||
const [pos, setPos] = useState<{ x: number; y: number }>({ x: 12, y: 12 });
|
||
const [dragging, setDragging] = useState(false);
|
||
const boxRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||
if (e.button !== 0) 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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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 (
|
||
<div
|
||
className={`hist${dragging ? ' dragging' : ''}`}
|
||
data-key="histogram"
|
||
ref={boxRef}
|
||
style={{ left: pos.x, top: pos.y }}
|
||
>
|
||
<div
|
||
className="hist-head"
|
||
data-key="histogram-drag"
|
||
title={t('hist.drag')}
|
||
onPointerDown={start}
|
||
onPointerMove={move}
|
||
onPointerUp={end}
|
||
onPointerCancel={end}
|
||
>
|
||
<span className="hist-title">{t('hist.title')}</span>
|
||
<button type="button" className="hist-close" data-key="histogram-close" aria-label={t('hist.hide')} onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
{data ? (
|
||
<svg className="hist-plot" data-key="histogram-plot" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img">
|
||
<path className="hist-lum" data-key="histogram-lum" d={curve(data.lum, max, true)} />
|
||
<path className="hist-ch hist-r" data-key="histogram-r" d={curve(data.r, max, false)} />
|
||
<path className="hist-ch hist-g" data-key="histogram-g" d={curve(data.g, max, false)} />
|
||
<path className="hist-ch hist-b" data-key="histogram-b" d={curve(data.b, max, false)} />
|
||
</svg>
|
||
) : (
|
||
<div className="hist-plot" data-key="histogram-plot" />
|
||
)}
|
||
<div className="hist-axis">
|
||
<span>0</span>
|
||
<span>255</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|