From 60a10d999e39797a6f7ab8af0e6be4136b99f5c6 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:02:20 +0200 Subject: [PATCH] /set/spill: landscape side column, 2.39:1 framing crop - 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 --- app/globals.css | 3 + components/set/SpillChecker.tsx | 130 +++++++++++++++++++++++--------- 2 files changed, 96 insertions(+), 37 deletions(-) diff --git a/app/globals.css b/app/globals.css index a337210..618dfff 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/components/set/SpillChecker.tsx b/components/set/SpillChecker.tsx index 879a511..1cabfb1 100644 --- a/components/set/SpillChecker.tsx +++ b/components/set/SpillChecker.tsx @@ -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 = { }; // 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 */} -
+ {/* Top bar (phone landscape: moves into the control column to give the picture the height) */} +
← Markers @@ -711,9 +737,9 @@ export default function SpillChecker() { {!showing ? ( fileRef.current?.click()} /> ) : ( - <> +
{/* Picture */} -
+
{readout && (
@@ -759,22 +785,36 @@ export default function SpillChecker() { FROZEN )} + {settings.aspect > 0 && ( + + {ASPECTS.find(([a]) => a === settings.aspect)?.[1] ?? `${settings.aspect.toFixed(2)}:1`} + + )} +
+ + {/* Controls (phone landscape: a narrow scrolling column on the right) */} +
+
+ + ← Markers + + {offline ? "✓ Offline" : ""}
{/* Verdict + legend */} -
+
{settings.mode === "even" ? ( ) : v ? ( ) : ( -
Waiting for a screen colour…
+
Waiting for a screen colour…
)}
{/* Mode buttons */} -
+
{MODES.map(([m, label]) => ( @@ -793,7 +833,7 @@ export default function SpillChecker() {
{/* Actions */} -
+
(frozen ? (status === "live" ? unfreeze() : startCamera()) : freeze())}> {frozen ? "Live" : "Freeze"} @@ -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)} /> )} - +
+
)}
); @@ -865,7 +906,7 @@ function Action({ children, onClick, active }: { children: React.ReactNode; onCl @@ -885,7 +926,7 @@ function Swatch({ level }: { level: Exclude }) { 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.
; + 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 = { @@ -896,18 +937,18 @@ function Verdict({ level, detail, widespread, fg }: { level: Level; detail: stri }[level]; return (
{level === "clean" ? ( - ✓ + ✓ ) : ( - + )}
-

{big}

-

{sub}

-

{detail}

+

{big}

+

{sub}

+

{detail}

); @@ -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
Point at the screen
; + 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

+
30 ? "border-4 border-white" : "border-2 border-white/30"}`} role="status"> +

{big}

+

{sub}

+

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

); } @@ -960,7 +1001,7 @@ function Legend({ mode, flat }: { mode: ViewMode; flat: Flat }) { item(box({ background: "#fff" }), "+30% hot"), ]; } - return
{items.map((n, i) => {n})}
; + return
{items.map((n, i) => {n})}
; } function SettingsPanel({ @@ -985,7 +1026,7 @@ function SettingsPanel({ onClose: () => void; }) { return ( -
+

Settings

+

Frame

+
+ {ASPECTS.map(([a, label]) => ( + + ))} +
+

Crops to the shot's framing. The verdict only counts what's inside.

+