Add /set: on-set screen tracking markers for crews
Deploy / deploy (push) Successful in 30s

- Unlinked, noindex page listing marker PNGs from public/set/markers
  (discovered at build time; filenames encode device/resolution/orientation)
- Detects the current screen and offers the closest match first
- One-tap full-screen viewer: wake lock, hidden controls, full grid/edges
  toggle, fill vs fit by aspect, back button closes, deep links via hash
- Offline service worker + home-screen manifest for iPhone full screen
- Marketing chrome moved into an (site) route group so /set has none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-06 09:11:36 +02:00
parent e08bc7e758
commit ca122ad723
43 changed files with 692 additions and 17 deletions
+3
View File
@@ -43,3 +43,6 @@ next-env.d.ts
# source media (web versions live in public/)
content/*.mp4
content/*.pdf
# source copies of screen markers (served from public/set/markers)
content/screen_markers/
+18
View File
@@ -21,6 +21,24 @@ All copy (studio info, services, projects, contact email) lives in
- **Logo:** the fox mark is a vector in `components/Fox.tsx` / `public/fox.svg`,
traced from `G:\_ADMIN\TWOTALESLOGO.png`. Favicons are `app/icon.png` and `app/apple-icon.png`.
## On-set screen markers (`/set`)
`twotalesvfx.com/set` is a tool page for production crews (not linked from the site,
not indexed). It lists screen-tracking marker images, highlights the one matching the
device it's opened on, and shows it full-screen in one tap. It works offline after the
first visit.
To add a device, drop PNGs into `public/set/markers/` named like:
```
markers_phone_iPhone-Pro_1179x2556.png # full dot grid
edge-markers_phone_iPhone-Pro_2556x1179_landscape.png # edges only
markers_laptop_1920x1080.png # name is optional
```
Kinds: `phone`, `tablet`, `laptop`, `monitor`. Any other PNG shows under "Other".
Bump `CACHE` in `public/set/sw.js` when replacing an existing image so phones fetch the new one.
## Structure
```
+13
View File
@@ -0,0 +1,13 @@
import Footer from "@/components/Footer";
import Header from "@/components/Header";
// Marketing pages: header, footer and film grain. Tools like /set live outside this group.
export default function SiteLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grain flex min-h-dvh flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
+1 -7
View File
@@ -1,7 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Josefin_Sans } from "next/font/google";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { site } from "@/content/site";
import "./globals.css";
@@ -40,11 +38,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
lang="en"
className={`${geistSans.variable} ${geistMono.variable} ${josefin.variable} antialiased`}
>
<body className="grain flex min-h-dvh flex-col font-sans">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
<body className="min-h-dvh font-sans">{children}</body>
</html>
);
}
+22 -10
View File
@@ -1,18 +1,30 @@
import Link from "next/link";
import Footer from "@/components/Footer";
import Fox from "@/components/Fox";
import Header from "@/components/Header";
export default function NotFound() {
return (
<div className="flex min-h-[80vh] flex-col items-center justify-center px-5 text-center">
<Fox className="h-16 w-16 text-white/20" />
<p className="mt-8 font-mono text-xs tracking-[0.3em] text-accent uppercase">Error 404 · Frame missing</p>
<h1 className="mt-4 font-display text-5xl font-thin uppercase md:text-7xl">Shot not found</h1>
<Link
href="/"
className="mt-10 border border-white/25 px-6 py-3.5 font-mono text-xs tracking-[0.2em] uppercase hover:border-fg"
>
Back to home
</Link>
<div className="grain flex min-h-dvh flex-col">
<Header />
<main className="flex-1">
<div className="flex min-h-[80vh] flex-col items-center justify-center px-5 text-center">
<Fox className="h-16 w-16 text-white/20" />
<p className="mt-8 font-mono text-xs tracking-[0.3em] text-accent uppercase">
Error 404 · Frame missing
</p>
<h1 className="mt-4 font-display text-5xl font-thin uppercase md:text-7xl">
Shot not found
</h1>
<Link
href="/"
className="mt-10 border border-white/25 px-6 py-3.5 font-mono text-xs tracking-[0.2em] uppercase hover:border-fg"
>
Back to home
</Link>
</div>
</main>
<Footer />
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata, Viewport } from "next";
import MarkerPicker from "@/components/set/MarkerPicker";
import { loadMarkers } from "@/lib/markers";
// On-set screen tracking markers. Deliberately not linked from the site,
// kept out of search results and the sitemap.
export const metadata: Metadata = {
title: "Screen markers",
description: "Two Tales VFX screen tracking markers for production crews.",
robots: { index: false, follow: false },
manifest: "/set/manifest.webmanifest",
appleWebApp: { capable: true, title: "Markers", statusBarStyle: "black-translucent" },
};
export const viewport: Viewport = {
themeColor: "#000000",
viewportFit: "cover",
};
export default function SetPage() {
const { screens, extras } = loadMarkers();
return (
<main className="min-h-dvh bg-ink">
<MarkerPicker screens={screens} extras={extras} />
</main>
);
}
+445
View File
@@ -0,0 +1,445 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
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";
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"]);
function setHash(hash: string, push: boolean) {
history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname);
window.dispatchEvent(new Event(HASH_EVENT));
}
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 warm its cache with every marker
useEffect(() => {
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker
.register("/set/sw.js", { scope: "/set/" })
.then(async () => {
setOffline("saving");
const urls = [...screens.flatMap((s) => [s.full, s.edge]), ...extras.map((e) => e.src)].filter(Boolean) as string[];
await Promise.all(urls.map((u) => fetch(u).catch(() => null)));
setOffline("ready");
})
.catch(() => {});
}, [screens, extras]);
const showScreen = (s: MarkerScreen) => {
setHash(`#${s.id}/${mode}`, true);
requestFullscreen();
};
const showExtra = (e: MarkerExtra) => {
setHash(`#${e.id}`, true);
requestFullscreen();
};
const close = useCallback(() => {
if (history.state?.marker) history.back();
else setHash("", false);
}, []);
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>
{/* 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>
))}
{extras.length > 0 && (
<section className="mt-8">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">Other</h2>
<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>
)}
<p className="mt-10 text-center text-xs leading-relaxed text-muted">
Tap the image for controls · Back or Esc to close · Screen stays awake while showing
</p>
{showing && (
<Viewer showing={showing} mode={showing.mode} onMode={switchMode} onClose={close} />
)}
</div>
);
}
function Viewer({
showing,
mode,
onMode,
onClose,
}: {
showing: Showing;
mode: Mode;
onMode: (m: Mode) => void;
onClose: () => void;
}) {
const [controls, setControls] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const hideTimer = useRef<number | undefined>(undefined);
// 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;
// Keep the screen awake while a marker is up
useEffect(() => {
let lock: WakeLockSentinel | null = null;
const acquire = async () => {
try {
lock = (await navigator.wakeLock?.request("screen")) ?? null;
} catch {}
};
const onVisible = () => document.visibilityState === "visible" && acquire();
acquire();
document.addEventListener("visibilitychange", onVisible);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("visibilitychange", onVisible);
document.body.style.overflow = "";
lock?.release().catch(() => {});
};
}, []);
useEffect(() => {
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
onFs();
document.addEventListener("fullscreenchange", onFs);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !document.fullscreenElement) onClose();
if (e.key === "f") onMode("full");
if (e.key === "e") onMode("edge");
if (e.key === " ") setControls((v) => !v);
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("fullscreenchange", onFs);
document.removeEventListener("keydown", onKey);
};
}, [onClose, onMode]);
const poke = () => {
window.clearTimeout(hideTimer.current);
setControls((v) => {
if (!v) hideTimer.current = window.setTimeout(() => setControls(false), 4000);
return !v;
});
};
return (
<div
className={`fixed inset-0 z-[100] bg-black ${controls ? "" : "cursor-none"}`}
onClick={() => {
if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {});
poke();
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={showing.src} alt={showing.label} className={`h-full w-full select-none ${fit}`} draggable={false} />
{canFullscreen && !fullscreen && !controls && (
<p className="pointer-events-none absolute inset-x-0 bottom-6 text-center font-mono text-xs text-white/70">
Tap for full screen
</p>
)}
{controls && (
<div
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"
onClick={(e) => e.stopPropagation()}
>
<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>
</div>
)}
</div>
);
}
+10
View File
@@ -23,6 +23,16 @@ server {
add_header Cache-Control "public, max-age=604800";
}
# Offline worker for /set must always be revalidated so updates roll out
location = /set/sw.js {
add_header Cache-Control "no-cache";
}
location ~* \.webmanifest$ {
types { application/manifest+json webmanifest; }
add_header Cache-Control "no-cache";
}
error_page 404 /404.html;
gzip on;
+85
View File
@@ -0,0 +1,85 @@
import fs from "node:fs";
import path from "node:path";
// Screen-tracking marker images for on-set use (served at /set).
// Files live in public/set/markers and are discovered at build time, so adding
// a device is just dropping in a PNG named like:
// markers_phone_iPhone-Pro_1179x2556.png
// edge-markers_phone_iPhone-Pro_2556x1179_landscape.png
// markers_laptop_1920x1080.png (no device name)
// plain_grey_128.png (anything else = "extra")
export type Kind = "phone" | "tablet" | "laptop" | "monitor";
export type MarkerScreen = {
id: string; // stable key, e.g. "phone-iphone-pro-1179x2556"
kind: Kind;
name: string; // "iPhone Pro"
width: number;
height: number;
landscape: boolean;
full?: string; // /set/markers/markers_….png
edge?: string; // /set/markers/edge-markers_….png
};
export type MarkerExtra = { id: string; name: string; src: string };
const DIR = path.join(process.cwd(), "public", "set", "markers");
const URL_BASE = "/set/markers/";
const ORDER: Kind[] = ["phone", "tablet", "laptop", "monitor"];
const PATTERN = /^(edge-markers|markers)_(phone|tablet|laptop|monitor)_(?:(.+?)_)?(\d+)x(\d+)(_landscape)?\.png$/i;
function prettyName(raw: string | undefined, kind: Kind, w: number, h: number) {
if (!raw) {
if (kind === "laptop" && Math.max(w, h) === 1920) return "Laptop Full HD";
return `${kind[0].toUpperCase()}${kind.slice(1)} ${w}×${h}`;
}
return raw
.replace(/-/g, " ")
.replace(/ProMax/g, "Pro Max")
.replace(/(\d+(\.\d+)?)$/, "$1″")
.replace(/^4K$/, "4K Monitor");
}
export function loadMarkers(): { screens: MarkerScreen[]; extras: MarkerExtra[] } {
const files = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.toLowerCase().endsWith(".png")) : [];
const screens = new Map<string, MarkerScreen>();
const extras: MarkerExtra[] = [];
for (const file of files.sort()) {
const m = file.match(PATTERN);
if (!m) {
const name = file
.replace(/\.png$/i, "")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
extras.push({ id: file.replace(/\.png$/i, "").toLowerCase(), name, src: URL_BASE + file });
continue;
}
const [, variant, kindRaw, rawName, w, h, land] = m;
const kind = kindRaw.toLowerCase() as Kind;
const width = Number(w);
const height = Number(h);
const id = `${kind}-${(rawName ?? "generic").toLowerCase()}-${width}x${height}`;
const screen = screens.get(id) ?? {
id,
kind,
name: prettyName(rawName, kind, width, height),
width,
height,
landscape: Boolean(land) || width > height,
};
if (variant.toLowerCase() === "edge-markers") screen.edge = URL_BASE + file;
else screen.full = URL_BASE + file;
screens.set(id, screen);
}
const sorted = [...screens.values()].sort(
(a, b) =>
ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) ||
a.name.localeCompare(b.name) ||
Number(a.landscape) - Number(b.landscape),
);
return { screens: sorted, extras };
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Two Tales VFX Screen Markers",
"short_name": "Markers",
"start_url": "/set/",
"scope": "/set/",
"display": "fullscreen",
"orientation": "any",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icon.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/apple-icon.png", "sizes": "180x180", "type": "image/png" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+54
View File
@@ -0,0 +1,54 @@
// Offline cache for /set (screen markers). Crews often have no signal on set:
// once the page has loaded once, it and every marker image work offline.
const CACHE = "ttvfx-set-v1";
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(["/set/"])).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k.startsWith("ttvfx-set-") && k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Page: network first so updates arrive, cached copy when offline
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put("/set/", copy));
return res;
})
.catch(() => caches.match("/set/")),
);
return;
}
// Marker images, build assets, fonts, icons: cache first
if (url.pathname.startsWith("/set/") || url.pathname.startsWith("/_next/") || /\.(png|woff2|webmanifest)$/.test(url.pathname)) {
event.respondWith(
caches.match(req).then(
(hit) =>
hit ||
fetch(req).then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
}),
),
);
}
});