Files
twotalesanimation b5dfdc9897
Deploy / deploy (push) Successful in 40s
Add /set/plate: background plate comp with poly mask
Load a plate (fit to width, drag/pinch/scroll to place), comp the live
camera over it through a polygon mask drawn around the foreground.
Plate, placement and mask are remembered between visits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:40:12 +02:00

718 lines
28 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
// On-set plate comp: a background plate that's already been shot, with the live camera
// comped over it through a simple polygon mask (where the foreground goes). For lining up
// a shot against its replacement background.
type Mode = "comp" | "plate" | "mask";
type Pt = [number, number];
// Plate placement in frame units: centre (cx, cy) as a fraction of the frame, scale 1 = fit to width
type Place = { cx: number; cy: number; scale: number };
// aspect: framing crop (width / height), 0 = the camera's full picture
type Settings = { aspect: number; place: Place; mask: Pt[]; outline: boolean };
const MODES: [Mode, string][] = [
["comp", "Comp"],
["plate", "Move plate"],
["mask", "Draw mask"],
];
const ASPECTS: [number, string][] = [
[0, "Full"],
[16 / 9, "16:9"],
[1.85, "1.85:1"],
[2.39, "2.39:1"],
];
const FIT: Place = { cx: 0.5, cy: 0.5, scale: 1 };
const DEFAULTS: Settings = { aspect: 0, place: FIT, mask: [], outline: false };
const SETTINGS_KEY = "ttvfx-plate-settings-v1";
const PLATE_KEY = "ttvfx-plate-image-v1";
// Must match CACHE in public/set/sw.js
const OFFLINE_CACHE = "ttvfx-set-v1";
// Kept small enough to remember between visits (localStorage holds ~5 MB)
const PLATE_MAX = 2048;
// Camera opacity outside the mask while moving the plate or drawing the mask
const GHOST = 0.5;
// Touch target around a mask point, in CSS px
const HIT = 22;
// 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 frame 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 };
}
// Nearest edge of the closed polygon to p, as the index to insert a new point at
function insertAt(pts: Pt[], p: Pt, fw: number, fh: number) {
let best = pts.length, bestD = Infinity;
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
const ax = a[0] * fw, ay = a[1] * fh, bx = b[0] * fw, by = b[1] * fh, px = p[0] * fw, py = p[1] * fh;
const dx = bx - ax, dy = by - ay;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1)));
const d = Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
if (d < bestD) {
bestD = d;
best = i + 1;
}
}
return best;
}
type Status = "idle" | "starting" | "live" | "error";
type Gesture =
| { kind: "point"; index: number }
| { kind: "pan"; x: number; y: number; place: Place }
| { kind: "pinch"; d: number; mx: number; my: number; place: Place }
| null;
export default function PlateComp() {
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState("");
const [frozen, setFrozen] = useState(false);
const [mode, setMode] = useState<Mode>("plate");
const [settings, setSettings] = useState<Settings>(DEFAULTS);
const [plate, setPlate] = useState<HTMLCanvasElement | null>(null);
const [history, setHistory] = useState<Pt[][]>([]);
// The mask point last added or moved, for Delete point
const [sel, setSel] = useState(-1);
const [offline, setOffline] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const plateFileRef = useRef<HTMLInputElement>(null);
const photoFileRef = useRef<HTMLInputElement>(null);
const frozenCanvas = useRef<HTMLCanvasElement | null>(null);
const stream = useRef<MediaStream | null>(null);
const pointers = useRef(new Map<number, { x: number; y: number }>());
const gesture = useRef<Gesture>(null);
const live = useRef({ settings: DEFAULTS, mode: "plate" as Mode, frozen: false, plate: null as HTMLCanvasElement | null, sel: -1 });
const showing = status === "live" || frozen;
useEffect(() => {
live.current = { settings, mode, frozen, plate, sel };
}, [settings, mode, frozen, plate, sel]);
// Remember the plate, its placement and the mask between visits
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? "null");
if (saved) requestAnimationFrame(() => setSettings((s) => ({ ...s, ...saved })));
const url = localStorage.getItem(PLATE_KEY);
if (url) {
const img = new Image();
img.onload = () => setPlate(toCanvas(img));
img.src = url;
}
} 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;
}, []);
// The frame (camera crop) inside the canvas box, in CSS px
const frameRect = useCallback(
(bw: number, bh: number) => {
const src = source();
if (!src) return null;
const cr = cropOf(src.w, src.h, live.current.settings.aspect);
return { src, cr, fit: fitRect(bw, bh, cr.w, cr.h) };
},
[source],
);
// Render loop: plate, then the camera clipped to the mask
useEffect(() => {
if (!showing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
let raf = 0;
const draw = () => {
raf = requestAnimationFrame(draw);
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);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, bw, bh);
const fr = frameRect(bw, bh);
if (!fr) return;
const { src, cr, fit: f } = fr;
const { settings: s, mode: m, plate: pl, sel: sl } = live.current;
const hasMask = s.mask.length >= 3;
ctx.save();
ctx.beginPath();
ctx.rect(f.x, f.y, f.w, f.h);
ctx.clip();
if (pl) {
const w = f.w * s.place.scale, h = (w * pl.height) / pl.width;
ctx.drawImage(pl, f.x + s.place.cx * f.w - w / 2, f.y + s.place.cy * f.h - h / 2, w, h);
}
const camera = (alpha: number) => {
ctx.globalAlpha = alpha;
ctx.drawImage(src.el, cr.x, cr.y, cr.w, cr.h, f.x, f.y, f.w, f.h);
ctx.globalAlpha = 1;
};
const path = () => {
ctx.beginPath();
s.mask.forEach(([x, y], i) => (i ? ctx.lineTo(f.x + x * f.w, f.y + y * f.h) : ctx.moveTo(f.x + x * f.w, f.y + y * f.h)));
ctx.closePath();
};
// Lining up: the whole camera picture shows faintly over the plate
if (m !== "comp" && pl) camera(GHOST);
if (hasMask) {
ctx.save();
path();
ctx.clip();
camera(1);
ctx.restore();
} else if (!pl || m === "mask") {
// No mask yet: nothing to comp, so show the camera
camera(1);
}
// Mask outline and points
if (s.mask.length && (m === "mask" || s.outline)) {
path();
if (s.mask.length < 3 || m === "mask") {
ctx.lineWidth = 3;
ctx.strokeStyle = "#000";
ctx.stroke();
}
ctx.lineWidth = 1.5;
ctx.setLineDash(m === "mask" ? [] : [6, 4]);
ctx.strokeStyle = "#fff";
ctx.stroke();
ctx.setLineDash([]);
if (m === "mask") {
s.mask.forEach(([x, y], i) => {
ctx.beginPath();
ctx.arc(f.x + x * f.w, f.y + y * f.h, i === sl ? 9 : 6, 0, Math.PI * 2);
ctx.fillStyle = i === sl ? "#ffd600" : "#fff";
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "#000";
ctx.stroke();
});
}
}
ctx.restore();
};
draw();
return () => cancelAnimationFrame(raf);
}, [showing, frameRect]);
// Keep the screen awake while lining up
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/plate/", ...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();
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");
}
};
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 loadImage = (file: File, done: (img: HTMLImageElement) => void) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
done(img);
URL.revokeObjectURL(url);
};
img.src = url;
};
const openPlate = (file: File) =>
loadImage(file, (img) => {
const c = toCanvas(img);
setPlate(c);
setSettings((s) => ({ ...s, place: FIT }));
try {
localStorage.setItem(PLATE_KEY, c.toDataURL("image/jpeg", 0.85));
} catch {
// Too big to remember; it still works for this visit
try {
localStorage.removeItem(PLATE_KEY);
} catch {}
}
});
// A still in place of the camera, for planning off set
const openPhoto = (file: File) =>
loadImage(file, (img) => {
frozenCanvas.current = toCanvas(img);
setFrozen(true);
});
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setSettings((s) => ({ ...s, [k]: v }));
const setMask = (mask: Pt[], undoable = true) => {
if (undoable) setHistory((h) => [...h.slice(-49), settings.mask]);
set("mask", mask);
};
const setPlace = (p: Place) => set("place", { ...p, scale: Math.max(0.1, Math.min(10, p.scale)) });
// Pointer position as frame units (0..1 across the frame)
const toFrame = (e: { clientX: number; clientY: number }, el: HTMLElement) => {
const rect = el.getBoundingClientRect();
const fr = frameRect(rect.width, rect.height);
if (!fr) return null;
const { fit: f } = fr;
return { p: [(e.clientX - rect.left - f.x) / f.w, (e.clientY - rect.top - f.y) / f.h] as Pt, f };
};
const startPinch = () => {
const [a, b] = [...pointers.current.values()];
gesture.current = { kind: "pinch", d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, place: settings.place };
};
const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (mode === "plate") {
if (pointers.current.size === 2) startPinch();
else gesture.current = { kind: "pan", x: e.clientX, y: e.clientY, place: settings.place };
return;
}
if (mode !== "mask" || pointers.current.size > 1) return;
const hit = toFrame(e, e.currentTarget);
if (!hit) return;
const { p, f } = hit;
const near = settings.mask.findIndex(([x, y]) => Math.hypot((x - p[0]) * f.w, (y - p[1]) * f.h) < HIT);
if (near >= 0) {
setHistory((h) => [...h.slice(-49), settings.mask]);
gesture.current = { kind: "point", index: near };
setSel(near);
return;
}
const q: Pt = [Math.max(0, Math.min(1, p[0])), Math.max(0, Math.min(1, p[1]))];
const i = settings.mask.length < 3 ? settings.mask.length : insertAt(settings.mask, q, f.w, f.h);
setMask([...settings.mask.slice(0, i), q, ...settings.mask.slice(i)]);
gesture.current = { kind: "point", index: i };
setSel(i);
};
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!pointers.current.has(e.pointerId)) return;
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const g = gesture.current;
if (!g) return;
const rect = e.currentTarget.getBoundingClientRect();
const fr = frameRect(rect.width, rect.height);
if (!fr) return;
const { fit: f } = fr;
if (g.kind === "point") {
const hit = toFrame(e, e.currentTarget);
if (!hit) return;
const q: Pt = [Math.max(0, Math.min(1, hit.p[0])), Math.max(0, Math.min(1, hit.p[1]))];
setSettings((s) => ({ ...s, mask: s.mask.map((pt, i) => (i === g.index ? q : pt)) }));
} else if (g.kind === "pan") {
setPlace({ ...g.place, cx: g.place.cx + (e.clientX - g.x) / f.w, cy: g.place.cy + (e.clientY - g.y) / f.h });
} else if (g.kind === "pinch" && pointers.current.size >= 2) {
const [a, b] = [...pointers.current.values()];
const ratio = Math.hypot(a.x - b.x, a.y - b.y) / (g.d || 1);
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
// Scale about where the fingers started, and follow them as they move
const ox = (g.mx - rect.left - f.x) / f.w, oy = (g.my - rect.top - f.y) / f.h;
setPlace({
scale: g.place.scale * ratio,
cx: ox + (g.place.cx - ox) * ratio + (mx - g.mx) / f.w,
cy: oy + (g.place.cy - oy) * ratio + (my - g.my) / f.h,
});
}
};
const onUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
pointers.current.delete(e.pointerId);
const left = [...pointers.current.values()][0];
// Lifting one finger of a pinch carries on as a pan with the other
gesture.current = mode === "plate" && left ? { kind: "pan", x: left.x, y: left.y, place: settings.place } : null;
};
// Desktop: scroll wheel scales the plate about the cursor
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || mode !== "plate") return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const fr = frameRect(rect.width, rect.height);
if (!fr) return;
const { fit: f } = fr;
const ratio = Math.exp(-e.deltaY * 0.0015);
const ox = (e.clientX - rect.left - f.x) / f.w, oy = (e.clientY - rect.top - f.y) / f.h;
setSettings((s) => ({
...s,
place: { scale: Math.max(0.1, Math.min(10, s.place.scale * ratio)), cx: ox + (s.place.cx - ox) * ratio, cy: oy + (s.place.cy - oy) * ratio },
}));
};
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, [mode, showing, frameRect]);
const fitHeight = () => {
const src = source();
if (!src || !plate) return;
const cr = cropOf(src.w, src.h, settings.aspect);
// Scale is relative to the frame width; fit height makes the plate as tall as the frame
setPlace({ cx: 0.5, cy: 0.5, scale: ((plate.width / plate.height) * cr.h) / cr.w });
};
const undo = () => {
const prev = history[history.length - 1];
if (!prev) return;
setHistory((h) => h.slice(0, -1));
set("mask", prev);
};
const cycleAspect = () => {
const i = ASPECTS.findIndex(([a]) => a === settings.aspect);
set("aspect", ASPECTS[(i + 1) % ASPECTS.length][0]);
};
const hint =
!plate
? "Load the background plate to start"
: mode === "plate"
? "Drag to move the plate · pinch or scroll to scale"
: mode === "mask" && settings.mask.length < 3
? "Tap around the foreground to draw the mask"
: mode === "mask"
? "Drag points to adjust · tap an edge to add a point"
: null;
return (
<div className="flex h-dvh flex-col bg-black text-fg select-none">
<video ref={videoRef} playsInline muted className="pointer-events-none absolute h-px w-px opacity-0" />
<FileInput inputRef={plateFileRef} onFile={openPlate} />
<FileInput inputRef={photoFileRef} onFile={openPhoto} />
{/* 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>
<h1 className="flex-1 text-center font-display text-lg font-light tracking-wide uppercase">Plate comp</h1>
<span className="w-20 text-right font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
</header>
{!showing ? (
<StartScreen
status={status}
error={error}
plate={plate}
onCamera={startCamera}
onPlate={() => plateFileRef.current?.click()}
onPhoto={() => photoFileRef.current?.click()}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col land:flex-row">
{/* Picture */}
<div className="relative min-h-0 flex-1 land:pl-[env(safe-area-inset-left)]">
<canvas
ref={canvasRef}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="absolute inset-0 h-full w-full touch-none"
/>
{hint && (
<p className="pointer-events-none absolute inset-x-4 top-3 rounded-lg bg-black/80 px-4 py-2 text-center text-sm font-medium">{hint}</p>
)}
{frozen && (
<span className="pointer-events-none absolute top-3 right-3 rounded bg-white px-2 py-1 font-mono text-xs font-bold text-black">
FROZEN
</span>
)}
{plate && (
<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">
Plate {Math.round(settings.place.scale * 100)}%
</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>
{/* Mode buttons */}
<div className="grid grid-cols-3 gap-1.5 px-4 pt-3 land:grid-cols-1 land:px-3 land:pt-2" role="radiogroup" aria-label="Mode">
{MODES.map(([m, label]) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
onClick={() => setMode(m)}
className={`min-h-12 rounded-lg text-sm font-semibold land:min-h-9 land:text-xs ${mode === m ? "bg-white text-black" : "bg-white/10 text-fg"}`}
>
{label}
</button>
))}
</div>
{/* Actions for the current mode */}
<div className="grid grid-cols-4 gap-1.5 px-4 pt-1.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] land:grid-cols-2 land:px-3 land:pb-0">
{mode === "plate" ? (
<>
<Action onClick={() => setPlace(FIT)}>Fit width</Action>
<Action onClick={fitHeight}>Fit height</Action>
<Action onClick={() => plateFileRef.current?.click()}>{plate ? "New plate" : "Load plate"}</Action>
<Action onClick={cycleAspect}>Frame {ASPECTS.find(([a]) => a === settings.aspect)?.[1] ?? "Full"}</Action>
</>
) : mode === "mask" ? (
<>
<Action onClick={undo} disabled={!history.length}>
Undo
</Action>
<Action
onClick={() => {
const i = sel >= 0 && sel < settings.mask.length ? sel : settings.mask.length - 1;
setMask(settings.mask.filter((_, j) => j !== i));
setSel(Math.min(i, settings.mask.length - 2));
}}
disabled={!settings.mask.length}
>
Delete point
</Action>
<Action onClick={() => setMask([])} disabled={!settings.mask.length}>
Clear mask
</Action>
<Action onClick={() => setMode("comp")}>Done</Action>
</>
) : (
<>
<Action onClick={() => (frozen ? (status === "live" ? setFrozen(false) : startCamera()) : freeze())}>
{frozen ? "Live" : "Freeze"}
</Action>
<Action onClick={() => set("outline", !settings.outline)} active={settings.outline}>
Outline
</Action>
<Action onClick={() => photoFileRef.current?.click()}>Photo</Action>
<Action onClick={cycleAspect}>Frame {ASPECTS.find(([a]) => a === settings.aspect)?.[1] ?? "Full"}</Action>
</>
)}
</div>
{/* Shown in every mode so the controls don't change height (and shift the picture) */}
{plate && (
<label className="block px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] land:px-3">
<span className="flex justify-between text-xs text-muted">
<span>Plate scale</span>
<span className="font-mono">{Math.round(settings.place.scale * 100)}%</span>
</span>
<input
type="range"
min={-1}
max={1}
step={0.005}
// Log scale: 10% to 1000%, 100% in the middle
value={Math.log10(settings.place.scale)}
onChange={(e) => setPlace({ ...settings.place, scale: 10 ** Number(e.target.value) })}
className="mt-1 w-full accent-white"
/>
</label>
)}
</div>
</div>
)}
</div>
);
}
// Downscale to a size that's quick to draw every frame and small enough to remember
function toCanvas(img: HTMLImageElement) {
const scale = Math.min(1, PLATE_MAX / Math.max(img.width, img.height));
const c = 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);
return c;
}
function FileInput({ inputRef, onFile }: { inputRef: React.RefObject<HTMLInputElement | null>; onFile: (f: File) => void }) {
return (
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onFile(f);
e.target.value = "";
}}
/>
);
}
function StartScreen({
status,
error,
plate,
onCamera,
onPlate,
onPhoto,
}: {
status: Status;
error: string;
plate: HTMLCanvasElement | null;
onCamera: () => void;
onPlate: () => void;
onPhoto: () => void;
}) {
return (
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-4 px-4 pb-10">
<p className="text-lg leading-relaxed">
Line up the shot against its background plate. The plate sits behind, the camera shows through a mask you draw
around the foreground.
</p>
<ol className="list-decimal space-y-1 pl-5 text-sm leading-relaxed text-muted">
<li>Load the background plate (a still or frame grab).</li>
<li>Start the camera. Move and scale the plate to match.</li>
<li>Draw a mask around where the foreground goes.</li>
</ol>
{error && <p className="rounded-lg border-2 border-white px-4 py-3 text-sm">{error}</p>}
<button type="button" onClick={onPlate} className={`min-h-14 rounded-xl text-lg font-semibold ${plate ? "bg-white/10" : "bg-white text-black"}`}>
{plate ? "✓ Plate loaded · change" : "Load plate"}
</button>
<button
type="button"
onClick={onCamera}
disabled={status === "starting"}
className={`min-h-14 rounded-xl text-lg font-semibold disabled:opacity-60 ${plate ? "bg-white text-black" : "bg-white/10"}`}
>
{status === "starting" ? "Starting camera…" : "Start camera"}
</button>
<button type="button" onClick={onPhoto} className="min-h-12 rounded-xl text-base text-muted">
Use a photo instead of the camera
</button>
</div>
);
}
function Action({ children, onClick, active, disabled }: { children: React.ReactNode; onClick: () => void; active?: boolean; disabled?: boolean }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`min-h-12 rounded-lg border text-sm leading-tight disabled:opacity-40 land:min-h-9 land:text-xs ${active ? "border-white bg-white/20" : "border-white/20 bg-transparent"}`}
>
{children}
</button>
);
}