Files
ttvfx/components/set/SpillChecker.tsx
T
twotalesanimation 52d492506b
Deploy / deploy (push) Successful in 35s
Add /set/spill: on-set green screen spill checker
- 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>
2026-10-07 11:55:24 +02:00

1101 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 &ldquo;is this a problem?&rdquo;, 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>
);
}