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 ( +
+
+ ); +} + +function StartScreen({ status, error, onCamera, onPhoto }: { status: Status; error: string; onCamera: () => void; onPhoto: () => void }) { + return ( +
+

+ Point your phone at the green screen setup. Spill shows up as{" "} + stripes, with a plain-English verdict underneath. No colour judgement needed. +

+
    +
  1. Start the camera and tap the screen once to sample its colour.
  2. +
  3. Frame the car or subject. Read the verdict at the bottom.
  4. +
  5. Tap anything for a readout. Freeze to look closer.
  6. +
+ {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. +

+
+ ); +} + +function Action({ children, onClick, active }: { children: React.ReactNode; onClick: () => void; active?: boolean }) { + return ( + + ); +} + +// CSS versions of the shader's stripe patterns +const PATTERN: Record, 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 }) { + return ; +} + +function Verdict({ level, detail, widespread, fg }: { level: Level; detail: string; widespread: boolean; fg: number }) { + if (fg < 0.02) { + return
Only screen in shot. Frame the subject.
; + } + 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 ( +
+ {level === "clean" ? ( + ✓ + ) : ( + + )} +
+

{big}

+

{sub}

+

{detail}

+
+
+ ); +} + +function EvenVerdict({ stats }: { stats: Stats | null }) { + if (!stats || stats.spread == null) { + return
Point at the screen
; + } + 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 ( +
30 ? "border-4 border-white" : "border-2 border-white/30"}`} role="status"> +

{big}

+

{sub}

+

Most of the screen is within a {pct}% brightness range

+
+ ); +} + +function Legend({ mode, flat }: { mode: ViewMode; flat: Flat }) { + const item = (swatch: React.ReactNode, label: string) => ( + + {swatch} + {label} + + ); + const box = (style: React.CSSProperties) => ; + 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
{items.map((n, i) => {n})}
; +} + +function SettingsPanel({ + settings, + set, + lockable, + locked, + onLock, + onReset, + whiteSet, + onClearWhite, + onClose, +}: { + settings: Settings; + set: (k: K, v: Settings[K]) => void; + lockable: boolean; + locked: boolean; + onLock: () => void; + onReset: () => void; + whiteSet: boolean; + onClearWhite: () => void; + onClose: () => void; +}) { + return ( +
+
+

Settings

+ +
+ + set("sens", Math.round((0.1 - v) * 1000) / 1000)} + /> + set("screenT", v)} + /> + + + +

Screen colour in Screen view

+
+ {(Object.keys(FLATS) as Flat[]).map((f) => { + const [r, g, b] = FLATS[f].map((c) => Math.round(c * 255)); + return ( + + ); + })} +
+ + {lockable && ( + + )} + + {whiteSet && ( + + )} + + +
+ ); +} + +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 ( + + ); +} diff --git a/public/set/sw.js b/public/set/sw.js index 907a114..4c9f43a 100644 --- a/public/set/sw.js +++ b/public/set/sw.js @@ -1,4 +1,4 @@ -// Offline cache for /set (screen markers). Crews often have no signal on set: +// Offline cache for /set (screen markers, spill checker). Crews often have no signal on set: // once the page has loaded once, it and every marker image work offline. // Must match OFFLINE_CACHE in components/set/MarkerPicker.tsx const CACHE = "ttvfx-set-v1"; @@ -22,16 +22,20 @@ self.addEventListener("fetch", (event) => { const url = new URL(req.url); if (url.origin !== self.location.origin) return; - // Page: network first so updates arrive, cached copy when offline + // Pages (/set/, /set/spill/): network first so updates arrive, cached copy when offline. + // Each page is cached under its own path so one never replaces the other. if (req.mode === "navigate") { + const page = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/"; event.respondWith( fetch(req) .then((res) => { - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put("/set/", copy)); + if (res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(page, copy)); + } return res; }) - .catch(() => caches.match("/set/")), + .catch(() => caches.match(page).then((hit) => hit || caches.match("/set/"))), ); return; }