// 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 }; }