diff --git a/app/globals.css b/app/globals.css
index 618dfff..c43facc 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -88,3 +88,11 @@ body {
.grain::after, .animate-drift, .animate-scan { animation: none; }
.reveal { opacity: 1; transform: none; transition: none; }
}
+
+/* Printable VFX report (/set/report/view): A4 paper, keep the accent colours */
+@media print {
+ @page { size: A4; margin: 14mm 12mm; background: white; }
+ /* The site is dark-scheme; paper isn't (otherwise the page margins print dark) */
+ html, body { background: white !important; color-scheme: light; }
+ .report { print-color-adjust: exact; -webkit-print-color-adjust: exact; font-size: 11px; }
+}
diff --git a/app/set/report/page.tsx b/app/set/report/page.tsx
new file mode 100644
index 0000000..646c1fb
--- /dev/null
+++ b/app/set/report/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata, Viewport } from "next";
+import ShotReport from "@/components/set/ShotReport";
+
+// On-set VFX supervisor take log. Like /set, not linked from the site and
+// kept out of search results and the sitemap.
+export const metadata: Metadata = {
+ title: "VFX report",
+ description: "Two Tales VFX on-set VFX supervisor take log.",
+ robots: { index: false, follow: false },
+ manifest: "/set/manifest.webmanifest",
+ appleWebApp: { capable: true, title: "VFX report", statusBarStyle: "black-translucent" },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#000000",
+ viewportFit: "cover",
+};
+
+export default function ReportPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/set/report/view/page.tsx b/app/set/report/view/page.tsx
new file mode 100644
index 0000000..12075d4
--- /dev/null
+++ b/app/set/report/view/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata, Viewport } from "next";
+import ReportView from "@/components/set/ReportView";
+
+// Printable on-set VFX report for post. Opened from a shared link (the day's log rides in
+// the #fragment) or on the supervisor's own device. Not linked from the site, not indexed.
+export const metadata: Metadata = {
+ title: "VFX shoot report",
+ description: "Two Tales VFX on-set VFX supervisor report.",
+ robots: { index: false, follow: false },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#0a0a0b",
+};
+
+export default function ReportViewPage() {
+ return (
+
+
+
+ );
+}
diff --git a/components/set/MarkerPicker.tsx b/components/set/MarkerPicker.tsx
index 9a0cc79..5de4e03 100644
--- a/components/set/MarkerPicker.tsx
+++ b/components/set/MarkerPicker.tsx
@@ -373,6 +373,16 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
→
+
+
+ VFX report
+ Log camera, references and notes for every take
+
+ →
+
Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake
diff --git a/components/set/ReportView.tsx b/components/set/ReportView.tsx
new file mode 100644
index 0000000..d1b3881
--- /dev/null
+++ b/components/set/ReportView.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { site } from "@/content/site";
+import { type Cam, type Report, type Take, CAM_FIELDS, clock, decodeReport, getPhoto, load, photoName, toCsv } from "@/lib/report";
+
+// The day's report as a printed sheet: white paper on screen, A4 on print. Settings that
+// stay the same for a whole setup are pulled up into one line, so the take table only
+// shows what changed between takes.
+
+type Source = "link" | "device";
+type State =
+ | { status: "loading" }
+ | { status: "error" }
+ | { status: "ready"; report: Report; source: Source; photos: Record };
+type Setup = { key: string; scene: string; shot: string; takes: Take[] };
+type Col = { key: keyof Cam; label: string };
+
+const COLS: Col[] = [{ key: "roll", label: "Roll" }, { key: "clip", label: "Clip" }, ...CAM_FIELDS.map(([key, label]) => ({ key, label }))];
+// How a constant setting reads in the setup summary
+const SUMMARY: Partial string>> = {
+ lens: (v) => `${v}mm`,
+ stop: (v) => `T${v}`,
+ focus: (v) => `Focus ${v}`,
+ height: (v) => `Height ${v}`,
+ tilt: (v) => `Tilt ${v}${/°$/.test(v) ? "" : "°"}`,
+ fps: (v) => `${v} fps`,
+ shutter: (v) => `Shutter ${v}`,
+ iso: (v) => `ISO ${v}`,
+ roll: (v) => `Roll ${v}`,
+ filter: (v) => v,
+};
+
+function setupsOf(takes: Take[]): Setup[] {
+ const out: Setup[] = [];
+ for (const t of takes) {
+ const key = `${t.scene}/${t.shot}`;
+ const s = out.find((x) => x.key === key);
+ if (s) s.takes.push(t);
+ else out.push({ key, scene: t.scene, shot: t.shot, takes: [t] });
+ }
+ return out;
+}
+
+const uniq = (a: T[]) => [...new Set(a)];
+
+export default function ReportView() {
+ const [state, setState] = useState({ status: "loading" });
+
+ useEffect(() => {
+ const urls: string[] = [];
+ (async () => {
+ const hash = location.hash.slice(1);
+ if (hash) {
+ try {
+ setState({ status: "ready", report: await decodeReport(hash), source: "link", photos: {} });
+ } catch {
+ setState({ status: "error" });
+ }
+ return;
+ }
+ // No link data: this device's own log, with its photos
+ const { meta, takes } = load();
+ const photos: Record = {};
+ for (const id of takes.flatMap((t) => t.photos)) {
+ try {
+ const b = await getPhoto(id);
+ if (b) urls.push((photos[id] = URL.createObjectURL(b)));
+ } catch {}
+ }
+ setState({ status: "ready", report: { meta, takes }, source: "device", photos });
+ })();
+ return () => urls.forEach((u) => URL.revokeObjectURL(u));
+ }, []);
+
+ // The document title becomes the PDF's file name
+ useEffect(() => {
+ if (state.status !== "ready") return;
+ const m = state.report.meta;
+ document.title = ["VFX report", m.production, m.day && `Day ${m.day}`].filter(Boolean).join(" - ");
+ }, [state]);
+
+ if (state.status === "loading") return ;
+ if (state.status === "error")
+ return (
+
+ This report link is incomplete. Some mail apps cut long links short: ask for it to be sent again, or as a
+ message instead.
+
+ );
+
+ const { report, source, photos } = state;
+ const { meta, takes } = report;
+ const setups = setupsOf(takes);
+ const first = takes[0]?.at;
+ const last = takes[takes.length - 1]?.at;
+ const date = first
+ ? new Date(first).toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" })
+ : "";
+ const cams = uniq(takes.flatMap((t) => t.cams.map((c) => c.id)));
+ const photoCount = takes.reduce((n, t) => n + t.photos.length, 0);
+ const tagCounts = uniq(takes.flatMap((t) => t.tags)).map((tag) => ({
+ tag,
+ n: setups.filter((s) => s.takes.some((t) => t.tags.includes(tag))).length,
+ }));
+
+ const downloadCsv = () => {
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(new Blob([toCsv(report)], { type: "text/csv" }));
+ a.download = `${document.title}.csv`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ };
+
+ return (
+
+ {/* Toolbar (screen only) */}
+
+ {source === "device" ? (
+
+ ← Logger
+
+ ) : (
+ Shared report
+ )}
+
+
+
+
+
+
+ {/* Masthead */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ Two Tales VFX
+
+ On-set VFX report
+
+
+
+ {meta.production || "Untitled production"}
+
+
+
+
+
+
+
+
+ {/* Day at a glance */}
+
+
+
+ t.rating === "circle").length} label="Circled" accent />
+
+
+
+
+
+ {tagCounts.length > 0 && (
+
+
+
+ {tagCounts.map(({ tag, n }) => (
+
+ {tag} · {n} setup{n > 1 ? "s" : ""}
+
+ ))}
+
+
+ )}
+
+ {setups.length === 0 && No takes logged yet.
}
+
+ {setups.map((s) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function SetupBlock({ setup, photos, source }: { setup: Setup; photos: Record; source: Source }) {
+ const rows = setup.takes.flatMap((t) => t.cams.map((c, i) => ({ t, c, first: i === 0, span: t.cams.length })));
+ const multiCam = uniq(rows.map((r) => r.c.id)).length > 1 || rows.some((r) => r.c.id !== "A");
+ // Same value on every row: say it once above the table. Different: a table column.
+ const constant: [Col, string][] = [];
+ const varying: Col[] = [];
+ for (const col of COLS) {
+ const vals = uniq(rows.map((r) => r.c[col.key]));
+ if (vals.length === 1) {
+ if (vals[0] && col.key !== "clip") constant.push([col, vals[0]]);
+ else if (vals[0]) varying.push(col);
+ } else varying.push(col);
+ }
+ const tags = uniq(setup.takes.flatMap((t) => t.tags));
+ const refs = uniq(setup.takes.flatMap((t) => t.refs));
+ const shots = setup.takes.flatMap((t) => t.photos.map((p, i) => ({ t, p, name: source === "link" ? p : photoName(t, i) })));
+ const start = setup.takes[0].at;
+ const end = setup.takes[setup.takes.length - 1].at;
+ const circled = setup.takes.filter((t) => t.rating === "circle").map((t) => t.take);
+
+ return (
+
+
+
+ Sc
+ {setup.scene}
+ /
+ Shot
+ {setup.shot}
+
+
+
+ {setup.takes.length} take{setup.takes.length > 1 ? "s" : ""}
+ {circled.length > 0 && · circled {circled.join(", ")}}
+
+ {clock(start)}
+ {end !== start && `–${clock(end)}`}
+
+
+
+
+
+ {constant.length > 0 && (
+
+
+ {constant.map(([col, v]) => (SUMMARY[col.key] ?? ((x: string) => `${col.label} ${x}`))(v)).join(" · ")}
+
+ )}
+ {tags.length > 0 && (
+
+
+ {tags.join(", ")}
+
+ )}
+
+ {refs.length > 0 && (
+
+ {refs.map((r) => (
+ -
+ ✓ {r}
+
+ ))}
+
+ )}
+
+
+ {/* Takes: scrolls sideways inside itself on a phone, never the page */}
+
+
+
+
+ | Take |
+ {multiCam && Cam | }
+ {varying.map((c) => (
+
+ {c.label}
+ |
+ ))}
+ Notes |
+
+
+
+ {rows.map(({ t, c, first, span }) => (
+
+ {first && (
+ |
+
+ |
+ )}
+ {multiCam && {c.id} | }
+ {varying.map((col) => (
+
+ {c[col.key] || –}
+ |
+ ))}
+ {first && (
+
+ {t.rating === "ng" && NG}
+ {t.notes}
+ |
+ )}
+
+ ))}
+
+
+
+
+ {shots.length > 0 &&
+ (source === "device" ? (
+
+ {shots.map(({ p, name }) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ {photos[p] &&
}
+ {name}
+
+ ))}
+
+ ) : (
+
+
+ {shots.map((s) => s.name).join(" · ")}
+
+ ))}
+
+ );
+}
+
+function TakeNo({ t }: { t: Take }) {
+ if (t.rating === "circle")
+ return (
+
+ {t.take}
+
+ );
+ return (
+
+ T{t.take}
+ {t.rating === "good" && Good}
+
+ );
+}
+
+function Meta({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
+ return (
+
+
{label}
+ {value || –}
+
+ );
+}
+
+function Stat({ n, label, accent, small }: { n: number | string; label: string; accent?: boolean; small?: boolean }) {
+ return (
+
+ );
+}
+
+function Label({ children, inline }: { children: React.ReactNode; inline?: boolean }) {
+ return (
+ {children}
+ );
+}
diff --git a/components/set/ShotReport.tsx b/components/set/ShotReport.tsx
new file mode 100644
index 0000000..6cccf20
--- /dev/null
+++ b/components/set/ShotReport.tsx
@@ -0,0 +1,694 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
+import {
+ type Cam, type Rating, type Store, type Take,
+ CAM_FIELDS, STORE_KEY, blankTake, blobToDataUrl, clearPhotos, delPhoto, encodeReport, getPhoto,
+ clock, load, normalise, photoName, putPhoto, shrink, toCsv, today, uid,
+} from "@/lib/report";
+
+// On-set VFX take log. Built for one thumb between takes: everything carries over from
+// the last take, "Save · next take" bumps the take number (and the clip name), so most
+// takes are one tap plus whatever changed. At wrap: a report link for post (/set/report/view),
+// CSV + photos, and a .json backup that carries everything (photos inline) to another device.
+
+// Must match CACHE in public/set/sw.js
+const OFFLINE_CACHE = "ttvfx-set-v1";
+
+const REFS = ["Grey ball", "Chrome ball", "Colour chart", "Clean plate", "HDRI", "Witness cam", "Set photos", "Lidar / scan", "Measurements", "Markers"];
+const TAGS = ["Green screen", "Blue screen", "Screen replace", "Clean-up", "Set extension", "CG character", "FX / sim", "Muzzle flash", "Stunt rig", "Driving", "Crowd"];
+const RATINGS: [Rating, string][] = [
+ ["circle", "◯ Circled"],
+ ["good", "Good"],
+ ["ng", "NG"],
+];
+
+// A003C007 -> A003C008: bump the last run of digits, keeping its width
+function bumpClip(clip: string) {
+ return clip.replace(/(\d+)(?!.*\d)/, (d) => String(Number(d) + 1).padStart(d.length, "0"));
+}
+
+// Next take keeps the whole setup; only per-take things reset
+function nextFrom(t: Take, newShot = false): Take {
+ return {
+ ...t,
+ id: uid(),
+ at: 0,
+ shot: newShot ? "" : t.shot,
+ take: newShot ? 1 : t.take + 1,
+ cams: t.cams.map((c) => ({ ...c, clip: c.clip ? bumpClip(c.clip) : "" })),
+ refs: newShot ? [] : t.refs,
+ rating: "",
+ notes: "",
+ photos: [],
+ };
+}
+
+// iPhone/iPad: share sheet (AirDrop, Mail, Save to Files). Elsewhere: plain downloads.
+async function shareFiles(files: File[]) {
+ if (navigator.canShare?.({ files })) {
+ try {
+ await navigator.share({ files, title: files[0].name });
+ return;
+ } catch (e) {
+ if ((e as Error).name === "AbortError") return;
+ }
+ }
+ for (const f of files) {
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(f);
+ a.download = f.name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ }
+}
+
+export default function ShotReport() {
+ const [store, setStore] = useState(null);
+ const [view, setView] = useState<"take" | "log" | "day">("take");
+ const [editing, setEditing] = useState(false); // draft is an existing take from the log
+ const [cam, setCam] = useState(0);
+ const [flash, setFlash] = useState("");
+ const [busy, setBusy] = useState("");
+ const [offline, setOffline] = useState(false);
+ const photoInput = useRef(null);
+ const restoreInput = useRef(null);
+
+ // Storage is read after mount so the static HTML matches on first paint
+ useEffect(() => {
+ setStore(load()); // eslint-disable-line react-hooks/set-state-in-effect
+ }, []);
+ useEffect(() => {
+ if (!store) return;
+ try {
+ localStorage.setItem(STORE_KEY, JSON.stringify(store));
+ } catch {}
+ }, [store]);
+
+ // 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/report/", "/set/report/view/", ...new Set(assets)]);
+ if (!cancelled) setOffline(true);
+ } catch {}
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!flash) return;
+ const t = setTimeout(() => setFlash(""), 1600);
+ return () => clearTimeout(t);
+ }, [flash]);
+
+ const lenses = useMemo(() => {
+ const seen = new Set();
+ for (const t of [...(store?.takes ?? [])].reverse()) for (const c of t.cams) if (c.lens) seen.add(c.lens);
+ return [...seen].slice(0, 8);
+ }, [store?.takes]);
+
+ if (!store) return ;
+
+ const d = store.draft;
+ const c = d.cams[Math.min(cam, d.cams.length - 1)];
+ const setDraft = (patch: Partial) => setStore({ ...store, draft: { ...d, ...patch } });
+ const setCamField = (k: keyof Cam, v: string) =>
+ setDraft({ cams: d.cams.map((x) => (x.id === c.id ? { ...x, [k]: v } : x)) });
+ const toggle = (k: "refs" | "tags", v: string) =>
+ setDraft({ [k]: d[k].includes(v) ? d[k].filter((x) => x !== v) : [...d[k], v] });
+
+ const save = (newShot = false) => {
+ if (!d.scene.trim() || !d.shot.trim()) {
+ setFlash("Scene and shot first");
+ return;
+ }
+ const saved = { ...d, at: d.at || Date.now() };
+ const exists = store.takes.some((t) => t.id === d.id);
+ const takes = exists ? store.takes.map((t) => (t.id === d.id ? saved : t)) : [...store.takes, saved];
+ // After editing an old take, carry on from the latest one rather than the edited one
+ const base = editing ? takes[takes.length - 1] : saved;
+ setStore({ ...store, takes, draft: nextFrom(base, newShot) });
+ setFlash(`Saved ${saved.scene}/${saved.shot} T${saved.take}`);
+ setEditing(false);
+ setCam(0);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ const edit = (t: Take) => {
+ setStore({ ...store, draft: structuredClone(t) });
+ setEditing(true);
+ setCam(0);
+ setView("take");
+ window.scrollTo({ top: 0 });
+ };
+
+ const cancelEdit = () => {
+ const last = store.takes[store.takes.length - 1];
+ setStore({ ...store, draft: last ? nextFrom(last) : blankTake() });
+ setEditing(false);
+ };
+
+ const remove = (t: Take) => {
+ if (!confirm(`Delete ${t.scene}/${t.shot} take ${t.take}?`)) return;
+ setStore({ ...store, takes: store.takes.filter((x) => x.id !== t.id) });
+ t.photos.forEach((id) => delPhoto(id).catch(() => {}));
+ };
+
+ const addPhotos = async (files: FileList) => {
+ setBusy("Saving photos…");
+ try {
+ const ids: string[] = [];
+ for (const f of Array.from(files)) {
+ const id = uid();
+ await putPhoto(id, await shrink(f));
+ ids.push(id);
+ }
+ setStore((s) => s && { ...s, draft: { ...s.draft, photos: [...s.draft.photos, ...ids] } });
+ } catch {
+ setFlash("Couldn't save that photo");
+ }
+ setBusy("");
+ };
+
+ const removePhoto = (id: string) => {
+ setDraft({ photos: d.photos.filter((x) => x !== id) });
+ // A logged take still uses it until the edit is saved
+ if (!store.takes.some((t) => t.photos.includes(id))) delPhoto(id).catch(() => {});
+ };
+
+ const fileBase = () => {
+ const m = store.meta;
+ return `VFX-report_${[m.production, m.day ? `day${m.day}` : "", today()].filter(Boolean).join("_").replace(/\s+/g, "-")}`;
+ };
+
+ // CSV plus every photo, named to match the CSV's Photos column
+ // The whole day rides in the link's #fragment: nothing is uploaded, the server never sees it
+ const shareLink = async () => {
+ const url = `${location.origin}/set/report/view/#${await encodeReport(store)}`;
+ const m = store.meta;
+ const title = `VFX report${m.production ? ` · ${m.production}` : ""}${m.day ? ` · Day ${m.day}` : ""}`;
+ if (navigator.share) {
+ try {
+ await navigator.share({ title, url });
+ return;
+ } catch (e) {
+ if ((e as Error).name === "AbortError") return;
+ }
+ }
+ try {
+ await navigator.clipboard.writeText(url);
+ setFlash("Link copied");
+ } catch {
+ window.open(url, "_blank");
+ }
+ };
+
+ const exportReport = async () => {
+ setBusy("Preparing…");
+ const files = [new File([toCsv(store)], `${fileBase()}.csv`, { type: "text/csv" })];
+ try {
+ for (const t of store.takes)
+ for (const [i, id] of t.photos.entries()) {
+ const b = await getPhoto(id);
+ if (b) files.push(new File([b], photoName(t, i), { type: "image/jpeg" }));
+ }
+ } catch {}
+ setBusy("");
+ await shareFiles(files);
+ };
+
+ // One self-contained file: the whole log with photos inline
+ const backup = async () => {
+ setBusy("Preparing…");
+ const photos: Record = {};
+ try {
+ for (const t of [...store.takes, store.draft])
+ for (const id of t.photos) {
+ const b = await getPhoto(id);
+ if (b) photos[id] = await blobToDataUrl(b);
+ }
+ } catch {}
+ const json = JSON.stringify({ app: "ttvfx-report", version: 1, ...store, photos });
+ setBusy("");
+ await shareFiles([new File([json], `${fileBase()}.json`, { type: "application/json" })]);
+ };
+
+ const restore = async (f: File) => {
+ try {
+ const data = JSON.parse(await f.text());
+ if (data.app !== "ttvfx-report") throw new Error();
+ const n = data.takes?.length ?? 0;
+ if (store.takes.length && !confirm(`Replace the ${store.takes.length} takes on this device with the ${n} in this backup?`)) return;
+ setBusy("Restoring…");
+ await clearPhotos();
+ for (const [id, url] of Object.entries(data.photos ?? {}) as [string, string][])
+ await putPhoto(id, await (await fetch(url)).blob());
+ setStore(normalise(data));
+ setEditing(false);
+ setFlash(`Restored ${n} takes`);
+ } catch {
+ setFlash("That isn't a VFX report backup");
+ }
+ setBusy("");
+ };
+
+ const newDay = () => {
+ if (!confirm("Start a new day? This clears every logged take and photo on this device. Back up first.")) return;
+ setStore({ meta: { ...store.meta, day: store.meta.day ? String(Number(store.meta.day) + 1 || "") : "" }, takes: [], draft: blankTake() });
+ clearPhotos().catch(() => {});
+ setEditing(false);
+ setView("take");
+ };
+
+ return (
+
+
+
+ ← Markers
+
+ VFX report
+ {offline ? "✓ Offline" : ""}
+
+
+
+
+ {view === "take" && (
+ <>
+ {/* Slate */}
+
+ setDraft({ scene: v })} />
+ setDraft({ shot: v })} />
+
+
Take
+
+ setDraft({ take: Math.max(1, d.take - 1) })}>−
+ setDraft({ take: Math.max(1, Number(e.target.value.replace(/\D/g, "")) || 1) })}
+ className="w-12 bg-transparent text-center font-mono text-3xl outline-none"
+ aria-label="Take"
+ />
+ setDraft({ take: d.take + 1 })}>+
+
+
+
+
+ {/* Rating */}
+
+ {RATINGS.map(([r, l]) => (
+
+ ))}
+
+
+ {/* Cameras */}
+
Camera
+
+ {d.cams.map((x, i) => (
+
+ ))}
+ {d.cams.length < 6 && (
+
+ )}
+ {d.cams.length > 1 && (
+
+ )}
+
+
+
+ setCamField("roll", v.toUpperCase())} />
+ setCamField("clip", v.toUpperCase())} />
+
+ {lenses.length > 0 && (
+
+ {lenses.map((l) => (
+ setCamField("lens", l)}>
+ {l}mm
+
+ ))}
+
+ )}
+
+ {CAM_FIELDS.map(([k, l, mode, ph]) => (
+ setCamField(k, v)} />
+ ))}
+
+
+
VFX
+
+ {TAGS.map((t) => (
+ toggle("tags", t)}>
+ {t}
+
+ ))}
+
+
+
References shot
+
+ {REFS.map((t) => (
+ toggle("refs", t)} check>
+ {t}
+
+ ))}
+
+
+
Photos
+
+ {d.photos.map((id) => (
+ removePhoto(id)} />
+ ))}
+
+
+
{
+ if (e.target.files?.length) addPhotos(e.target.files);
+ e.target.value = "";
+ }}
+ />
+
+
Notes
+
+ );
+}
+
+function Log({ takes, onEdit, onDelete, onExport }: { takes: Take[]; onEdit: (t: Take) => void; onDelete: (t: Take) => void; onExport: () => void }) {
+ if (!takes.length) return No takes logged yet.
;
+ // Group by scene/shot, newest setup first
+ const groups: { key: string; takes: Take[] }[] = [];
+ for (const t of takes) {
+ const key = `${t.scene} / ${t.shot}`;
+ const g = groups.find((x) => x.key === key);
+ if (g) g.takes.push(t);
+ else groups.push({ key, takes: [t] });
+ }
+ groups.reverse();
+ return (
+
+
+ {groups.map((g) => (
+
+
{g.key}
+
+ {g.takes.map((t) => (
+ -
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+function Thumb({ id, onRemove }: { id: string; onRemove: () => void }) {
+ const [url, setUrl] = useState("");
+ useEffect(() => {
+ let u = "";
+ getPhoto(id)
+ .then((b) => {
+ if (b) setUrl((u = URL.createObjectURL(b)));
+ })
+ .catch(() => {});
+ return () => {
+ if (u) URL.revokeObjectURL(u);
+ };
+ }, [id]);
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ {url &&

}
+
+
+ );
+}
+
+function Heading({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+function BigField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
+ return (
+
+ );
+}
+
+function Field({
+ label,
+ value,
+ onChange,
+ placeholder,
+ inputMode = "text",
+}: {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ inputMode?: "decimal" | "text";
+}) {
+ return (
+
+ );
+}
+
+function Chip({ on, onClick, check, children }: { on: boolean; onClick: () => void; check?: boolean; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function StepBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/lib/report.ts b/lib/report.ts
new file mode 100644
index 0000000..229a241
--- /dev/null
+++ b/lib/report.ts
@@ -0,0 +1,183 @@
+// Shared by the on-set take logger (/set/report) and the printable report (/set/report/view).
+// No server: takes live in localStorage, photos in IndexedDB. A report link carries the day's
+// log compressed in the URL fragment, which browsers never send to the server.
+
+export type Cam = {
+ id: string;
+ roll: string;
+ clip: string;
+ lens: string;
+ stop: string;
+ focus: string;
+ height: string;
+ tilt: string;
+ fps: string;
+ shutter: string;
+ iso: string;
+ filter: string;
+};
+export type Rating = "" | "good" | "circle" | "ng";
+export type Take = {
+ id: string;
+ at: number;
+ scene: string;
+ shot: string;
+ take: number;
+ cams: Cam[];
+ refs: string[];
+ tags: string[];
+ rating: Rating;
+ notes: string;
+ photos: string[]; // ids in the photo store (file names in a shared link)
+};
+export type Meta = { production: string; day: string; unit: string; supervisor: string };
+export type Store = { meta: Meta; takes: Take[]; draft: Take };
+
+export const STORE_KEY = "ttvfx-report-v1";
+const PHOTO_MAX = 2400; // px, long edge: plenty for set reference, keeps the device store small
+
+export const CAM_FIELDS: [keyof Cam, string, "decimal" | "text", string][] = [
+ ["lens", "Lens mm", "decimal", "35"],
+ ["stop", "T-stop", "decimal", "2.8"],
+ ["focus", "Focus", "text", "3m"],
+ ["height", "Height", "text", "1.4m"],
+ ["tilt", "Tilt °", "text", "-5"],
+ ["fps", "FPS", "decimal", "25"],
+ ["shutter", "Shutter", "text", "180°"],
+ ["iso", "ISO / EI", "decimal", "800"],
+ ["filter", "Filter / ND", "text", "ND.6"],
+];
+
+export const uid = () => Math.random().toString(36).slice(2, 10);
+export const today = () => new Date().toISOString().slice(0, 10);
+export const blankCam = (id = "A"): Cam => ({ id, roll: "", clip: "", lens: "", stop: "", focus: "", height: "", tilt: "", fps: "25", shutter: "180°", iso: "", filter: "" });
+export const blankTake = (): Take => ({ id: uid(), at: 0, scene: "", shot: "", take: 1, cams: [blankCam()], refs: [], tags: [], rating: "", notes: "", photos: [] });
+export const fresh = (): Store => ({ meta: { production: "", day: "", unit: "", supervisor: "" }, takes: [], draft: blankTake() });
+
+// SC12_SH3A_T4_2.jpg
+export const photoName = (t: Take, i: number) => `SC${t.scene}_SH${t.shot}_T${t.take}_${i + 1}.jpg`.replace(/[^\w.-]+/g, "-");
+
+// Fill in fields added since the data was saved (or missing from a backup or link)
+export function normalise(s: Partial): Store {
+ const f = fresh();
+ const take = (t: Partial): Take => ({
+ ...blankTake(),
+ ...t,
+ cams: (t.cams?.length ? t.cams : [blankCam()]).map((c) => ({ ...blankCam(c.id), ...c })),
+ refs: t.refs ?? [],
+ tags: t.tags ?? [],
+ photos: t.photos ?? [],
+ });
+ return { meta: { ...f.meta, ...s.meta }, takes: (s.takes ?? []).map(take), draft: s.draft ? take(s.draft) : f.draft };
+}
+
+export function load(): Store {
+ try {
+ const raw = localStorage.getItem(STORE_KEY);
+ if (raw) return normalise(JSON.parse(raw));
+ } catch {}
+ return fresh();
+}
+
+// Minimal IndexedDB key/value store for photo blobs
+function photoDb(): Promise {
+ return new Promise((res, rej) => {
+ const r = indexedDB.open("ttvfx-report", 1);
+ r.onupgradeneeded = () => r.result.createObjectStore("photos");
+ r.onsuccess = () => res(r.result);
+ r.onerror = () => rej(r.error);
+ });
+}
+async function photoOp(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise {
+ const db = await photoDb();
+ return new Promise((res, rej) => {
+ const r = fn(db.transaction("photos", mode).objectStore("photos"));
+ r.onsuccess = () => res(r.result);
+ r.onerror = () => rej(r.error);
+ });
+}
+export const getPhoto = (id: string) => photoOp("readonly", (s) => s.get(id));
+export const putPhoto = (id: string, b: Blob) => photoOp("readwrite", (s) => s.put(b, id));
+export const delPhoto = (id: string) => photoOp("readwrite", (s) => s.delete(id));
+export const clearPhotos = () => photoOp("readwrite", (s) => s.clear());
+
+// Phone photos are 12-48 MP; scale down and re-encode as JPEG
+export async function shrink(f: File): Promise {
+ const bmp = await createImageBitmap(f);
+ const k = Math.min(1, PHOTO_MAX / Math.max(bmp.width, bmp.height));
+ const cv = document.createElement("canvas");
+ cv.width = Math.round(bmp.width * k);
+ cv.height = Math.round(bmp.height * k);
+ cv.getContext("2d")!.drawImage(bmp, 0, 0, cv.width, cv.height);
+ bmp.close();
+ return new Promise((res) => cv.toBlob((b) => res(b ?? f), "image/jpeg", 0.85));
+}
+
+export const blobToDataUrl = (b: Blob) =>
+ new Promise((res) => {
+ const r = new FileReader();
+ r.onload = () => res(r.result as string);
+ r.readAsDataURL(b);
+ });
+
+const csvCell = (v: string | number) => {
+ const s = String(v);
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+};
+
+export const clock = (at: number) => new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
+
+// One row per camera per take, so it drops straight into a spreadsheet
+export function toCsv(s: Pick) {
+ const head = ["Production", "Day", "Unit", "Supervisor", "Time", "Scene", "Shot", "Take", "Rating", "Cam", "Roll", "Clip",
+ ...CAM_FIELDS.map(([, l]) => l), "VFX", "References", "Notes", "Photos"];
+ const rows = s.takes.flatMap((t) =>
+ t.cams.map((c) => [
+ s.meta.production, s.meta.day, s.meta.unit, s.meta.supervisor, clock(t.at),
+ t.scene, t.shot, t.take, t.rating, c.id, c.roll, c.clip,
+ ...CAM_FIELDS.map(([k]) => c[k]), t.tags.join("; "), t.refs.join("; "), t.notes,
+ t.photos.map((_, i) => photoName(t, i)).join("; "),
+ ]),
+ );
+ return [head, ...rows].map((r) => r.map(csvCell).join(",")).join("\n");
+}
+
+// --- Report links -----------------------------------------------------------------------
+
+export type Report = Pick;
+
+// Drop empty values so the link stays short; normalise() puts them back
+function compact(o: T): Partial {
+ return Object.fromEntries(
+ Object.entries(o).filter(([, v]) => v !== "" && v !== 0 && !(Array.isArray(v) && v.length === 0)),
+ ) as Partial;
+}
+
+const toB64Url = (b: Uint8Array) => {
+ let s = "";
+ for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000));
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+};
+const fromB64Url = (s: string) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
+
+// Photos can't ride in a link, so they become their file names (matching the shared files)
+export async function encodeReport(r: Report): Promise {
+ const data = {
+ v: 1,
+ meta: compact(r.meta),
+ takes: r.takes.map((t) => {
+ const { id: _id, ...rest } = { ...t, photos: t.photos.map((_, i) => photoName(t, i)) };
+ void _id;
+ return { ...compact(rest), cams: t.cams.map(compact) };
+ }),
+ };
+ const stream = new Blob([JSON.stringify(data)]).stream().pipeThrough(new CompressionStream("deflate-raw"));
+ return toB64Url(new Uint8Array(await new Response(stream).arrayBuffer()));
+}
+
+export async function decodeReport(s: string): Promise {
+ const stream = new Blob([fromB64Url(s)]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
+ const data = JSON.parse(await new Response(stream).text());
+ const { meta, takes } = normalise(data);
+ return { meta, takes };
+}
diff --git a/public/set/sw.js b/public/set/sw.js
index 4c9f43a..a4b4256 100644
--- a/public/set/sw.js
+++ b/public/set/sw.js
@@ -1,4 +1,4 @@
-// Offline cache for /set (screen markers, spill checker). Crews often have no signal on set:
+// Offline cache for /set (screen markers, spill checker, 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";