Files
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

362 lines
15 KiB
TypeScript
Raw Permalink 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 { 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<string, string> };
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<Record<keyof Cam, (v: string) => 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 = <T,>(a: T[]) => [...new Set(a)];
export default function ReportView() {
const [state, setState] = useState<State>({ 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<string, string> = {};
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 <div className="min-h-dvh" />;
if (state.status === "error")
return (
<p className="mx-auto max-w-md px-6 py-24 text-center text-sm leading-relaxed text-muted">
This report link is incomplete. Some mail apps cut long links short: ask for it to be sent again, or as a
message instead.
</p>
);
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 (
<div className="px-0 pb-16 sm:px-6 print:p-0">
{/* Toolbar (screen only) */}
<div className="mx-auto flex max-w-[60rem] items-center gap-2 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3 sm:px-0 print:hidden">
{source === "device" ? (
<Link href="/set/report/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
← Logger
</Link>
) : (
<span className="font-mono text-[11px] tracking-wider text-muted uppercase">Shared report</span>
)}
<span className="flex-1" />
<button onClick={downloadCsv} className="min-h-11 rounded-lg bg-white/5 px-4 text-sm text-fg">
CSV
</button>
<button onClick={() => window.print()} className="min-h-11 rounded-lg bg-accent px-4 text-sm font-medium text-ink">
Save PDF
</button>
</div>
<article className="report mx-auto max-w-[60rem] bg-[#fbfaf7] px-5 py-8 text-[#141413] shadow-2xl sm:rounded-sm sm:px-12 sm:py-12 print:max-w-none print:bg-white print:p-0 print:shadow-none">
{/* Masthead */}
<header className="flex items-center gap-2.5 border-b border-[#141413] pb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/fox.svg" alt="" className="h-5 w-5 invert" />
<span className="font-display text-xs tracking-[0.3em] uppercase">Two Tales VFX</span>
<span className="flex-1" />
<span className="font-mono text-[10px] tracking-widest text-[#77756f] uppercase">On-set VFX report</span>
</header>
<h1 className="mt-8 font-display text-4xl leading-none font-light tracking-wide uppercase sm:text-5xl">
{meta.production || "Untitled production"}
</h1>
<dl className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">
<Meta label="Shoot day" value={meta.day} />
<Meta label="Date" value={date} wide />
<Meta label="Unit" value={meta.unit} />
<Meta label="VFX supervisor" value={meta.supervisor} />
</dl>
{/* Day at a glance */}
<section className="mt-8 grid grid-cols-3 border-y border-[#e3e0d8] sm:grid-cols-6">
<Stat n={setups.length} label="Setups" />
<Stat n={takes.length} label="Takes" />
<Stat n={takes.filter((t) => t.rating === "circle").length} label="Circled" accent />
<Stat n={cams.length} label={cams.length === 1 ? "Camera" : "Cameras"} />
<Stat n={photoCount} label="Photos" />
<Stat n={first ? `${clock(first)}` : "–"} label={last && last !== first ? `to ${clock(last)}` : "First take"} small />
</section>
{tagCounts.length > 0 && (
<section className="mt-6">
<Label>VFX work today</Label>
<div className="mt-2 flex flex-wrap gap-1.5">
{tagCounts.map(({ tag, n }) => (
<span key={tag} className="rounded-full border border-[#d9d5cc] px-2.5 py-0.5 text-xs">
{tag} <span className="text-[#77756f]">· {n} setup{n > 1 ? "s" : ""}</span>
</span>
))}
</div>
</section>
)}
{setups.length === 0 && <p className="mt-12 text-center text-sm text-[#77756f]">No takes logged yet.</p>}
{setups.map((s) => (
<SetupBlock key={s.key} setup={s} photos={photos} source={source} />
))}
<footer className="mt-12 flex justify-between border-t border-[#e3e0d8] pt-3 font-mono text-[10px] text-[#77756f]">
<span>{site.name} · {new URL(site.url).host}</span>
<span>
{takes.length} takes · {setups.length} setups
</span>
</footer>
</article>
</div>
);
}
function SetupBlock({ setup, photos, source }: { setup: Setup; photos: Record<string, string>; 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 (
<section className="mt-12 break-inside-avoid-page">
<div className="flex items-end gap-4 border-b border-[#141413] pb-2">
<h2 className="font-mono text-2xl leading-none">
<span className="mr-1 align-middle text-[10px] tracking-widest text-[#77756f] uppercase">Sc</span>
{setup.scene}
<span className="mx-2 text-[#c9c5bb]">/</span>
<span className="mr-1 align-middle text-[10px] tracking-widest text-[#77756f] uppercase">Shot</span>
{setup.shot}
</h2>
<span className="flex-1" />
<span className="text-right font-mono text-[11px] leading-tight text-[#77756f]">
{setup.takes.length} take{setup.takes.length > 1 ? "s" : ""}
{circled.length > 0 && <span className="text-[#c4451c]"> · circled {circled.join(", ")}</span>}
<br />
{clock(start)}
{end !== start && `–${clock(end)}`}
</span>
</div>
<div className="mt-3 grid gap-x-8 gap-y-2 text-[13px] sm:grid-cols-[1fr_auto]">
<div>
{constant.length > 0 && (
<p>
<Label inline>Camera</Label>
{constant.map(([col, v]) => (SUMMARY[col.key] ?? ((x: string) => `${col.label} ${x}`))(v)).join(" · ")}
</p>
)}
{tags.length > 0 && (
<p className="mt-1">
<Label inline>VFX</Label>
{tags.join(", ")}
</p>
)}
</div>
{refs.length > 0 && (
<ul className="flex flex-wrap content-start gap-1 self-start sm:max-w-[18rem] sm:justify-end">
{refs.map((r) => (
<li key={r} className="rounded-sm bg-[#141413] px-1.5 py-0.5 text-[10px] tracking-wide whitespace-nowrap text-[#fbfaf7] uppercase">
✓ {r}
</li>
))}
</ul>
)}
</div>
{/* Takes: scrolls sideways inside itself on a phone, never the page */}
<div className="-mx-5 mt-4 overflow-x-auto px-5 sm:mx-0 sm:px-0 print:overflow-visible">
<table className="w-full border-collapse text-left text-[12.5px]">
<thead>
<tr className="border-b border-[#d9d5cc] text-[9.5px] tracking-wider text-[#77756f] uppercase">
<th className="py-1.5 pr-3 font-normal">Take</th>
{multiCam && <th className="py-1.5 pr-3 font-normal">Cam</th>}
{varying.map((c) => (
<th key={c.key} className="py-1.5 pr-3 font-normal whitespace-nowrap">
{c.label}
</th>
))}
<th className="w-full py-1.5 font-normal">Notes</th>
</tr>
</thead>
<tbody>
{rows.map(({ t, c, first, span }) => (
<tr key={`${t.id}-${c.id}`} className={`break-inside-avoid align-top ${first ? "border-t border-[#ebe8e1]" : ""}`}>
{first && (
<td rowSpan={span} className="py-2 pr-3 whitespace-nowrap">
<TakeNo t={t} />
</td>
)}
{multiCam && <td className="py-2 pr-3 font-mono">{c.id}</td>}
{varying.map((col) => (
<td key={col.key} className="py-2 pr-3 font-mono whitespace-nowrap">
{c[col.key] || <span className="text-[#c9c5bb]">–</span>}
</td>
))}
{first && (
<td rowSpan={span} className="min-w-[12rem] py-2 leading-snug">
{t.rating === "ng" && <span className="mr-1.5 text-[10px] tracking-wider text-[#77756f] uppercase">NG</span>}
{t.notes}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{shots.length > 0 &&
(source === "device" ? (
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{shots.map(({ p, name }) => (
<figure key={p} className="break-inside-avoid">
{/* eslint-disable-next-line @next/next/no-img-element */}
{photos[p] && <img src={photos[p]} alt={name} className="aspect-[4/3] w-full rounded-sm object-cover" />}
<figcaption className="mt-1 font-mono text-[9.5px] text-[#77756f]">{name}</figcaption>
</figure>
))}
</div>
) : (
<p className="mt-3 text-[11px] leading-relaxed text-[#77756f]">
<Label inline>Photos</Label>
<span className="font-mono">{shots.map((s) => s.name).join(" · ")}</span>
</p>
))}
</section>
);
}
function TakeNo({ t }: { t: Take }) {
if (t.rating === "circle")
return (
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full border-[1.5px] border-[#e8572a] px-1 font-mono text-[15px] text-[#c4451c]">
{t.take}
</span>
);
return (
<span className={`inline-flex h-7 items-center font-mono text-[15px] ${t.rating === "ng" ? "text-[#a5a29a] line-through" : ""}`}>
T{t.take}
{t.rating === "good" && <span className="ml-1.5 text-[9px] tracking-wider text-[#4f7a3a] uppercase no-underline">Good</span>}
</span>
);
}
function Meta({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
return (
<div className={wide ? "col-span-2 sm:col-span-1" : ""}>
<dt className="text-[9.5px] tracking-wider text-[#77756f] uppercase">{label}</dt>
<dd className="mt-0.5">{value || <span className="text-[#c9c5bb]">–</span>}</dd>
</div>
);
}
function Stat({ n, label, accent, small }: { n: number | string; label: string; accent?: boolean; small?: boolean }) {
return (
<div className="border-[#e3e0d8] py-4 pr-2 [&:not(:first-child)]:pl-4 sm:[&:not(:first-child)]:border-l">
<div className={`font-mono leading-none ${small ? "pt-1.5 text-lg" : "text-3xl"} ${accent ? "text-[#c4451c]" : ""}`}>{n}</div>
<div className="mt-1.5 text-[9.5px] tracking-wider text-[#77756f] uppercase">{label}</div>
</div>
);
}
function Label({ children, inline }: { children: React.ReactNode; inline?: boolean }) {
return (
<span className={`text-[9.5px] tracking-wider text-[#77756f] uppercase ${inline ? "mr-2" : "block"}`}>{children}</span>
);
}