Files
ttvfx/components/set/MarkerPicker.tsx
T
twotalesanimation 4ac1817425
Deploy / deploy (push) Successful in 41s
Add /set/report: on-set VFX take logger and shareable report
Supervisors log scene/shot/take, per-camera settings, VFX tags,
references and photos on iPhone/iPad; works offline, stored on device
(localStorage + IndexedDB for photos). Exports CSV + photos and a
.json backup/restore.

/set/report/view renders a printable A4 report. "Send report link"
carries the day's log compressed in the URL fragment, so post gets
a formatted page with Save PDF and nothing is uploaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:33:26 +02:00

636 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
type Showing = { src: string; label: string; detail: string; screen?: MarkerScreen; extra?: MarkerExtra };
const KIND_LABEL: Record<Kind, string> = { phone: "Phones", tablet: "Tablets", laptop: "Laptops", monitor: "Monitors" };
const MODE_KEY = "ttvfx-set-mode";
// Must match CACHE in public/set/sw.js
const OFFLINE_CACHE = "ttvfx-set-v1";
function detectScreen() {
const dpr = window.devicePixelRatio || 1;
let w = Math.round(window.screen.width * dpr);
let h = Math.round(window.screen.height * dpr);
const landscape = window.matchMedia("(orientation: landscape)").matches;
// iOS reports screen size in portrait regardless of rotation
if (landscape !== w > h) [w, h] = [h, w];
return { w, h, landscape };
}
function bestMatch(screens: MarkerScreen[], w: number, h: number) {
const pool = screens.filter((s) => s.width > s.height === w > h);
const candidates = pool.length ? pool : screens;
let best: { s: MarkerScreen; score: number } | null = null;
for (const s of candidates) {
const aspect = Math.abs(s.width / s.height - w / h);
const size = Math.abs(s.width * s.height - w * h) / (s.width * s.height);
const score = aspect * 10 + size;
if (!best || score < best.score) best = { s, score };
}
if (!best) return null;
const exact = Math.abs(best.s.width - w) <= 2 && Math.abs(best.s.height - h) <= 2;
return { screen: best.s, exact };
}
function srcFor(s: MarkerScreen, mode: Mode) {
return (mode === "edge" ? s.edge : s.full) ?? s.full ?? s.edge ?? "";
}
function Shape({ w, h, active }: { w: number; h: number; active?: boolean }) {
const scale = 34 / Math.max(w, h);
return (
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span
className={`block rounded-[3px] border-2 ${active ? "border-accent bg-accent/20" : "border-white/60"}`}
style={{ width: Math.max(10, w * scale), height: Math.max(10, h * scale) }}
/>
</span>
);
}
// --- Browser-owned state, read via useSyncExternalStore -------------------
// The URL hash says which marker is open (#<screen-id>/<full|edge> or #<extra-id>),
// so share links, refresh and the back button all behave the same way.
const HASH_EVENT = "ttvfx-set-hash";
const MODE_EVENT = "ttvfx-set-mode";
function subscribe(events: string[]) {
return (cb: () => void) => {
events.forEach((e) => window.addEventListener(e, cb));
return () => events.forEach((e) => window.removeEventListener(e, cb));
};
}
const subscribeHash = subscribe(["popstate", "hashchange", HASH_EVENT]);
const subscribeMode = subscribe(["storage", MODE_EVENT]);
const subscribeScreen = subscribe(["resize", "orientationchange"]);
// History entries pushed while the viewer is open. While it is open, Back is trapped
// (a stray press of Android's back button must not drop the marker mid-take), so
// closing has to unwind all of them.
let viewerDepth = 0;
let closingViewer = false;
function setHash(hash: string, push: boolean) {
if (push) viewerDepth++;
history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname);
window.dispatchEvent(new Event(HASH_EVENT));
}
function closeViewer() {
closingViewer = true;
if (viewerDepth > 0) {
const n = viewerDepth;
viewerDepth = 0;
history.go(-n); // the popstate handler below finishes the job
} else {
closingViewer = false;
setHash("", false);
}
}
if (typeof window !== "undefined") {
window.addEventListener("popstate", () => {
if (!closingViewer) return;
closingViewer = false;
// Opened from a direct link: the entry we land on still carries the hash
if (location.hash) setHash("", false);
});
}
function readMode(): Mode {
try {
return localStorage.getItem(MODE_KEY) === "edge" ? "edge" : "full";
} catch {
return "full";
}
}
function writeMode(m: Mode) {
try {
localStorage.setItem(MODE_KEY, m);
} catch {}
window.dispatchEvent(new Event(MODE_EVENT));
}
function screenKey() {
const { w, h } = detectScreen();
return w > 0 && h > 0 ? `${w}x${h}` : ""; // some browsers report 0×0 while hidden
}
function iosNeedsTip() {
const ios = /iPhone|iPod/.test(navigator.userAgent);
const standalone = window.matchMedia("(display-mode: standalone), (display-mode: fullscreen)").matches;
return ios && !standalone;
}
function requestFullscreen() {
if (document.fullscreenEnabled && !document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
}
}
export default function MarkerPicker({ screens, extras }: { screens: MarkerScreen[]; extras: MarkerExtra[] }) {
const hash = useSyncExternalStore(subscribeHash, () => location.hash, () => "");
const mode = useSyncExternalStore(subscribeMode, readMode, () => "full" as Mode);
const key = useSyncExternalStore(subscribeScreen, screenKey, () => "");
const iosTip = useSyncExternalStore(() => () => {}, iosNeedsTip, () => false);
const [offline, setOffline] = useState<"idle" | "saving" | "ready">("idle");
const device = useMemo(() => {
if (!key) return null;
const [w, h] = key.split("x").map(Number);
return { w, h };
}, [key]);
const match = useMemo(() => (device ? bestMatch(screens, device.w, device.h) : null), [device, screens]);
// What the viewer shows, derived from the hash
const showing = useMemo<(Showing & { mode: Mode }) | null>(() => {
const [id, m] = hash.slice(1).split("/");
if (!id) return null;
const viewMode: Mode = m === "edge" ? "edge" : "full";
const s = screens.find((x) => x.id === id);
if (s)
return {
src: srcFor(s, viewMode),
label: s.name,
detail: `${s.width}×${s.height}${s.landscape ? " · landscape" : ""}`,
screen: s,
mode: viewMode,
};
const e = extras.find((x) => x.id === id);
return e ? { src: e.src, label: e.name, detail: "", extra: e, mode: viewMode } : null;
}, [hash, screens, extras]);
// Leave browser full screen whenever the viewer closes (incl. back button)
useEffect(() => {
if (!showing && document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}, [showing]);
// Offline: register the service worker, then put every marker plus this page's own
// scripts/styles/fonts straight into its cache. Writing from the page (rather than
// relying on the worker intercepting fetches) also covers the very first visit,
// before the worker controls the page.
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;
if (cancelled) return;
setOffline("saving");
const markers = [...screens.flatMap((s) => [s.full, s.edge]), ...extras.map((e) => e.src)].filter(Boolean) as string[];
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/", ...new Set([...markers, ...assets])]);
if (!cancelled) setOffline("ready");
} catch {
if (!cancelled) setOffline("idle");
}
})();
return () => {
cancelled = true;
};
}, [screens, extras]);
const showScreen = (s: MarkerScreen) => {
setHash(`#${s.id}/${mode}`, true);
requestFullscreen();
};
const showExtra = (e: MarkerExtra) => {
setHash(`#${e.id}`, true);
requestFullscreen();
};
const close = useCallback(() => closeViewer(), []);
const switchMode = useCallback(
(m: Mode) => {
writeMode(m);
if (showing?.screen) setHash(`#${showing.screen.id}/${m}`, false);
},
[showing],
);
const chooseMode = writeMode;
const grouped = useMemo(() => {
const out = new Map<Kind, MarkerScreen[]>();
for (const s of screens) out.set(s.kind, [...(out.get(s.kind) ?? []), s]);
return [...out.entries()];
}, [screens]);
return (
<div className="mx-auto max-w-2xl px-4 pt-6 pb-24">
<header className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-[11px] tracking-[0.25em] text-accent uppercase">Two Tales VFX · On set</p>
<h1 className="mt-1 font-display text-3xl font-light tracking-wide uppercase">Screen markers</h1>
</div>
<p className="pt-1 text-right font-mono text-[11px] leading-relaxed text-muted">
{offline === "ready" ? "✓ Works offline" : offline === "saving" ? "Saving for offline…" : ""}
</p>
</header>
{/* Marker style */}
<div className="mt-6 grid grid-cols-2 gap-2 rounded-xl bg-white/5 p-1.5" role="radiogroup" aria-label="Marker style">
{(
[
["full", "Full grid", "Dots across the whole screen"],
["edge", "Edges only", "Clear centre for actors' hands"],
] as const
).map(([m, label, hint]) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
onClick={() => chooseMode(m)}
className={`min-h-16 rounded-lg px-3 py-2.5 text-left transition-colors ${
mode === m ? "bg-fg text-ink" : "text-fg hover:bg-white/5"
}`}
>
<span className="block text-base font-medium">{label}</span>
<span className={`block text-xs ${mode === m ? "text-ink/70" : "text-muted"}`}>{hint}</span>
</button>
))}
</div>
{/* Plain grey and other non-device images */}
{extras.length > 0 && (
<section className="mt-6">
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{extras.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => showExtra(e)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span className="block h-7 w-7 rounded-[3px] bg-[#808080]" />
</span>
<span className="flex-1 text-base">{e.name}</span>
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
))}
</ul>
</section>
)}
{/* This screen */}
{match && device && (
<section className="mt-6">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">This screen</h2>
<button
type="button"
onClick={() => showScreen(match.screen)}
className="flex min-h-24 w-full items-center gap-4 rounded-xl border-2 border-accent bg-accent/10 px-4 py-4 text-left active:bg-accent/25"
>
<Shape w={match.screen.width} h={match.screen.height} active />
<span className="min-w-0 flex-1">
<span className="block text-xl font-medium">{match.screen.name}</span>
<span className="block font-mono text-xs text-muted">
{match.screen.width}×{match.screen.height}
{match.screen.landscape ? " · landscape" : ""} ·{" "}
{match.exact ? "exact match" : `closest to ${device.w}×${device.h}`}
</span>
</span>
<span className="shrink-0 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-ink">Show</span>
</button>
</section>
)}
{iosTip && (
<p className="mt-4 rounded-lg bg-white/5 px-4 py-3 text-sm leading-relaxed text-muted">
<span className="text-fg">iPhone tip:</span> Safari can&apos;t hide its bars. For true full screen tap{" "}
<span className="text-fg">Share → Add to Home Screen</span> once, then open “Markers” from the home screen.
</p>
)}
{/* All screens */}
{grouped.map(([kind, list]) => (
<section key={kind} className="mt-8">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{KIND_LABEL[kind]}</h2>
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{list.map((s) => {
const isMatch = match?.screen.id === s.id;
return (
<li key={s.id}>
<button
type="button"
onClick={() => showScreen(s)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<Shape w={s.width} h={s.height} active={isMatch} />
<span className="min-w-0 flex-1">
<span className="block text-base">{s.name}</span>
<span className="block font-mono text-xs text-muted">
{s.width}×{s.height}
{s.landscape ? " · landscape" : " · portrait"}
</span>
</span>
{isMatch && <span className="font-mono text-[10px] tracking-widest text-accent uppercase">This</span>}
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
);
})}
</ul>
</section>
))}
<details className="mt-8 rounded-xl bg-white/[0.04] px-4 py-3 text-sm leading-relaxed text-muted">
<summary className="cursor-pointer py-2 text-fg">Lock the whole device (blocks home swipe &amp; notifications)</summary>
<p className="mt-2">
<span className="text-fg">iPhone / iPad:</span> Settings → Accessibility → Guided Access → on. Open a marker,
triple-click the side (or home) button, turn <span className="text-fg">Touch</span> off in Options, then
Start. Triple-click again to end.
</p>
<p className="mt-2 pb-1">
<span className="text-fg">Android:</span> Settings → Security → App pinning (Screen pinning) → on. Open a
marker, open Recents, tap the app icon → Pin. Hold Back + Overview to unpin.
</p>
</details>
<Link
href="/set/spill/"
className="mt-8 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">Spill checker</span>
<span className="block text-sm text-muted">Point your phone at the green screen setup to find spill</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"
>
<span>
<span className="block font-medium">VFX report</span>
<span className="block text-sm text-muted">Log camera, references and notes for every take</span>
</span>
<span aria-hidden className="text-xl text-muted">→</span>
</Link>
<p className="mt-10 text-center text-xs leading-relaxed text-muted">
Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake
</p>
{showing && (
<Viewer showing={showing} mode={showing.mode} onMode={switchMode} onClose={close} />
)}
</div>
);
}
const HOLD_MS = 3000; // how long to hold the corner to unlock
const HOLD_SHOW_MS = 500; // ring only appears after this, so brushes show nothing
const CORNER = 80; // px, top-left hot zone
const CONTROLS_MS = 6000; // controls re-lock after this
function Viewer({
showing,
mode,
onMode,
onClose,
}: {
showing: Showing;
mode: Mode;
onMode: (m: Mode) => void;
onClose: () => void;
}) {
// Touch-locked by default: only a 3 s hold in the top-left corner shows controls.
const [controls, setControls] = useState(false);
const [hold, setHold] = useState(0); // 0..1 progress of the corner hold
const [hint, setHint] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const hideTimer = useRef<number | undefined>(undefined);
const holdRef = useRef<{ id: number; raf: number } | null>(null);
// Stretch to fill when the image's shape is close to the visible area (markers sit on
// the real screen edges); otherwise fit it whole so the dots stay round.
const viewport = useSyncExternalStore(subscribeScreen, () => `${innerWidth}x${innerHeight}`, () => "1x1");
const fit = useMemo(() => {
if (!showing.screen) return "object-cover";
const [vw, vh] = viewport.split("x").map(Number);
const ratio = showing.screen.width / showing.screen.height / (vw / vh);
return Math.abs(ratio - 1) <= 0.15 ? "object-fill" : "object-contain";
}, [showing.screen, viewport]);
const canFullscreen = typeof document !== "undefined" && document.fullscreenEnabled;
const showControls = useCallback(() => {
window.clearTimeout(hideTimer.current);
setControls(true);
setHint(false);
hideTimer.current = window.setTimeout(() => setControls(false), CONTROLS_MS);
}, []);
const lock = () => {
window.clearTimeout(hideTimer.current);
setControls(false);
};
// Keep the screen awake while a marker is up
useEffect(() => {
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(() => {});
};
}, []);
// Swallow everything the browser would otherwise do with stray input:
// scrolling, pull-to-refresh, pinch/double-tap zoom, long-press menus, Back.
useEffect(() => {
const root = document.documentElement;
const prev = { overflow: document.body.style.overflow, overscroll: root.style.overscrollBehavior };
document.body.style.overflow = "hidden";
root.style.overscrollBehavior = "none";
const block = (e: Event) => e.preventDefault();
const blockTouch = (e: TouchEvent) => {
if (!(e.target as Element).closest?.("[data-controls]")) e.preventDefault();
};
document.addEventListener("touchmove", blockTouch, { passive: false });
document.addEventListener("gesturestart", block); // iOS pinch
document.addEventListener("contextmenu", block);
document.addEventListener("dblclick", block);
// Guard entry so Back stays on the marker; re-armed every time it is used
viewerDepth++;
history.pushState({ marker: true }, "", location.hash);
const onPop = () => {
if (closingViewer || !location.hash) return;
history.pushState({ marker: true }, "", location.hash);
};
window.addEventListener("popstate", onPop);
const hintTimer = window.setTimeout(() => setHint(false), 4000);
return () => {
document.body.style.overflow = prev.overflow;
root.style.overscrollBehavior = prev.overscroll;
document.removeEventListener("touchmove", blockTouch);
document.removeEventListener("gesturestart", block);
document.removeEventListener("contextmenu", block);
document.removeEventListener("dblclick", block);
window.removeEventListener("popstate", onPop);
window.clearTimeout(hintTimer);
};
}, []);
useEffect(() => {
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener("fullscreenchange", onFs);
const onKey = (e: KeyboardEvent) => {
// Locked: keys do nothing (a prop laptop keyboard may get typed on mid-take).
// The browser itself still leaves full screen on Esc; that cannot be blocked.
if (!controls) {
e.preventDefault();
return;
}
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("fullscreenchange", onFs);
document.removeEventListener("keydown", onKey);
};
}, [controls, onClose]);
// --- corner hold -------------------------------------------------------
const cancelHold = () => {
if (holdRef.current) cancelAnimationFrame(holdRef.current.raf);
holdRef.current = null;
setHold(0);
};
const onPointerDown = (e: React.PointerEvent) => {
if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {});
if (controls || e.clientX > CORNER || e.clientY > CORNER) return;
const start = performance.now();
const tick = () => {
if (!holdRef.current) return;
const t = performance.now() - start;
setHold(t < HOLD_SHOW_MS ? 0 : Math.min(1, (t - HOLD_SHOW_MS) / (HOLD_MS - HOLD_SHOW_MS)));
if (t >= HOLD_MS) {
cancelHold();
showControls();
return;
}
holdRef.current.raf = requestAnimationFrame(tick);
};
holdRef.current = { id: e.pointerId, raf: requestAnimationFrame(tick) };
};
const onPointerMove = (e: React.PointerEvent) => {
if (holdRef.current?.id === e.pointerId && (e.clientX > CORNER * 1.5 || e.clientY > CORNER * 1.5)) cancelHold();
};
return (
<div
className={`fixed inset-0 z-[100] touch-none bg-black select-none [-webkit-touch-callout:none] ${controls ? "" : "cursor-none"}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={cancelHold}
onPointerCancel={cancelHold}
onPointerLeave={cancelHold}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={showing.src} alt={showing.label} className={`pointer-events-none h-full w-full ${fit}`} draggable={false} />
{/* Corner-hold progress ring */}
{hold > 0 && (
<svg className="pointer-events-none absolute top-3 left-3 h-14 w-14 -rotate-90" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="15" fill="rgba(0,0,0,.6)" stroke="rgba(255,255,255,.25)" strokeWidth="3" />
<circle
cx="18"
cy="18"
r="15"
fill="none"
stroke="var(--accent)"
strokeWidth="3"
strokeDasharray={`${hold * 94.25} 94.25`}
strokeLinecap="round"
/>
</svg>
)}
{hint && !controls && (
<div className="pointer-events-none absolute inset-x-0 bottom-[max(1.5rem,env(safe-area-inset-bottom))] flex justify-center px-4">
<p className="rounded-full bg-black/75 px-4 py-2 text-center font-mono text-xs text-white/85">
🔒 Touch locked · hold top-left corner 3 s for controls
{canFullscreen && !fullscreen ? " · tap for full screen" : ""}
</p>
</div>
)}
{controls && (
<div
data-controls
className="absolute inset-x-0 top-0 flex flex-wrap items-center gap-2 bg-black/85 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3"
onPointerDown={(e) => {
e.stopPropagation();
showControls(); // keep them up while being used
}}
>
<button
type="button"
onClick={onClose}
className="min-h-12 rounded-lg bg-white/10 px-4 text-sm font-medium text-fg active:bg-white/20"
>
✕ Close
</button>
{showing.screen && (
<div className="flex rounded-lg bg-white/10 p-1">
{(["full", "edge"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => onMode(m)}
className={`min-h-10 rounded-md px-3 text-sm ${mode === m ? "bg-fg text-ink" : "text-fg"}`}
>
{m === "full" ? "Full grid" : "Edges"}
</button>
))}
</div>
)}
<span className="min-w-0 flex-1 truncate px-1 text-right font-mono text-xs text-white/70">
{showing.label} {showing.detail && `· ${showing.detail}`}
</span>
<a
href={showing.src}
download
className="flex min-h-12 items-center rounded-lg bg-white/10 px-4 text-sm text-fg active:bg-white/20"
>
Save
</a>
<button
type="button"
onClick={lock}
className="min-h-12 rounded-lg bg-accent px-4 text-sm font-semibold text-ink active:opacity-80"
>
🔒 Lock
</button>
</div>
)}
</div>
);
}