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) => ( + + ))} + +
+ {site.name} · {new URL(site.url).host} + + {takes.length} takes · {setups.length} setups + +
+
+
+ ); +} + +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 */} +
+ + + + + {multiCam && } + {varying.map((c) => ( + + ))} + + + + + {rows.map(({ t, c, first, span }) => ( + + {first && ( + + )} + {multiCam && } + {varying.map((col) => ( + + ))} + {first && ( + + )} + + ))} + +
TakeCam + {c.label} + Notes
+ + {c.id} + {c[col.key] || –} + + {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}} +
{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 ( +
+
{n}
+
{label}
+
+ ); +} + +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 +