/set/spill: landscape side column, 2.39:1 framing crop
Deploy / deploy (push) Successful in 33s

- Phones held sideways get the picture full height with a narrow control
  column on the right (new `land` Tailwind variant), settings as a side panel
- Framing crop (Full / 16:9 / 1.85:1 / 2.39:1, default 2.39:1) applied to the
  picture, taps and stats, so the verdict only counts what's in frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-07 12:02:20 +02:00
parent 52d492506b
commit 60a10d999e
2 changed files with 96 additions and 37 deletions
+3
View File
@@ -1,5 +1,8 @@
@import "tailwindcss";
/* Phones held sideways: short landscape screens (tools in /set use a side column) */
@custom-variant land (@media (orientation: landscape) and (max-height: 540px));
:root {
--ink: #0a0a0b;
--ink-2: #111113;
+93 -37
View File
@@ -13,7 +13,8 @@ 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 };
// aspect: framing crop (width / height), 0 = the camera's full picture
type Settings = { mode: ViewMode; screenT: number; sens: number; edges: boolean; flat: Flat; aspect: number };
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 };
@@ -32,11 +33,17 @@ const FLATS: Record<Flat, [number, number, number]> = {
};
// 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 DEFAULTS: Settings = { mode: "spill", screenT: 0.55, sens: 0.04, edges: true, flat: "green", aspect: 2.39 };
const ASPECTS: [number, string][] = [
[0, "Full"],
[16 / 9, "16:9"],
[1.85, "1.85:1"],
[2.39, "2.39:1"],
];
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;
const PROBE_W = 200;
// Brightness floors: below these, colour is judged in absolute terms so dark noise doesn't
// read as screen or spill
const FLOOR = 0.25;
@@ -202,6 +209,7 @@ precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uTexel, uFit;
uniform vec4 uCrop; // framing crop in texture space: offset xy, size zw
uniform float uKeySat, uKeyMean, uKeyBlue, uScreenT, uLo, uMid, uHi, uEdge, uMode, uTime, uDpr;
uniform vec3 uFlat, uKeyU, uGain;
@@ -238,6 +246,7 @@ bool nearScreen(vec2 uv) {
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; }
uv = uCrop.xy + uv * uCrop.zw;
vec3 c = px(uv);
// Classify on a slightly softened colour so camera noise doesn't sparkle
vec2 t = uTexel * 1.5;
@@ -314,11 +323,22 @@ function initGL(canvas: HTMLCanvasElement): GL | null {
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 names = ["uTexel", "uFit", "uCrop", "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 };
}
// The centred part of the source inside the chosen frame aspect (the shot's framing)
function cropOf(w: number, h: number, aspect: number) {
if (!aspect || Math.abs(w / h - aspect) < 0.01) return { x: 0, y: 0, w, h };
if (w / h > aspect) {
const cw = h * aspect;
return { x: (w - cw) / 2, y: 0, w: cw, h };
}
const ch = w / aspect;
return { x: 0, y: (h - ch) / 2, w, h: ch };
}
// 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);
@@ -393,15 +413,16 @@ export default function SpillChecker() {
return null;
}, []);
// Read a small copy of the current frame for stats and auto-key
// Read a small copy of the framed part of the current frame for stats and auto-key
const sampleFrame = useCallback(() => {
const src = source();
if (!src) return null;
const cr = cropOf(src.w, src.h, live.current.settings.aspect);
const c = (probe.current ??= document.createElement("canvas"));
c.width = PROBE_W;
c.height = Math.max(1, Math.round((PROBE_W * src.h) / src.w));
c.height = Math.max(1, Math.round((PROBE_W * cr.h) / cr.w));
const ctx = c.getContext("2d", { willReadFrequently: true })!;
ctx.drawImage(src.el, 0, 0, c.width, c.height);
ctx.drawImage(src.el, cr.x, cr.y, cr.w, cr.h, 0, 0, c.width, c.height);
return { data: ctx.getImageData(0, 0, c.width, c.height).data, w: c.width, h: c.height };
}, [source]);
@@ -455,10 +476,13 @@ export default function SpillChecker() {
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 cr = cropOf(src.w, src.h, s.aspect);
const fit = fitRect(bw, bh, cr.w, cr.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);
// Texture rows are flipped (UNPACK_FLIP_Y), so y is measured from the bottom
gl.uniform4f(u.uCrop, cr.x / src.w, 1 - (cr.y + cr.h) / src.h, cr.w / src.w, cr.h / src.h);
gl.uniform3fv(u.uKeyU, k?.u ?? [0, 1, 0]);
gl.uniform3fv(u.uGain, gn);
gl.uniform1f(u.uKeyMean, k?.mean ?? 1);
@@ -618,10 +642,12 @@ export default function SpillChecker() {
const src = source();
if (!src) return;
const rect = e.currentTarget.getBoundingClientRect();
const fit = fitRect(rect.width, rect.height, src.w, src.h);
const cr = cropOf(src.w, src.h, settings.aspect);
const fit = fitRect(rect.width, rect.height, cr.w, cr.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 fx = (tx - fit.x) / fit.scale, fy = (ty - fit.y) / fit.scale;
if (fx < 0 || fy < 0 || fx >= cr.w || fy >= cr.h) return;
const sx = cr.x + fx, sy = cr.y + fy;
const R = Math.max(2, Math.round(src.w / 300));
const c = document.createElement("canvas");
c.width = c.height = R * 2 + 1;
@@ -699,8 +725,8 @@ export default function SpillChecker() {
}}
/>
{/* Top bar */}
<header className="flex items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
{/* Top bar (phone landscape: moves into the control column to give the picture the height) */}
<header className={`flex items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2 ${showing ? "land:hidden" : ""}`}>
<Link href="/set/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
← Markers
</Link>
@@ -711,9 +737,9 @@ export default function SpillChecker() {
{!showing ? (
<StartScreen status={status} error={error} onCamera={startCamera} onPhoto={() => fileRef.current?.click()} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col land:flex-row">
{/* Picture */}
<div className="relative min-h-0 flex-1">
<div className="relative min-h-0 flex-1 land:pl-[env(safe-area-inset-left)]">
<canvas ref={canvasRef} onPointerDown={onTap} className="absolute inset-0 h-full w-full touch-none" />
{readout && (
<div className="pointer-events-none absolute inset-0">
@@ -759,22 +785,36 @@ export default function SpillChecker() {
FROZEN
</span>
)}
{settings.aspect > 0 && (
<span className="pointer-events-none absolute right-3 bottom-3 rounded border border-white/40 bg-black/70 px-2 py-0.5 font-mono text-[11px] text-white/80">
{ASPECTS.find(([a]) => a === settings.aspect)?.[1] ?? `${settings.aspect.toFixed(2)}:1`}
</span>
)}
</div>
{/* Controls (phone landscape: a narrow scrolling column on the right) */}
<div className="land:flex land:w-60 land:shrink-0 land:flex-col land:overflow-y-auto land:border-l land:border-white/10 land:pr-[env(safe-area-inset-right)] land:pb-2">
<div className="hidden items-center justify-between px-3 pt-2 land:flex">
<Link href="/set/" className="flex min-h-9 items-center text-xs text-muted">
← Markers
</Link>
<span className="font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
</div>
{/* Verdict + legend */}
<div className="px-4 pt-3">
<div className="px-4 pt-3 land:px-3 land:pt-1">
{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>
<div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg land:px-2 land:py-1.5 land:text-sm">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">
<div className="grid grid-cols-4 gap-1.5 px-4 pt-3 land:grid-cols-2 land:px-3 land:pt-2" role="radiogroup" aria-label="View">
{MODES.map(([m, label]) => (
<button
key={m}
@@ -785,7 +825,7 @@ export default function SpillChecker() {
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"}`}
className={`min-h-12 rounded-lg text-sm font-semibold land:min-h-9 land:text-xs ${settings.mode === m ? "bg-white text-black" : "bg-white/10 text-fg"}`}
>
{label}
</button>
@@ -793,7 +833,7 @@ export default function SpillChecker() {
</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))]">
<div className="grid grid-cols-5 gap-1.5 px-4 pt-1.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] land:grid-cols-3 land:px-3 land:pb-0">
<Action onClick={() => (frozen ? (status === "live" ? unfreeze() : startCamera()) : freeze())}>
{frozen ? "Live" : "Freeze"}
</Action>
@@ -816,13 +856,14 @@ export default function SpillChecker() {
lockable={lockable && status === "live"}
locked={locked}
onLock={toggleLock}
onReset={() => setSettings({ ...DEFAULTS, mode: settings.mode, flat: settings.flat })}
onReset={() => setSettings({ ...DEFAULTS, mode: settings.mode, flat: settings.flat, aspect: settings.aspect })}
whiteSet={whiteSet}
onClearWhite={() => setGain(NO_GAIN)}
onClose={() => setPanel(false)}
/>
)}
</>
</div>
</div>
)}
</div>
);
@@ -865,7 +906,7 @@ function Action({ children, onClick, active }: { children: React.ReactNode; onCl
<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"}`}
className={`min-h-12 rounded-lg border text-sm leading-tight land:min-h-9 land:text-xs ${active ? "border-white bg-white/20" : "border-white/20 bg-transparent"}`}
>
{children}
</button>
@@ -885,7 +926,7 @@ function Swatch({ level }: { level: Exclude<Level, "clean"> }) {
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>;
return <div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg land:px-2 land:py-1.5 land:text-sm">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 = {
@@ -896,18 +937,18 @@ function Verdict({ level, detail, widespread, fg }: { level: Level; detail: stri
}[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"}`}
className={`flex items-center gap-3 rounded-xl px-4 py-2.5 land:gap-2 land:px-2.5 land:py-1.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="flex h-8 w-11 shrink-0 items-center justify-center text-2xl land:hidden">✓</span>
) : (
<span className="h-8 w-11 shrink-0 rounded border border-white/60" style={PATTERN[level]} />
<span className="h-8 w-11 shrink-0 rounded border border-white/60 land:h-6 land:w-6" 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>
<p className="text-xl leading-tight font-bold uppercase land:text-base">{big}</p>
<p className="text-sm font-medium land:text-xs land:leading-snug">{sub}</p>
<p className="truncate text-xs opacity-75 land:hidden">{detail}</p>
</div>
</div>
);
@@ -915,16 +956,16 @@ function Verdict({ level, detail, widespread, fg }: { level: Level; detail: stri
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>;
return <div className="rounded-xl border-2 border-white/30 px-4 py-3 text-center text-lg land:px-2 land:py-1.5 land:text-sm">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 className={`rounded-xl px-4 py-2.5 land:px-2.5 land:py-1.5 ${pct > 30 ? "border-4 border-white" : "border-2 border-white/30"}`} role="status">
<p className="text-xl leading-tight font-bold uppercase land:text-base">{big}</p>
<p className="text-sm font-medium land:text-xs">{sub}</p>
<p className="text-xs opacity-75 land:hidden">Most of the screen is within a {pct}% brightness range</p>
</div>
);
}
@@ -960,7 +1001,7 @@ function Legend({ mode, flat }: { mode: ViewMode; flat: Flat }) {
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>;
return <div className="flex flex-wrap gap-x-4 gap-y-1 pt-2 text-xs text-white/80 land:gap-x-3 land:pt-1.5 land:text-[11px]">{items.map((n, i) => <span key={i}>{n}</span>)}</div>;
}
function SettingsPanel({
@@ -985,7 +1026,7 @@ function SettingsPanel({
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="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))] land:top-0 land:left-auto land:max-h-none land:w-80 land:rounded-none land:border-t-0 land:border-l land:pr-[max(1rem,env(safe-area-inset-right))]">
<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">
@@ -993,6 +1034,21 @@ function SettingsPanel({
</button>
</div>
<p className="mt-4 text-sm">Frame</p>
<div className="mt-2 grid grid-cols-4 gap-1.5">
{ASPECTS.map(([a, label]) => (
<button
key={label}
type="button"
onClick={() => set("aspect", a)}
className={`min-h-11 rounded-lg text-sm ${settings.aspect === a ? "bg-white text-black" : "bg-white/10"}`}
>
{label}
</button>
))}
</div>
<p className="mt-1 text-xs text-muted">Crops to the shot&apos;s framing. The verdict only counts what&apos;s inside.</p>
<Slider
label="Spill sensitivity"
hint="Higher finds fainter spill"