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

184 lines
7.3 KiB
TypeScript

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