Add /set/plate: background plate comp with poly mask
Deploy / deploy (push) Successful in 40s

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>
This commit is contained in:
twotalesanimation
2026-11-03 07:40:12 +02:00
parent 11ce5c128c
commit b5dfdc9897
5 changed files with 763 additions and 2 deletions
+9
View File
@@ -49,6 +49,15 @@ live key with despill) and Evenness (screen brightness bands). "Pick white" sets
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.
## On-set plate comp (`/set/plate`)
For shooting a foreground against a background plate that's already been shot. Load the
plate (fit to width, then drag / pinch / scroll to place it), start the camera, and draw a
polygon mask around where the foreground goes: the camera shows inside the mask, the plate
everywhere else. "Move plate" and "Draw mask" show the camera faintly over the whole plate
for lining up. The plate, its placement and the mask are remembered between visits. The
code is in `components/set/PlateComp.tsx`.
## Structure
```
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata, Viewport } from "next";
import PlateComp from "@/components/set/PlateComp";
// On-set plate comp: line up a shot against its background plate. Like /set, not linked
// from the site and kept out of search results and the sitemap.
export const metadata: Metadata = {
title: "Plate comp",
description: "Two Tales VFX on-set background plate comp.",
robots: { index: false, follow: false },
manifest: "/set/manifest.webmanifest",
appleWebApp: { capable: true, title: "Plate", statusBarStyle: "black-translucent" },
};
export const viewport: Viewport = {
themeColor: "#000000",
viewportFit: "cover",
};
export default function PlatePage() {
return (
<main className="h-dvh bg-black">
<PlateComp />
</main>
);
}
+10
View File
@@ -373,6 +373,16 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
</span>
<span aria-hidden className="text-xl text-muted">→</span>
</Link>
<Link
href="/set/plate/"
className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-4 py-4 transition hover:bg-white/10"
>
<span>
<span className="block font-medium">Plate comp</span>
<span className="block text-sm text-muted">Line up the shot against its background plate</span>
</span>
<span aria-hidden className="text-xl text-muted">→</span>
</Link>
<Link
href="/set/report/"
className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-4 py-4 transition hover:bg-white/10"
+717
View File
@@ -0,0 +1,717 @@
"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>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Offline cache for /set (screen markers, spill checker, VFX report). Crews often have no signal on set:
// Offline cache for /set (screen markers, spill checker, plate comp, VFX report). 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,7 +22,7 @@ self.addEventListener("fetch", (event) => {
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Pages (/set/, /set/spill/): network first so updates arrive, cached copy when offline.
// Pages (/set/, /set/spill/, /set/plate/): 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 + "/";