4ac1817425
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>
184 lines
7.3 KiB
TypeScript
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 };
|
|
}
|