"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} ); }