From 52d492506b23b0099af132a1e5fd1318f53ae6dc Mon Sep 17 00:00:00 2001
From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com>
Date: Wed, 7 Oct 2026 11:55:24 +0200
Subject: [PATCH] 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
---
README.md | 10 +
app/set/spill/page.tsx | 25 +
components/set/MarkerPicker.tsx | 12 +
components/set/SpillChecker.tsx | 1100 +++++++++++++++++++++++++++++++
public/set/sw.js | 14 +-
5 files changed, 1156 insertions(+), 5 deletions(-)
create mode 100644 app/set/spill/page.tsx
create mode 100644 components/set/SpillChecker.tsx
diff --git a/README.md b/README.md
index f36becb..66a38cb 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,16 @@ markers_laptop_1920x1080.png # name is optional
Kinds: `phone`, `tablet`, `laptop`, `monitor`. Any other PNG shows under "Other".
Bump `CACHE` in `public/set/sw.js` when replacing an existing image so phones fetch the new one.
+## On-set spill checker (`/set/spill`)
+
+Point a phone camera (or load a photo) at a green or blue screen setup. A WebGL shader
+classifies every pixel against the sampled screen colour and shows spill as stripe
+patterns plus a plain-English verdict. It is designed for colour-blind use, so it never
+relies on red vs green. Views: Spill, Screen (keyed area as one flat colour), Key (rough
+live key with despill) and Evenness (screen brightness bands). "Pick white" sets a
+neutral reference, like a white balance pick. The code is in `components/set/SpillChecker.tsx`;
+thresholds are tuned on a real Alexa car-interior plate. The camera needs https.
+
## Structure
```
diff --git a/app/set/spill/page.tsx b/app/set/spill/page.tsx
new file mode 100644
index 0000000..04c85c6
--- /dev/null
+++ b/app/set/spill/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata, Viewport } from "next";
+import SpillChecker from "@/components/set/SpillChecker";
+
+// On-set green/blue screen spill checker. Like /set, not linked from the site and
+// kept out of search results and the sitemap.
+export const metadata: Metadata = {
+ title: "Spill checker",
+ description: "Two Tales VFX on-set green screen spill checker.",
+ robots: { index: false, follow: false },
+ manifest: "/set/manifest.webmanifest",
+ appleWebApp: { capable: true, title: "Spill", statusBarStyle: "black-translucent" },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#000000",
+ viewportFit: "cover",
+};
+
+export default function SpillPage() {
+ return (
+
+
+
+ );
+}
diff --git a/components/set/MarkerPicker.tsx b/components/set/MarkerPicker.tsx
index fa519be..9a0cc79 100644
--- a/components/set/MarkerPicker.tsx
+++ b/components/set/MarkerPicker.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+import Link from "next/link";
import type { Kind, MarkerExtra, MarkerScreen } from "@/lib/markers";
type Mode = "full" | "edge";
@@ -362,6 +363,17 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
+
+
+ Spill checker
+ Point your phone at the green screen setup to find spill
+
+ →
+
+
Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake
diff --git a/components/set/SpillChecker.tsx b/components/set/SpillChecker.tsx
new file mode 100644
index 0000000..879a511
--- /dev/null
+++ b/components/set/SpillChecker.tsx
@@ -0,0 +1,1100 @@
+"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 = {
+ 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 };
+
+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("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(null);
+ const [gain, setGain] = useState(NO_GAIN);
+ const [picking, setPicking] = useState(null);
+ const [settings, setSettings] = useState(DEFAULTS);
+ const [stats, setStats] = useState(null);
+ const [readout, setReadout] = useState(null);
+ const [panel, setPanel] = useState(false);
+ const [lockable, setLockable] = useState(false);
+ const [locked, setLocked] = useState(false);
+ const [offline, setOffline] = useState(false);
+
+ const canvasRef = useRef(null);
+ const videoRef = useRef(null);
+ const fileRef = useRef(null);
+ const frozenCanvas = useRef(null);
+ const probe = useRef(null);
+ const stream = useRef(null);
+ const glRef = useRef(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 = {};
+ 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) => {
+ 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: K, v: Settings[K]) => setSettings((s) => ({ ...s, [k]: v }));
+ const v = stats && key ? verdict(stats) : null;
+
+ return (
+
+
+ {
+ const f = e.target.files?.[0];
+ if (f) openPhoto(f);
+ e.target.value = "";
+ }}
+ />
+
+ {/* Top bar */}
+
+
+ ← Markers
+
+
+ {picking === "white" ? (
+ <>
+ Tap something white or grey
+
+ Lit by your main light, away from the screen. A grey card is best.
+
+ >
+ ) : key ? (
+ "Tap the screen to sample its colour"
+ ) : (
+ "Tap the green or blue screen to start"
+ )}
+
+ Point your phone at the green screen setup. Spill shows up as{" "}
+ stripes, with a plain-English verdict underneath. No colour judgement needed.
+
+
+
Start the camera and tap the screen once to sample its colour.
+
Frame the car or subject. Read the verdict at the bottom.
+
Tap anything for a readout. Freeze to look closer.
+
+ {error &&
{error}
}
+
+
+
+ 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.
+