Add /set/report: on-set VFX take logger and shareable report
Deploy / deploy (push) Successful in 41s

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>
This commit is contained in:
twotalesanimation
2026-10-16 21:33:26 +02:00
parent a7b7a29ff2
commit 4ac1817425
8 changed files with 1304 additions and 1 deletions
+8
View File
@@ -88,3 +88,11 @@ body {
.grain::after, .animate-drift, .animate-scan { animation: none; } .grain::after, .animate-drift, .animate-scan { animation: none; }
.reveal { opacity: 1; transform: none; transition: 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; }
}
+25
View File
@@ -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 (
<main className="min-h-dvh bg-ink">
<ShotReport />
</main>
);
}
+22
View File
@@ -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 (
<main className="min-h-dvh bg-ink print:bg-white">
<ReportView />
</main>
);
}
+10
View File
@@ -373,6 +373,16 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
</span> </span>
<span aria-hidden className="text-xl text-muted">→</span> <span aria-hidden className="text-xl text-muted">→</span>
</Link> </Link>
<Link
href="/set/report/"
className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-4 py-4 transition hover:bg-white/10"
>
<span>
<span className="block font-medium">VFX report</span>
<span className="block text-sm text-muted">Log camera, references and notes for every take</span>
</span>
<span aria-hidden className="text-xl text-muted">→</span>
</Link>
<p className="mt-10 text-center text-xs leading-relaxed text-muted"> <p className="mt-10 text-center text-xs leading-relaxed text-muted">
Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake
+361
View File
@@ -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<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>
);
}
+694
View File
@@ -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<Store | null>(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<HTMLInputElement>(null);
const restoreInput = useRef<HTMLInputElement>(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<string>();
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 <div className="min-h-dvh" />;
const d = store.draft;
const c = d.cams[Math.min(cam, d.cams.length - 1)];
const setDraft = (patch: Partial<Take>) => 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<string, string> = {};
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 (
<div className="mx-auto max-w-2xl px-4 pb-40">
<header className="flex items-center gap-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
<Link href="/set/" className="flex min-h-11 items-center pr-2 text-sm text-muted">
← Markers
</Link>
<h1 className="flex-1 text-center font-display text-lg font-light tracking-wide uppercase">VFX report</h1>
<span className="w-20 text-right font-mono text-[10px] text-muted">{offline ? "✓ Offline" : ""}</span>
</header>
<nav className="sticky top-0 z-20 -mx-4 flex gap-1 bg-ink/95 px-4 pt-[env(safe-area-inset-top)] pb-3 backdrop-blur">
{([["take", editing ? "Editing" : "Take"], ["log", `Log · ${store.takes.length}`], ["day", "Day"]] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setView(v)}
className={`min-h-11 flex-1 rounded-lg text-sm ${view === v ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
>
{l}
</button>
))}
</nav>
{view === "take" && (
<>
{/* Slate */}
<section className="grid grid-cols-[1fr_1fr_auto] gap-2">
<BigField label="Scene" value={d.scene} onChange={(v) => setDraft({ scene: v })} />
<BigField label="Shot" value={d.shot} onChange={(v) => setDraft({ shot: v })} />
<div className="rounded-xl bg-white/5 px-2 pt-2 pb-1">
<span className="block px-1 text-[11px] tracking-wider text-muted uppercase">Take</span>
<div className="flex items-center">
<StepBtn onClick={() => setDraft({ take: Math.max(1, d.take - 1) })}>−</StepBtn>
<input
value={d.take}
inputMode="numeric"
onChange={(e) => 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"
/>
<StepBtn onClick={() => setDraft({ take: d.take + 1 })}>+</StepBtn>
</div>
</div>
</section>
{/* Rating */}
<section className="mt-3 grid grid-cols-3 gap-2">
{RATINGS.map(([r, l]) => (
<button
key={r}
onClick={() => setDraft({ rating: d.rating === r ? "" : r })}
className={`min-h-12 rounded-xl text-sm font-medium ${
d.rating === r ? (r === "ng" ? "bg-white/25 text-fg line-through" : "bg-accent text-ink") : "bg-white/5 text-muted"
}`}
>
{l}
</button>
))}
</section>
{/* Cameras */}
<Heading>Camera</Heading>
<div className="flex gap-1">
{d.cams.map((x, i) => (
<button
key={x.id}
onClick={() => setCam(i)}
className={`min-h-11 min-w-12 rounded-lg font-mono ${x.id === c.id ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}
>
{x.id}
</button>
))}
{d.cams.length < 6 && (
<button
onClick={() => {
const id = String.fromCharCode(65 + d.cams.length);
setDraft({ cams: [...d.cams, { ...c, id, roll: "", clip: "" }] });
setCam(d.cams.length);
}}
className="min-h-11 min-w-12 rounded-lg bg-white/5 text-muted"
aria-label="Add camera"
>
+
</button>
)}
{d.cams.length > 1 && (
<button
onClick={() => {
if (!confirm(`Remove camera ${c.id}?`)) return;
const cams = d.cams.filter((x) => x.id !== c.id).map((x, i) => ({ ...x, id: String.fromCharCode(65 + i) }));
setDraft({ cams });
setCam(0);
}}
className="ml-auto min-h-11 px-3 text-sm text-muted"
>
Remove {c.id}
</button>
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Field label="Roll" value={c.roll} placeholder="A003" onChange={(v) => setCamField("roll", v.toUpperCase())} />
<Field label="Clip" value={c.clip} placeholder="A003C007" onChange={(v) => setCamField("clip", v.toUpperCase())} />
</div>
{lenses.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{lenses.map((l) => (
<Chip key={l} on={c.lens === l} onClick={() => setCamField("lens", l)}>
{l}mm
</Chip>
))}
</div>
)}
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-5">
{CAM_FIELDS.map(([k, l, mode, ph]) => (
<Field key={k} label={l} value={c[k]} placeholder={ph} inputMode={mode} onChange={(v) => setCamField(k, v)} />
))}
</div>
<Heading>VFX</Heading>
<div className="flex flex-wrap gap-1.5">
{TAGS.map((t) => (
<Chip key={t} on={d.tags.includes(t)} onClick={() => toggle("tags", t)}>
{t}
</Chip>
))}
</div>
<Heading>References shot</Heading>
<div className="flex flex-wrap gap-1.5">
{REFS.map((t) => (
<Chip key={t} on={d.refs.includes(t)} onClick={() => toggle("refs", t)} check>
{t}
</Chip>
))}
</div>
<Heading>Photos</Heading>
<div className="flex flex-wrap gap-2">
{d.photos.map((id) => (
<Thumb key={id} id={id} onRemove={() => removePhoto(id)} />
))}
<button
onClick={() => photoInput.current?.click()}
className="flex h-20 w-20 flex-col items-center justify-center rounded-lg bg-white/5 text-xs text-muted"
>
<span className="text-2xl leading-none">+</span>
Photo
</button>
</div>
<input
ref={photoInput}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) addPhotos(e.target.files);
e.target.value = "";
}}
/>
<Heading>Notes</Heading>
<textarea
value={d.notes}
onChange={(e) => setDraft({ notes: e.target.value })}
rows={3}
placeholder="Boom in frame at end, actor crosses marker, wind on screen…"
className="w-full rounded-xl bg-white/5 px-3 py-3 text-base outline-none placeholder:text-white/20 focus:bg-white/10"
/>
{/* Save bar */}
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-ink/95 backdrop-blur">
<div className="mx-auto flex max-w-2xl gap-2 px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{editing ? (
<>
<button onClick={cancelEdit} className="min-h-14 flex-1 rounded-xl bg-white/5 text-muted">
Cancel
</button>
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
Update take
</button>
</>
) : (
<>
<button onClick={() => save(true)} className="min-h-14 flex-1 rounded-xl bg-white/10 text-sm leading-tight">
Save ·<br />new shot
</button>
<button onClick={() => save()} className="min-h-14 flex-[2] rounded-xl bg-accent font-medium text-ink">
Save · next take
</button>
</>
)}
</div>
</div>
</>
)}
{view === "log" && <Log takes={store.takes} onEdit={edit} onDelete={remove} onExport={exportReport} />}
{view === "day" && (
<section>
<div className="grid grid-cols-2 gap-2">
{(["production", "day", "unit", "supervisor"] as const).map((k) => (
<Field
key={k}
label={k === "day" ? "Shoot day" : k[0].toUpperCase() + k.slice(1)}
value={store.meta[k]}
inputMode={k === "day" ? "decimal" : "text"}
onChange={(v) => setStore({ ...store, meta: { ...store.meta, [k]: v } })}
/>
))}
</div>
<button
onClick={shareLink}
disabled={!store.takes.length}
className="mt-6 min-h-14 w-full rounded-xl bg-accent font-medium text-ink disabled:opacity-30"
>
Send report link to post
</button>
<Link
href="/set/report/view/"
className="mt-2 flex min-h-12 w-full items-center justify-center rounded-xl bg-white/10 text-sm"
>
Preview report · save PDF with photos
</Link>
<button
onClick={exportReport}
disabled={!store.takes.length}
className="mt-2 min-h-12 w-full rounded-xl bg-white/10 text-sm disabled:opacity-30"
>
Share CSV + photos
</button>
<div className="mt-2 grid grid-cols-2 gap-2">
<button onClick={backup} className="min-h-12 rounded-xl bg-white/10 text-sm">
Backup (.json)
</button>
<button onClick={() => restoreInput.current?.click()} className="min-h-12 rounded-xl bg-white/10 text-sm">
Restore backup
</button>
</div>
<input
ref={restoreInput}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) restore(f);
e.target.value = "";
}}
/>
<button onClick={newDay} className="mt-6 min-h-12 w-full rounded-xl bg-white/5 text-sm text-muted">
Start a new day (clears the log)
</button>
<p className="mt-6 text-xs leading-relaxed text-muted">
Everything saves on this device as you type and works without signal. Nothing is uploaded: the report
link carries the day&apos;s log inside it (photos are listed by name; send them with Share CSV + photos, or
send the PDF from Preview, which includes them). Keep a backup: the .json holds every take and photo, and
Restore loads it on another phone or iPad. Clearing Safari&apos;s website data erases the log.
</p>
</section>
)}
{busy && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 text-sm">{busy}</div>}
{flash && (
<div className="pointer-events-none fixed inset-x-0 bottom-28 z-40 flex justify-center px-4">
<span className="rounded-full bg-fg px-4 py-2 text-sm text-ink shadow-lg">{flash}</span>
</div>
)}
</div>
);
}
function Log({ takes, onEdit, onDelete, onExport }: { takes: Take[]; onEdit: (t: Take) => void; onDelete: (t: Take) => void; onExport: () => void }) {
if (!takes.length) return <p className="py-16 text-center text-sm text-muted">No takes logged yet.</p>;
// 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 (
<section>
<button onClick={onExport} className="mb-4 min-h-12 w-full rounded-xl bg-white/10 text-sm">
Share report · CSV + photos
</button>
{groups.map((g) => (
<div key={g.key} className="mb-5">
<h2 className="mb-1.5 font-mono text-sm text-fg">{g.key}</h2>
<ul className="divide-y divide-line overflow-hidden rounded-xl bg-white/[0.04]">
{g.takes.map((t) => (
<li key={t.id} className="flex items-stretch">
<button onClick={() => onEdit(t)} className="flex min-h-14 flex-1 items-center gap-3 px-3 py-2 text-left">
<span className={`w-10 font-mono text-lg ${t.rating === "ng" ? "text-muted line-through" : ""}`}>
{t.rating === "circle" ? <span className="rounded-full border border-accent px-1.5 text-accent">{t.take}</span> : `T${t.take}`}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{t.cams.map((c) => [c.id, c.lens && `${c.lens}mm`, c.stop && `T${c.stop}`, c.clip].filter(Boolean).join(" ")).join(" · ")}
</span>
<span className="block truncate text-xs text-muted">
{clock(t.at)}
{t.tags.length > 0 && ` · ${t.tags.join(", ")}`}
{t.photos.length > 0 && ` · ${t.photos.length} photo${t.photos.length > 1 ? "s" : ""}`}
{t.notes && ` · ${t.notes}`}
</span>
</span>
</button>
<button onClick={() => onDelete(t)} className="px-4 text-muted" aria-label={`Delete take ${t.take}`}>
×
</button>
</li>
))}
</ul>
</div>
))}
</section>
);
}
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 (
<div className="relative h-20 w-20 overflow-hidden rounded-lg bg-white/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
{url && <img src={url} alt="" className="h-full w-full object-cover" />}
<button
onClick={() => confirm("Remove this photo?") && onRemove()}
className="absolute top-0 right-0 flex h-8 w-8 items-center justify-center bg-black/60 text-sm"
aria-label="Remove photo"
>
×
</button>
</div>
);
}
function Heading({ children }: { children: React.ReactNode }) {
return <h2 className="mt-6 mb-2 text-[11px] tracking-wider text-muted uppercase">{children}</h2>;
}
function BigField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<label className="block min-w-0 rounded-xl bg-white/5 px-3 pt-2 pb-1 focus-within:bg-white/10">
<span className="block text-[11px] tracking-wider text-muted uppercase">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
className="w-full bg-transparent font-mono text-3xl outline-none"
/>
</label>
);
}
function Field({
label,
value,
onChange,
placeholder,
inputMode = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
inputMode?: "decimal" | "text";
}) {
return (
<label className="block min-w-0 rounded-lg bg-white/5 px-3 pt-1.5 pb-1 focus-within:bg-white/10">
<span className="block truncate text-[10px] tracking-wider text-muted uppercase">{label}</span>
{/* 16px text so iOS doesn't zoom in on focus */}
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
inputMode={inputMode}
autoComplete="off"
autoCorrect="off"
className="w-full bg-transparent font-mono text-base outline-none placeholder:text-white/15"
/>
</label>
);
}
function Chip({ on, onClick, check, children }: { on: boolean; onClick: () => void; check?: boolean; children: React.ReactNode }) {
return (
<button onClick={onClick} aria-pressed={on} className={`min-h-10 rounded-full px-3.5 text-sm ${on ? "bg-fg text-ink" : "bg-white/5 text-muted"}`}>
{check && on && "✓ "}
{children}
</button>
);
}
function StepBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button onClick={onClick} className="flex h-11 w-9 items-center justify-center rounded-lg text-2xl text-muted active:bg-white/10">
{children}
</button>
);
}
+183
View File
@@ -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>): Store {
const f = fresh();
const take = (t: Partial<Take>): 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<IDBDatabase> {
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<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
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<Blob | undefined>("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<Blob> {
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<string>((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<Store, "meta" | "takes">) {
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<Store, "meta" | "takes">;
// Drop empty values so the link stays short; normalise() puts them back
function compact<T extends object>(o: T): Partial<T> {
return Object.fromEntries(
Object.entries(o).filter(([, v]) => v !== "" && v !== 0 && !(Array.isArray(v) && v.length === 0)),
) as Partial<T>;
}
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<string> {
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<Report> {
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 };
}
+1 -1
View File
@@ -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. // once the page has loaded once, it and every marker image work offline.
// Must match OFFLINE_CACHE in components/set/MarkerPicker.tsx // Must match OFFLINE_CACHE in components/set/MarkerPicker.tsx
const CACHE = "ttvfx-set-v1"; const CACHE = "ttvfx-set-v1";