52d492506b
Deploy / deploy (push) Successful in 35s
- Phone camera or photo -> WebGL shader that classifies every pixel against the sampled screen colour; colour-blind friendly (stripes, motion, words) - Views: Spill, Screen (flat colour), rough live Key, Evenness - Spill = worse of lean toward the screen hue and the green/blue despill limit, tuned on a real Alexa car-interior plate (teal screen, green bounce) - Plain-English verdict incl. widespread colour cast; tap for readouts - Pick screen / pick white (neutral reference), freeze, exposure/WB lock - Service worker caches each /set page under its own path (spill page no longer overwrites the markers page offline); link from /set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1101 lines
44 KiB
TypeScript
1101 lines
44 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
|
||
// On-set spill checker: camera (or a photo) -> WebGL shader that classifies every
|
||
// pixel against the sampled screen colour. Built for colour-blind use: severity is
|
||
// shown with brightness, stripe patterns, motion and words, never red-vs-green.
|
||
|
||
type ViewMode = "screen" | "spill" | "key" | "even";
|
||
type RGB = [number, number, number];
|
||
type Pick = "screen" | "white" | null;
|
||
type Flat = "green" | "magenta" | "white" | "blue";
|
||
// u: unit vector of the screen's hue (its colour minus grey); sat: how strongly it leans that way
|
||
type Key = { r: number; g: number; b: number; u: [number, number, number]; sat: number; mean: number; blue: boolean };
|
||
type Settings = { mode: ViewMode; screenT: number; sens: number; edges: boolean; flat: Flat };
|
||
type Level = "clean" | "low" | "medium" | "high";
|
||
type Stats = { fg: number; screen: number; lo: number; mid: number; hi: number; spread: number | null };
|
||
type Readout = { x: number; y: number; title: string; detail: string; level?: Level };
|
||
|
||
const MODES: [ViewMode, string][] = [
|
||
["spill", "Spill"],
|
||
["screen", "Screen"],
|
||
["key", "Key"],
|
||
["even", "Evenness"],
|
||
];
|
||
const FLATS: Record<Flat, [number, number, number]> = {
|
||
green: [0, 1, 0],
|
||
magenta: [1, 0, 1],
|
||
white: [1, 1, 1],
|
||
blue: [0, 0.35, 1],
|
||
};
|
||
// Tuned on a real Alexa car-interior plate (teal screen through the windows, green bounce
|
||
// on a cream interior)
|
||
const DEFAULTS: Settings = { mode: "spill", screenT: 0.55, sens: 0.04, edges: true, flat: "green" };
|
||
const SETTINGS_KEY = "ttvfx-spill-settings-v3";
|
||
// Must match CACHE in public/set/sw.js
|
||
const OFFLINE_CACHE = "ttvfx-set-v1";
|
||
const PROBE_W = 160;
|
||
// Brightness floors: below these, colour is judged in absolute terms so dark noise doesn't
|
||
// read as screen or spill
|
||
const FLOOR = 0.25;
|
||
const SPILL_FLOOR = 0.35;
|
||
// Screen pixels must point (nearly) the same hue direction as the sample
|
||
const HUE_MIN = 0.8;
|
||
// In evenness mode anything at least this strong counts as screen, so dark corners still show
|
||
const EVEN_MIN = 0.35;
|
||
|
||
// Spill thresholds: how far a colour is pushed toward the screen, as a fraction of its brightness
|
||
function levels(s: Settings) {
|
||
return { lo: s.sens, mid: s.sens * 1.75, hi: s.sens * 3.75 };
|
||
}
|
||
|
||
// n: how far a colour leans toward the screen's hue relative to its brightness (1 = as strongly
|
||
// as the screen itself); cos: how closely its hue matches; bright: brightness vs the sample.
|
||
// spill: contamination as a fraction of brightness. The worse of (a) lean toward the screen's
|
||
// own hue and (b) plain green (or blue) over the other two channels, the classic despill
|
||
// limit. Bounce off a teal screen often lands on a cream interior as plain green, so the
|
||
// screen hue alone misses it; the despill limit leaves yellows and skin alone.
|
||
function measure(r: number, g: number, b: number, key: Key) {
|
||
const m = (r + g + b) / 3;
|
||
const cr = r - m, cg = g - m, cb = b - m;
|
||
const proj = cr * key.u[0] + cg * key.u[1] + cb * key.u[2];
|
||
const excess = key.blue ? b - Math.max(r, g) : g - Math.max(r, b);
|
||
return {
|
||
n: proj / Math.max(m, FLOOR) / key.sat,
|
||
cos: proj / (Math.hypot(cr, cg, cb) + 1e-4),
|
||
bright: m / key.mean,
|
||
spill: Math.max(proj, excess) / Math.max(m, SPILL_FLOOR),
|
||
};
|
||
}
|
||
|
||
const NO_GAIN: RGB = [1, 1, 1];
|
||
|
||
// White reference: per-channel gains that make the tapped colour neutral grey, like a white
|
||
// balance pick. Undoes a camera's (or the light's) overall cast so it isn't read as spill.
|
||
function makeGain(r: number, g: number, b: number): RGB | string {
|
||
const m = (r + g + b) / 3;
|
||
if (m < 0.15) return "Too dark. Tap something brighter.";
|
||
if (Math.max(r, g, b) > 0.98) return "That's clipped. Tap something a little darker.";
|
||
if ((Math.max(r, g, b) - Math.min(r, g, b)) / m > 0.4) return "That's too colourful. Tap something white or grey.";
|
||
return [m / Math.max(r, 0.01), m / Math.max(g, 0.01), m / Math.max(b, 0.01)];
|
||
}
|
||
|
||
const isScreen = (v: { n: number; cos: number }, s: Settings) => v.n >= s.screenT && v.cos >= HUE_MIN;
|
||
|
||
function levelOf(n: number, s: Settings): Level {
|
||
const l = levels(s);
|
||
return n >= l.hi ? "high" : n >= l.mid ? "medium" : n >= l.lo ? "low" : "clean";
|
||
}
|
||
|
||
function makeKey(r: number, g: number, b: number): Key | null {
|
||
const m = (r + g + b) / 3;
|
||
const c: [number, number, number] = [r - m, g - m, b - m];
|
||
const len = Math.hypot(...c);
|
||
const sat = len / Math.max(m, FLOOR);
|
||
// Must be clearly coloured, and green or blue rather than red/orange/purple
|
||
if (sat < 0.2 || c[0] >= Math.max(c[1], c[2]) || c[0] > 0) return null;
|
||
return { r, g, b, u: [c[0] / len, c[1] / len, c[2] / len], sat, mean: Math.max(m, 0.01), blue: c[2] > c[1] };
|
||
}
|
||
|
||
// Guess the screen colour from a frame: the stronger half of clearly green (or blue) pixels.
|
||
// Returns the camera's own (unbalanced) colour; the key is made from it with the current gain.
|
||
function autoKey(d: Uint8ClampedArray, gain: RGB): RGB | null {
|
||
const pick = (blue: boolean) => {
|
||
const px: { v: number; i: number }[] = [];
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const r = (d[i] / 255) * gain[0], g = (d[i + 1] / 255) * gain[1], b = (d[i + 2] / 255) * gain[2];
|
||
const v = blue ? b - Math.max(r, g) : g - Math.max(r, b);
|
||
if (v > 0.08) px.push({ v, i });
|
||
}
|
||
return px;
|
||
};
|
||
const g = pick(false), b = pick(true);
|
||
const px = g.length >= b.length ? g : b;
|
||
if (px.length < (d.length / 4) * 0.02) return null;
|
||
px.sort((a, c) => c.v - a.v);
|
||
const top = px.slice(0, Math.ceil(px.length / 2));
|
||
let r = 0, gg = 0, bb = 0;
|
||
for (const { i } of top) {
|
||
r += d[i]; gg += d[i + 1]; bb += d[i + 2];
|
||
}
|
||
const n = top.length * 255;
|
||
return [r / n, gg / n, bb / n];
|
||
}
|
||
|
||
const keyFrom = (raw: RGB | null, gain: RGB) =>
|
||
raw ? makeKey(Math.min(raw[0] * gain[0], 1), Math.min(raw[1] * gain[1], 1), Math.min(raw[2] * gain[2], 1)) : null;
|
||
|
||
function computeStats(d: Uint8ClampedArray, w: number, h: number, key: Key, s: Settings, gain: RGB): Stats {
|
||
const sp = new Float32Array(w * h);
|
||
const scr = new Uint8Array(w * h);
|
||
const even: number[] = [];
|
||
for (let p = 0; p < w * h; p++) {
|
||
const v = measure(
|
||
Math.min((d[p * 4] / 255) * gain[0], 1),
|
||
Math.min((d[p * 4 + 1] / 255) * gain[1], 1),
|
||
Math.min((d[p * 4 + 2] / 255) * gain[2], 1),
|
||
key,
|
||
);
|
||
sp[p] = v.spill;
|
||
scr[p] = isScreen(v, s) ? 1 : 0;
|
||
if (v.n >= EVEN_MIN && v.cos >= HUE_MIN) even.push(v.bright);
|
||
}
|
||
const l = levels(s);
|
||
let fg = 0, screen = 0, lo = 0, mid = 0, hi = 0;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const p = y * w + x;
|
||
if (scr[p]) {
|
||
screen++;
|
||
continue;
|
||
}
|
||
if (s.edges) {
|
||
let near = false;
|
||
for (let dy = -1; dy <= 1 && !near; dy++)
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
const xx = x + dx, yy = y + dy;
|
||
if (xx >= 0 && yy >= 0 && xx < w && yy < h && scr[yy * w + xx]) {
|
||
near = true;
|
||
break;
|
||
}
|
||
}
|
||
if (near) continue;
|
||
}
|
||
fg++;
|
||
if (sp[p] >= l.hi) hi++;
|
||
else if (sp[p] >= l.mid) mid++;
|
||
else if (sp[p] >= l.lo) lo++;
|
||
}
|
||
}
|
||
let spread: number | null = null;
|
||
if (even.length > w * h * 0.03) {
|
||
even.sort((a, b) => a - b);
|
||
spread = even[Math.floor(even.length * 0.9)] - even[Math.floor(even.length * 0.1)];
|
||
}
|
||
return { fg: fg / (w * h), screen: screen / (w * h), lo: lo / Math.max(fg, 1), mid: mid / Math.max(fg, 1), hi: hi / Math.max(fg, 1), spread };
|
||
}
|
||
|
||
function verdict(st: Stats): { level: Level; detail: string; widespread: boolean } {
|
||
const pct = (v: number) => `${v < 0.01 && v > 0 ? "<1" : Math.round(v * 100)}%`;
|
||
const detail = `Of the subject: ${pct(st.hi)} heavy · ${pct(st.mid + st.hi)} medium or worse · ${pct(st.lo + st.mid + st.hi)} any`;
|
||
// A cast over a quarter of the subject is a problem even with no single hot spot
|
||
const widespread = st.mid + st.hi >= 0.25;
|
||
if (st.hi >= 0.01 || widespread) return { level: "high", detail, widespread };
|
||
if (st.mid + st.hi >= 0.02) return { level: "medium", detail, widespread };
|
||
if (st.lo + st.mid + st.hi >= 0.02) return { level: "low", detail, widespread };
|
||
return { level: "clean", detail, widespread };
|
||
}
|
||
|
||
// --- WebGL --------------------------------------------------------------------
|
||
|
||
const VERT = `attribute vec2 p; varying vec2 vUv;
|
||
void main() { vUv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }`;
|
||
|
||
const FRAG = `
|
||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||
precision highp float;
|
||
#else
|
||
precision mediump float;
|
||
#endif
|
||
varying vec2 vUv;
|
||
uniform sampler2D uTex;
|
||
uniform vec2 uTexel, uFit;
|
||
uniform float uKeySat, uKeyMean, uKeyBlue, uScreenT, uLo, uMid, uHi, uEdge, uMode, uTime, uDpr;
|
||
uniform vec3 uFlat, uKeyU, uGain;
|
||
|
||
// Every read goes through the white reference
|
||
vec3 px(vec2 uv) { return min(texture2D(uTex, uv).rgb * uGain, 1.0); }
|
||
|
||
// x: lean toward the screen hue relative to brightness (1 = as strong as the screen)
|
||
// y: how closely the hue matches (1 = same direction). Mirrors measure() in JS.
|
||
vec2 measure(vec3 c) {
|
||
float m = (c.r + c.g + c.b) / 3.0;
|
||
vec3 pc = c - m;
|
||
float proj = dot(pc, uKeyU);
|
||
return vec2(proj / max(m, ${FLOOR.toFixed(2)}) / uKeySat, proj / (length(pc) + 1e-4));
|
||
}
|
||
// Mirrors spill in measure(): worse of screen-hue lean and the green (or blue) despill limit
|
||
float spillOf(vec3 c) {
|
||
float m = (c.r + c.g + c.b) / 3.0;
|
||
float excess = uKeyBlue > 0.5 ? c.b - max(c.r, c.g) : c.g - max(c.r, c.b);
|
||
return max(dot(c - m, uKeyU), excess) / max(m, ${SPILL_FLOOR.toFixed(2)});
|
||
}
|
||
bool screenVal(vec2 k) { return k.x >= uScreenT && k.y >= ${HUE_MIN.toFixed(2)}; }
|
||
bool isScreen(vec2 uv) { return screenVal(measure(px(uv))); }
|
||
// Pixels right next to the screen are mixed edge pixels, not spill
|
||
bool nearScreen(vec2 uv) {
|
||
if (uEdge <= 0.0) return false;
|
||
vec2 o = uTexel * uEdge;
|
||
vec2 q = o * 0.7071;
|
||
return isScreen(uv + vec2(o.x, 0.0)) || isScreen(uv - vec2(o.x, 0.0)) ||
|
||
isScreen(uv + vec2(0.0, o.y)) || isScreen(uv - vec2(0.0, o.y)) ||
|
||
isScreen(uv + q) || isScreen(uv - q) ||
|
||
isScreen(uv + vec2(q.x, -q.y)) || isScreen(uv + vec2(-q.x, q.y));
|
||
}
|
||
|
||
void main() {
|
||
vec2 uv = (vUv - 0.5) / uFit + 0.5;
|
||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }
|
||
vec3 c = px(uv);
|
||
// Classify on a slightly softened colour so camera noise doesn't sparkle
|
||
vec2 t = uTexel * 1.5;
|
||
vec3 soft = (c * 2.0 + px(uv + vec2(t.x, 0.0)) + px(uv - vec2(t.x, 0.0)) +
|
||
px(uv + vec2(0.0, t.y)) + px(uv - vec2(0.0, t.y))) / 6.0;
|
||
vec2 k = measure(soft);
|
||
float n = k.x;
|
||
float luma = dot(c, vec3(0.299, 0.587, 0.114));
|
||
bool screen = screenVal(k);
|
||
vec2 css = gl_FragCoord.xy / uDpr;
|
||
vec3 col;
|
||
if (uMode < 0.5) {
|
||
// Screen: everything the key would pull is one flat colour
|
||
col = screen ? uFlat : vec3(luma * 0.75);
|
||
} else if (uMode < 1.5) {
|
||
// Spill: screen black, subject dim grey, spill as stripes (sparse, dense, hazard tape)
|
||
if (screen) col = vec3(0.0);
|
||
else {
|
||
col = vec3(luma * 0.55);
|
||
float sp = spillOf(soft);
|
||
if (sp >= uLo && !nearScreen(uv)) {
|
||
float d = css.x + css.y;
|
||
if (sp >= uHi) col = mod(d + uTime * 50.0, 16.0) < 8.0 ? vec3(1.0, 0.84, 0.0) : vec3(0.0);
|
||
else if (sp >= uMid) col = mod(d + uTime * 15.0, 9.0) < 4.0 ? vec3(1.0) : col * 0.5;
|
||
else if (mod(d, 14.0) < 2.0) col = vec3(0.92);
|
||
}
|
||
}
|
||
} else if (uMode < 2.5) {
|
||
// Rough key: matte from the screen-hue lean, despill by removing that lean, over a checkerboard
|
||
float a = clamp((n - 0.15) / max(uScreenT - 0.15, 0.01), 0.0, 1.0) * smoothstep(0.5, ${HUE_MIN.toFixed(2)}, k.y);
|
||
vec3 d = c - uKeyU * max(dot(c - (c.r + c.g + c.b) / 3.0, uKeyU), 0.0);
|
||
float ck = mod(floor(css.x / 16.0) + floor(css.y / 16.0), 2.0);
|
||
col = mix(d, vec3(0.22 + ck * 0.14), a);
|
||
} else if (uMode < 3.5) {
|
||
// Evenness: screen brightness in 10% bands, mid grey = the sampled colour
|
||
if (n >= ${EVEN_MIN.toFixed(2)} && k.y >= ${HUE_MIN.toFixed(2)}) {
|
||
float q = floor((soft.r + soft.g + soft.b) / 3.0 / uKeyMean * 10.0 + 0.5) / 10.0;
|
||
col = vec3(clamp(0.5 + (q - 1.0) * 1.6, 0.0, 1.0));
|
||
} else col = vec3(luma * 0.2);
|
||
} else {
|
||
// Plain picture, for sampling the screen colour
|
||
col = c;
|
||
}
|
||
gl_FragColor = vec4(col, 1.0);
|
||
}`;
|
||
|
||
type GL = { gl: WebGLRenderingContext; prog: WebGLProgram; tex: WebGLTexture; u: Record<string, WebGLUniformLocation | null> };
|
||
|
||
function initGL(canvas: HTMLCanvasElement): GL | null {
|
||
const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false });
|
||
if (!gl) return null;
|
||
const sh = (type: number, src: string) => {
|
||
const s = gl.createShader(type)!;
|
||
gl.shaderSource(s, src);
|
||
gl.compileShader(s);
|
||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? "shader");
|
||
return s;
|
||
};
|
||
const prog = gl.createProgram()!;
|
||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT));
|
||
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG));
|
||
gl.linkProgram(prog);
|
||
gl.useProgram(prog);
|
||
const buf = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
||
const loc = gl.getAttribLocation(prog, "p");
|
||
gl.enableVertexAttribArray(loc);
|
||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||
const tex = gl.createTexture()!;
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
const names = ["uTexel", "uFit", "uKeyU", "uGain", "uKeySat", "uKeyMean", "uKeyBlue", "uScreenT", "uLo", "uMid", "uHi", "uEdge", "uMode", "uTime", "uDpr", "uFlat"];
|
||
const u = Object.fromEntries(names.map((n) => [n, gl.getUniformLocation(prog, n)]));
|
||
return { gl, prog, tex, u };
|
||
}
|
||
|
||
// Where the source image sits inside the canvas box (letterboxed, "contain")
|
||
function fitRect(boxW: number, boxH: number, srcW: number, srcH: number) {
|
||
const scale = Math.min(boxW / srcW, boxH / srcH);
|
||
const w = srcW * scale, h = srcH * scale;
|
||
return { scale, w, h, x: (boxW - w) / 2, y: (boxH - h) / 2 };
|
||
}
|
||
|
||
// --- Component ----------------------------------------------------------------
|
||
|
||
type Status = "idle" | "starting" | "live" | "error";
|
||
|
||
export default function SpillChecker() {
|
||
const [status, setStatus] = useState<Status>("idle");
|
||
const [error, setError] = useState("");
|
||
const [frozen, setFrozen] = useState(false);
|
||
// Screen colour as the camera saw it; the key is derived through the white reference
|
||
const [screenRaw, setScreenRaw] = useState<RGB | null>(null);
|
||
const [gain, setGain] = useState<RGB>(NO_GAIN);
|
||
const [picking, setPicking] = useState<Pick>(null);
|
||
const [settings, setSettings] = useState<Settings>(DEFAULTS);
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [readout, setReadout] = useState<Readout | null>(null);
|
||
const [panel, setPanel] = useState(false);
|
||
const [lockable, setLockable] = useState(false);
|
||
const [locked, setLocked] = useState(false);
|
||
const [offline, setOffline] = useState(false);
|
||
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const frozenCanvas = useRef<HTMLCanvasElement | null>(null);
|
||
const probe = useRef<HTMLCanvasElement | null>(null);
|
||
const stream = useRef<MediaStream | null>(null);
|
||
const glRef = useRef<GL | null>(null);
|
||
const live = useRef({ key: null as Key | null, gain: NO_GAIN, settings: DEFAULTS, frozen: false, picking: null as Pick });
|
||
|
||
const showing = status === "live" || frozen;
|
||
const key = keyFrom(screenRaw, gain);
|
||
const whiteSet = gain !== NO_GAIN;
|
||
|
||
useEffect(() => {
|
||
live.current = { key, gain, settings, frozen, picking };
|
||
}, [key, gain, settings, frozen, picking]);
|
||
|
||
// Live readouts go stale as the camera moves; clear them after a few seconds
|
||
useEffect(() => {
|
||
if (!readout || frozen) return;
|
||
const id = window.setTimeout(() => setReadout(null), 4000);
|
||
return () => window.clearTimeout(id);
|
||
}, [readout, frozen]);
|
||
|
||
// Remember settings between visits
|
||
useEffect(() => {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? "null");
|
||
if (saved) requestAnimationFrame(() => setSettings((s) => ({ ...s, ...saved })));
|
||
} catch {}
|
||
}, []);
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
} catch {}
|
||
}, [settings]);
|
||
|
||
const source = useCallback((): { el: CanvasImageSource; w: number; h: number } | null => {
|
||
if (live.current.frozen && frozenCanvas.current) {
|
||
const c = frozenCanvas.current;
|
||
return { el: c, w: c.width, h: c.height };
|
||
}
|
||
const v = videoRef.current;
|
||
if (v && v.videoWidth) return { el: v, w: v.videoWidth, h: v.videoHeight };
|
||
return null;
|
||
}, []);
|
||
|
||
// Read a small copy of the current frame for stats and auto-key
|
||
const sampleFrame = useCallback(() => {
|
||
const src = source();
|
||
if (!src) return null;
|
||
const c = (probe.current ??= document.createElement("canvas"));
|
||
c.width = PROBE_W;
|
||
c.height = Math.max(1, Math.round((PROBE_W * src.h) / src.w));
|
||
const ctx = c.getContext("2d", { willReadFrequently: true })!;
|
||
ctx.drawImage(src.el, 0, 0, c.width, c.height);
|
||
return { data: ctx.getImageData(0, 0, c.width, c.height).data, w: c.width, h: c.height };
|
||
}, [source]);
|
||
|
||
const refreshStats = useCallback(() => {
|
||
const f = sampleFrame();
|
||
if (!f) return;
|
||
const { gain: gn } = live.current;
|
||
let k = live.current.key;
|
||
if (!k) {
|
||
const raw = autoKey(f.data, gn);
|
||
k = keyFrom(raw, gn);
|
||
if (!k) return setStats(null);
|
||
setScreenRaw(raw);
|
||
}
|
||
setStats(computeStats(f.data, f.w, f.h, k, live.current.settings, gn));
|
||
}, [sampleFrame]);
|
||
|
||
// Render loop
|
||
useEffect(() => {
|
||
if (!showing) return;
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
if (glRef.current?.gl.canvas !== canvas) {
|
||
try {
|
||
glRef.current = initGL(canvas);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
if (glRef.current?.gl.canvas !== canvas) {
|
||
requestAnimationFrame(() => {
|
||
setError("This browser can't run the checker (no WebGL).");
|
||
setStatus("error");
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const { gl, tex, u } = glRef.current;
|
||
let raf = 0;
|
||
const t0 = performance.now();
|
||
const draw = () => {
|
||
raf = requestAnimationFrame(draw);
|
||
const src = source();
|
||
const { key: k, gain: gn, settings: s, picking: pk } = live.current;
|
||
if (!src) return;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
const bw = canvas.clientWidth, bh = canvas.clientHeight;
|
||
if (canvas.width !== Math.round(bw * dpr) || canvas.height !== Math.round(bh * dpr)) {
|
||
canvas.width = Math.round(bw * dpr);
|
||
canvas.height = Math.round(bh * dpr);
|
||
}
|
||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src.el as TexImageSource);
|
||
const fit = fitRect(bw, bh, src.w, src.h);
|
||
const l = levels(s);
|
||
gl.uniform2f(u.uTexel, 1 / src.w, 1 / src.h);
|
||
gl.uniform2f(u.uFit, fit.w / bw, fit.h / bh);
|
||
gl.uniform3fv(u.uKeyU, k?.u ?? [0, 1, 0]);
|
||
gl.uniform3fv(u.uGain, gn);
|
||
gl.uniform1f(u.uKeyMean, k?.mean ?? 1);
|
||
gl.uniform1f(u.uKeyBlue, k?.blue ? 1 : 0);
|
||
// No key yet: impossible thresholds, so the picture shows plain
|
||
gl.uniform1f(u.uKeySat, k?.sat ?? 1);
|
||
gl.uniform1f(u.uScreenT, k ? s.screenT : 99);
|
||
gl.uniform1f(u.uLo, k ? l.lo : 99);
|
||
gl.uniform1f(u.uMid, l.mid);
|
||
gl.uniform1f(u.uHi, l.hi);
|
||
// Edge band ~ 1/240 of the frame width either side of the screen
|
||
gl.uniform1f(u.uEdge, s.edges ? Math.max(2, src.w / 240) : 0);
|
||
gl.uniform1f(u.uMode, k && !pk ? ["screen", "spill", "key", "even"].indexOf(s.mode) : 4);
|
||
gl.uniform1f(u.uTime, ((performance.now() - t0) / 1000) % 1000);
|
||
gl.uniform1f(u.uDpr, dpr);
|
||
gl.uniform3fv(u.uFlat, FLATS[s.flat]);
|
||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||
};
|
||
draw();
|
||
return () => cancelAnimationFrame(raf);
|
||
}, [showing, source]);
|
||
|
||
// Stats a few times a second (a small 160px copy of the frame, so cheap even when frozen)
|
||
useEffect(() => {
|
||
if (!showing) return;
|
||
const id = window.setInterval(refreshStats, 300);
|
||
return () => window.clearInterval(id);
|
||
}, [showing, refreshStats]);
|
||
|
||
// Keep the screen awake while checking
|
||
useEffect(() => {
|
||
if (status !== "live") return;
|
||
let wake: WakeLockSentinel | null = null;
|
||
const acquire = async () => {
|
||
try {
|
||
wake = (await navigator.wakeLock?.request("screen")) ?? null;
|
||
} catch {}
|
||
};
|
||
const onVisible = () => document.visibilityState === "visible" && acquire();
|
||
acquire();
|
||
document.addEventListener("visibilitychange", onVisible);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisible);
|
||
wake?.release().catch(() => {});
|
||
};
|
||
}, [status]);
|
||
|
||
// Stop the camera when leaving the page
|
||
useEffect(() => () => stream.current?.getTracks().forEach((t) => t.stop()), []);
|
||
|
||
// Offline: same service worker as /set; save this page and its assets on first visit
|
||
useEffect(() => {
|
||
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator) || !("caches" in window)) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
await navigator.serviceWorker.register("/set/sw.js", { scope: "/set/" });
|
||
await navigator.serviceWorker.ready;
|
||
const assets = performance
|
||
.getEntriesByType("resource")
|
||
.map((e) => new URL(e.name))
|
||
.filter((u) => u.origin === location.origin && /^\/(_next\/|icon|apple-icon)/.test(u.pathname))
|
||
.map((u) => u.pathname + u.search);
|
||
const cache = await caches.open(OFFLINE_CACHE);
|
||
await cache.addAll(["/set/spill/", ...new Set(assets)]);
|
||
if (!cancelled) setOffline(true);
|
||
} catch {}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const startCamera = async () => {
|
||
setError("");
|
||
if (!navigator.mediaDevices?.getUserMedia) {
|
||
setError(window.isSecureContext ? "This browser has no camera access." : "The camera needs a secure (https) page.");
|
||
setStatus("error");
|
||
return;
|
||
}
|
||
setStatus("starting");
|
||
try {
|
||
const s = await navigator.mediaDevices.getUserMedia({
|
||
audio: false,
|
||
video: { facingMode: { ideal: "environment" }, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||
});
|
||
stream.current = s;
|
||
const v = videoRef.current!;
|
||
v.srcObject = s;
|
||
await v.play();
|
||
const caps = (s.getVideoTracks()[0]?.getCapabilities?.() ?? {}) as { exposureMode?: string[]; whiteBalanceMode?: string[] };
|
||
setLockable(Boolean(caps.exposureMode?.includes("manual") || caps.whiteBalanceMode?.includes("manual")));
|
||
setFrozen(false);
|
||
setStatus("live");
|
||
} catch (e) {
|
||
const name = (e as DOMException)?.name;
|
||
setError(
|
||
name === "NotAllowedError"
|
||
? "Camera permission was refused. Allow camera access for this site in your browser settings, then try again."
|
||
: "Couldn't start the camera.",
|
||
);
|
||
setStatus("error");
|
||
}
|
||
};
|
||
|
||
// Lock exposure and white balance so the phone stops drifting while you check
|
||
const toggleLock = async () => {
|
||
const track = stream.current?.getVideoTracks()[0];
|
||
if (!track) return;
|
||
const mode = locked ? "continuous" : "manual";
|
||
const caps = (track.getCapabilities?.() ?? {}) as { exposureMode?: string[]; whiteBalanceMode?: string[] };
|
||
const c: Record<string, string> = {};
|
||
if (caps.exposureMode?.includes(mode)) c.exposureMode = mode;
|
||
if (caps.whiteBalanceMode?.includes(mode)) c.whiteBalanceMode = mode;
|
||
try {
|
||
await track.applyConstraints({ advanced: [c as MediaTrackConstraintSet] });
|
||
setLocked(!locked);
|
||
} catch {}
|
||
};
|
||
|
||
const freeze = () => {
|
||
const v = videoRef.current;
|
||
if (!v?.videoWidth) return;
|
||
const c = (frozenCanvas.current ??= document.createElement("canvas"));
|
||
c.width = v.videoWidth;
|
||
c.height = v.videoHeight;
|
||
c.getContext("2d")!.drawImage(v, 0, 0);
|
||
setFrozen(true);
|
||
};
|
||
|
||
const unfreeze = () => {
|
||
setReadout(null);
|
||
setFrozen(false);
|
||
};
|
||
|
||
const openPhoto = (file: File) => {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const scale = Math.min(1, 1920 / Math.max(img.width, img.height));
|
||
const c = (frozenCanvas.current ??= document.createElement("canvas"));
|
||
c.width = Math.round(img.width * scale);
|
||
c.height = Math.round(img.height * scale);
|
||
c.getContext("2d")!.drawImage(img, 0, 0, c.width, c.height);
|
||
URL.revokeObjectURL(url);
|
||
setReadout(null);
|
||
setScreenRaw(null);
|
||
setGain(NO_GAIN);
|
||
setStats(null);
|
||
setFrozen(true);
|
||
};
|
||
img.src = url;
|
||
};
|
||
|
||
// Tap: pick the screen colour, or read out the value under your finger
|
||
const onTap = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
const src = source();
|
||
if (!src) return;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const fit = fitRect(rect.width, rect.height, src.w, src.h);
|
||
const tx = e.clientX - rect.left, ty = e.clientY - rect.top;
|
||
const sx = (tx - fit.x) / fit.scale, sy = (ty - fit.y) / fit.scale;
|
||
if (sx < 0 || sy < 0 || sx >= src.w || sy >= src.h) return;
|
||
const R = Math.max(2, Math.round(src.w / 300));
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = R * 2 + 1;
|
||
const ctx = c.getContext("2d", { willReadFrequently: true })!;
|
||
ctx.drawImage(src.el, sx - R, sy - R, c.width, c.height, 0, 0, c.width, c.height);
|
||
const d = ctx.getImageData(0, 0, c.width, c.height).data;
|
||
let r = 0, g = 0, b = 0;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
r += d[i]; g += d[i + 1]; b += d[i + 2];
|
||
}
|
||
const n = (d.length / 4) * 255;
|
||
r /= n; g /= n; b /= n;
|
||
|
||
if (picking === "white") {
|
||
const gn = makeGain(r, g, b);
|
||
if (typeof gn === "string") {
|
||
setReadout({ x: tx, y: ty, title: "Not white", detail: gn });
|
||
return;
|
||
}
|
||
setGain(gn);
|
||
setPicking(null);
|
||
setReadout({ x: tx, y: ty, title: "White set", detail: "This now counts as neutral. Clear it in Settings." });
|
||
return;
|
||
}
|
||
if (picking === "screen" || !key) {
|
||
const k = keyFrom([r, g, b], gain);
|
||
if (!k) {
|
||
setReadout({ x: tx, y: ty, title: "Not a screen", detail: "That isn't green or blue enough. Tap the screen itself." });
|
||
return;
|
||
}
|
||
setScreenRaw([r, g, b]);
|
||
setPicking(null);
|
||
setReadout({ x: tx, y: ty, title: `${k.blue ? "Blue" : "Green"} screen set`, detail: "Everything is now measured against this colour" });
|
||
return;
|
||
}
|
||
const m = measure(Math.min(r * gain[0], 1), Math.min(g * gain[1], 1), Math.min(b * gain[2], 1), key);
|
||
const pct = Math.round(Math.max(0, m.n) * 100);
|
||
if (settings.mode === "even") {
|
||
setReadout(
|
||
m.n >= EVEN_MIN && m.cos >= HUE_MIN
|
||
? { x: tx, y: ty, title: `Screen ${Math.round(m.bright * 100)}%`, detail: "brightness of your sample (100% = same)" }
|
||
: { x: tx, y: ty, title: "Not screen", detail: "Tap the screen to read its level" },
|
||
);
|
||
return;
|
||
}
|
||
if (isScreen(m, settings)) {
|
||
setReadout({ x: tx, y: ty, title: "Screen", detail: `${pct}% screen colour. The key removes this.` });
|
||
return;
|
||
}
|
||
const lv = levelOf(m.spill, settings);
|
||
setReadout({
|
||
x: tx,
|
||
y: ty,
|
||
level: lv,
|
||
title: lv === "clean" ? "No spill" : `${lv.toUpperCase()} spill`,
|
||
detail: `Pushed ${Math.round(Math.max(0, m.spill) * 100)}% toward the screen colour`,
|
||
});
|
||
};
|
||
|
||
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setSettings((s) => ({ ...s, [k]: v }));
|
||
const v = stats && key ? verdict(stats) : null;
|
||
|
||
return (
|
||
<div className="flex h-dvh flex-col bg-black text-fg select-none">
|
||
<video ref={videoRef} playsInline muted className="pointer-events-none absolute h-px w-px opacity-0" />
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) openPhoto(f);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
|
||
{/* Top bar */}
|
||
<header className="flex items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
|
||
<Link href="/set/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
|
||
← Markers
|
||
</Link>
|
||
<h1 className="flex-1 text-center font-display text-lg font-light tracking-wide uppercase">Spill checker</h1>
|
||
<span className="w-20 text-right font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
|
||
</header>
|
||
|
||
{!showing ? (
|
||
<StartScreen status={status} error={error} onCamera={startCamera} onPhoto={() => fileRef.current?.click()} />
|
||
) : (
|
||
<>
|
||
{/* Picture */}
|
||
<div className="relative min-h-0 flex-1">
|
||
<canvas ref={canvasRef} onPointerDown={onTap} className="absolute inset-0 h-full w-full touch-none" />
|
||
{readout && (
|
||
<div className="pointer-events-none absolute inset-0">
|
||
<span
|
||
className="absolute h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_0_0_3px_black]"
|
||
style={{ left: readout.x, top: readout.y }}
|
||
/>
|
||
<div
|
||
className="absolute max-w-[80%] -translate-x-1/2 rounded-xl border-2 border-white bg-black px-4 py-3 text-center"
|
||
style={{ left: `clamp(40%, ${readout.x}px, 60%)`, top: readout.y > 160 ? readout.y - 120 : readout.y + 34 }}
|
||
>
|
||
<p className="flex items-center justify-center gap-2 text-xl font-bold uppercase">
|
||
{readout.level && readout.level !== "clean" && <Swatch level={readout.level} />}
|
||
{readout.title}
|
||
</p>
|
||
<p className="mt-0.5 text-sm text-white/80">{readout.detail}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(picking || !key) && (
|
||
<p className="pointer-events-none absolute inset-x-4 top-3 rounded-lg bg-black/80 px-4 py-3 text-center text-base font-medium">
|
||
{picking === "white" ? (
|
||
<>
|
||
Tap something white or grey
|
||
<span className="block text-sm font-normal text-white/80">
|
||
Lit by your main light, away from the screen. A grey card is best.
|
||
</span>
|
||
</>
|
||
) : key ? (
|
||
"Tap the screen to sample its colour"
|
||
) : (
|
||
"Tap the green or blue screen to start"
|
||
)}
|
||
</p>
|
||
)}
|
||
{whiteSet && !picking && (
|
||
<span className="pointer-events-none absolute top-3 left-3 rounded border border-white bg-black px-2 py-1 font-mono text-xs font-bold">
|
||
WHITE SET
|
||
</span>
|
||
)}
|
||
{frozen && (
|
||
<span className="pointer-events-none absolute top-3 right-3 rounded bg-white px-2 py-1 font-mono text-xs font-bold text-black">
|
||
FROZEN
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Verdict + legend */}
|
||
<div className="px-4 pt-3">
|
||
{settings.mode === "even" ? (
|
||
<EvenVerdict stats={key ? stats : null} />
|
||
) : v ? (
|
||
<Verdict level={v.level} detail={v.detail} widespread={v.widespread} fg={stats!.fg} />
|
||
) : (
|
||
<div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg">Waiting for a screen colour…</div>
|
||
)}
|
||
<Legend mode={settings.mode} flat={settings.flat} />
|
||
</div>
|
||
|
||
{/* Mode buttons */}
|
||
<div className="grid grid-cols-4 gap-1.5 px-4 pt-3" role="radiogroup" aria-label="View">
|
||
{MODES.map(([m, label]) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={settings.mode === m}
|
||
onClick={() => {
|
||
set("mode", m);
|
||
setReadout(null);
|
||
}}
|
||
className={`min-h-12 rounded-lg text-sm font-semibold ${settings.mode === m ? "bg-white text-black" : "bg-white/10 text-fg"}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="grid grid-cols-5 gap-1.5 px-4 pt-1.5 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||
<Action onClick={() => (frozen ? (status === "live" ? unfreeze() : startCamera()) : freeze())}>
|
||
{frozen ? "Live" : "Freeze"}
|
||
</Action>
|
||
<Action onClick={() => setPicking((p) => (p === "screen" ? null : "screen"))} active={picking === "screen"}>
|
||
Pick screen
|
||
</Action>
|
||
<Action onClick={() => setPicking((p) => (p === "white" ? null : "white"))} active={picking === "white"}>
|
||
Pick white
|
||
</Action>
|
||
<Action onClick={() => fileRef.current?.click()}>Photo</Action>
|
||
<Action onClick={() => setPanel((p) => !p)} active={panel}>
|
||
Settings
|
||
</Action>
|
||
</div>
|
||
|
||
{panel && (
|
||
<SettingsPanel
|
||
settings={settings}
|
||
set={set}
|
||
lockable={lockable && status === "live"}
|
||
locked={locked}
|
||
onLock={toggleLock}
|
||
onReset={() => setSettings({ ...DEFAULTS, mode: settings.mode, flat: settings.flat })}
|
||
whiteSet={whiteSet}
|
||
onClearWhite={() => setGain(NO_GAIN)}
|
||
onClose={() => setPanel(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StartScreen({ status, error, onCamera, onPhoto }: { status: Status; error: string; onCamera: () => void; onPhoto: () => void }) {
|
||
return (
|
||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-4 px-4 pb-10">
|
||
<p className="text-lg leading-relaxed">
|
||
Point your phone at the green screen setup. Spill shows up as{" "}
|
||
<strong>stripes</strong>, with a plain-English verdict underneath. No colour judgement needed.
|
||
</p>
|
||
<ol className="list-decimal space-y-1 pl-5 text-sm leading-relaxed text-muted">
|
||
<li>Start the camera and tap the screen once to sample its colour.</li>
|
||
<li>Frame the car or subject. Read the verdict at the bottom.</li>
|
||
<li>Tap anything for a readout. Freeze to look closer.</li>
|
||
</ol>
|
||
{error && <p className="rounded-lg border-2 border-white px-4 py-3 text-sm">{error}</p>}
|
||
<button
|
||
type="button"
|
||
onClick={onCamera}
|
||
disabled={status === "starting"}
|
||
className="min-h-14 rounded-xl bg-white text-lg font-semibold text-black disabled:opacity-60"
|
||
>
|
||
{status === "starting" ? "Starting camera…" : "Start camera"}
|
||
</button>
|
||
<button type="button" onClick={onPhoto} className="min-h-14 rounded-xl bg-white/10 text-lg font-semibold">
|
||
Check a photo
|
||
</button>
|
||
<p className="text-xs leading-relaxed text-muted">
|
||
A phone camera is not the production camera: auto exposure, white balance and compression all shift the
|
||
numbers. Use this to answer “is this a problem?”, not as a measurement.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Action({ children, onClick, active }: { children: React.ReactNode; onClick: () => void; active?: boolean }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`min-h-12 rounded-lg border text-sm ${active ? "border-white bg-white/20" : "border-white/20 bg-transparent"}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// CSS versions of the shader's stripe patterns
|
||
const PATTERN: Record<Exclude<Level, "clean">, React.CSSProperties> = {
|
||
low: { background: "repeating-linear-gradient(45deg, #ebebeb 0 2px, #555 2px 14px)" },
|
||
medium: { background: "repeating-linear-gradient(45deg, #fff 0 4px, #2a2a2a 4px 9px)" },
|
||
high: { background: "repeating-linear-gradient(45deg, #ffd600 0 8px, #000 8px 16px)" },
|
||
};
|
||
|
||
function Swatch({ level }: { level: Exclude<Level, "clean"> }) {
|
||
return <span className="inline-block h-6 w-9 shrink-0 rounded border border-white/60" style={PATTERN[level]} />;
|
||
}
|
||
|
||
function Verdict({ level, detail, widespread, fg }: { level: Level; detail: string; widespread: boolean; fg: number }) {
|
||
if (fg < 0.02) {
|
||
return <div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg">Only screen in shot. Frame the subject.</div>;
|
||
}
|
||
const big = { clean: "No spill problem", low: "Low spill", medium: "Medium spill", high: "High spill" }[level];
|
||
const sub = {
|
||
clean: "Nothing worth worrying about",
|
||
low: "Normal. Comp will handle this",
|
||
medium: "Worth fixing if it's easy",
|
||
high: widespread ? "Colour cast across the whole subject. Fix before shooting" : "Fix this before shooting",
|
||
}[level];
|
||
return (
|
||
<div
|
||
className={`flex items-center gap-3 rounded-xl px-4 py-2.5 ${level === "high" ? "border-4 border-[#ffd600] bg-black" : level === "medium" ? "border-2 border-white bg-white text-black" : "border-2 border-white/30"}`}
|
||
role="status"
|
||
>
|
||
{level === "clean" ? (
|
||
<span className="flex h-8 w-11 shrink-0 items-center justify-center text-2xl">✓</span>
|
||
) : (
|
||
<span className="h-8 w-11 shrink-0 rounded border border-white/60" style={PATTERN[level]} />
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="text-xl leading-tight font-bold uppercase">{big}</p>
|
||
<p className="text-sm font-medium">{sub}</p>
|
||
<p className="truncate text-xs opacity-75">{detail}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EvenVerdict({ stats }: { stats: Stats | null }) {
|
||
if (!stats || stats.spread == null) {
|
||
return <div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg">Point at the screen</div>;
|
||
}
|
||
const pct = Math.round(stats.spread * 100);
|
||
const [big, sub] =
|
||
pct <= 15 ? ["Even", "Screen lighting looks good"] : pct <= 30 ? ["A bit uneven", "Usually fine for a key"] : ["Uneven", "Look for hot spots and dark corners"];
|
||
return (
|
||
<div className={`rounded-xl px-4 py-2.5 ${pct > 30 ? "border-4 border-white" : "border-2 border-white/30"}`} role="status">
|
||
<p className="text-xl leading-tight font-bold uppercase">{big}</p>
|
||
<p className="text-sm font-medium">{sub}</p>
|
||
<p className="text-xs opacity-75">Most of the screen is within a {pct}% brightness range</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Legend({ mode, flat }: { mode: ViewMode; flat: Flat }) {
|
||
const item = (swatch: React.ReactNode, label: string) => (
|
||
<span className="flex items-center gap-1.5">
|
||
{swatch}
|
||
{label}
|
||
</span>
|
||
);
|
||
const box = (style: React.CSSProperties) => <span className="inline-block h-4 w-6 rounded-sm border border-white/50" style={style} />;
|
||
let items: React.ReactNode[] = [];
|
||
if (mode === "spill") {
|
||
items = [
|
||
item(box({ background: "#000" }), "Screen"),
|
||
item(box(PATTERN.low), "Low"),
|
||
item(box(PATTERN.medium), "Medium"),
|
||
item(box(PATTERN.high), "High"),
|
||
];
|
||
} else if (mode === "screen") {
|
||
const [r, g, b] = FLATS[flat].map((c) => Math.round(c * 255));
|
||
items = [item(box({ background: `rgb(${r},${g},${b})` }), "Keyed out (screen)"), item(box({ background: "#777" }), "Kept")];
|
||
} else if (mode === "key") {
|
||
items = [
|
||
item(box({ background: "repeating-conic-gradient(#383838 0 25%, #5c5c5c 0 50%) 0 0/8px 8px" }), "Removed"),
|
||
item(box({ background: "#999" }), "Kept, spill removed"),
|
||
];
|
||
} else {
|
||
items = [
|
||
item(box({ background: "#000" }), "−30% dark"),
|
||
item(box({ background: "#808080" }), "Your sample"),
|
||
item(box({ background: "#fff" }), "+30% hot"),
|
||
];
|
||
}
|
||
return <div className="flex flex-wrap gap-x-4 gap-y-1 pt-2 text-xs text-white/80">{items.map((n, i) => <span key={i}>{n}</span>)}</div>;
|
||
}
|
||
|
||
function SettingsPanel({
|
||
settings,
|
||
set,
|
||
lockable,
|
||
locked,
|
||
onLock,
|
||
onReset,
|
||
whiteSet,
|
||
onClearWhite,
|
||
onClose,
|
||
}: {
|
||
settings: Settings;
|
||
set: <K extends keyof Settings>(k: K, v: Settings[K]) => void;
|
||
lockable: boolean;
|
||
locked: boolean;
|
||
onLock: () => void;
|
||
onReset: () => void;
|
||
whiteSet: boolean;
|
||
onClearWhite: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-x-0 bottom-0 z-10 max-h-[70dvh] overflow-y-auto rounded-t-2xl border-t border-white/20 bg-ink-2 px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">Settings</h2>
|
||
<button type="button" onClick={onClose} className="min-h-11 rounded-lg bg-white/10 px-4 text-sm">
|
||
Done
|
||
</button>
|
||
</div>
|
||
|
||
<Slider
|
||
label="Spill sensitivity"
|
||
hint="Higher finds fainter spill"
|
||
min={0.02}
|
||
max={0.08}
|
||
step={0.005}
|
||
// Inverted: sliding right lowers the threshold
|
||
value={0.1 - settings.sens}
|
||
display={`${(settings.sens * 100).toFixed(1)}% threshold`}
|
||
onChange={(v) => set("sens", Math.round((0.1 - v) * 1000) / 1000)}
|
||
/>
|
||
<Slider
|
||
label="Screen tolerance"
|
||
hint="How strong a colour counts as screen. Lower it if shadowed screen shows as spill"
|
||
min={0.35}
|
||
max={0.85}
|
||
step={0.05}
|
||
value={settings.screenT}
|
||
display={`${Math.round(settings.screenT * 100)}%`}
|
||
onChange={(v) => set("screenT", v)}
|
||
/>
|
||
|
||
<label className="mt-5 flex min-h-11 items-center justify-between gap-4">
|
||
<span>
|
||
<span className="block">Ignore subject edges</span>
|
||
<span className="block text-xs text-muted">Edge pixels always mix with the screen</span>
|
||
</span>
|
||
<input type="checkbox" checked={settings.edges} onChange={(e) => set("edges", e.target.checked)} className="h-6 w-6 accent-white" />
|
||
</label>
|
||
|
||
<p className="mt-5 text-sm">Screen colour in Screen view</p>
|
||
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||
{(Object.keys(FLATS) as Flat[]).map((f) => {
|
||
const [r, g, b] = FLATS[f].map((c) => Math.round(c * 255));
|
||
return (
|
||
<button
|
||
key={f}
|
||
type="button"
|
||
onClick={() => set("flat", f)}
|
||
className={`flex min-h-11 items-center justify-center gap-2 rounded-lg text-sm capitalize ${settings.flat === f ? "bg-white text-black" : "bg-white/10"}`}
|
||
>
|
||
<span className="h-4 w-4 rounded-sm border border-black/40" style={{ background: `rgb(${r},${g},${b})` }} />
|
||
{f}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{lockable && (
|
||
<button type="button" onClick={onLock} className={`mt-5 min-h-12 w-full rounded-lg text-sm ${locked ? "bg-white text-black" : "bg-white/10"}`}>
|
||
{locked ? "Exposure & white balance locked: tap to unlock" : "Lock exposure & white balance"}
|
||
</button>
|
||
)}
|
||
|
||
{whiteSet && (
|
||
<button type="button" onClick={onClearWhite} className="mt-3 min-h-11 w-full rounded-lg bg-white/10 text-sm">
|
||
Clear white reference
|
||
</button>
|
||
)}
|
||
|
||
<button type="button" onClick={onReset} className="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm">
|
||
Reset thresholds
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Slider({
|
||
label,
|
||
hint,
|
||
min,
|
||
max,
|
||
step,
|
||
value,
|
||
display,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
hint: string;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
value: number;
|
||
display: string;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<label className="mt-5 block">
|
||
<span className="flex justify-between text-sm">
|
||
<span>{label}</span>
|
||
<span className="font-mono text-muted">{display}</span>
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
className="mt-2 w-full accent-white"
|
||
/>
|
||
<span className="block text-xs text-muted">{hint}</span>
|
||
</label>
|
||
);
|
||
}
|