import exifr from 'exifr'; import type { GPSInfo } from '../../shared/types'; // Browser-side input handling: turn a dropped File into the source bytes the // engine wants, plus the two derived things the UI needs — a smaller copy for // the live preview, and whatever GPS the photo already carries. export async function fileToBytes(file: File | Blob): Promise { return new Uint8Array(await file.arrayBuffer()); } // Preview renders run on a downscaled copy: the grade is pixel-wise, so grading // a 1600px copy shows the same look, and it keeps the slider responsive. Export // always runs on the original bytes. export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0.92): Promise { const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); try { const longest = Math.max(bitmap.width, bitmap.height); if (longest <= maxDim) return bytes; const scale = maxDim / longest; const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) return bytes; ctx.drawImage(bitmap, 0, 0, w, h); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); return new Uint8Array(await blob.arrayBuffer()); } finally { bitmap.close(); } } // The same resample, but it enlarges as well as shrinks and always re-encodes. // `resizedJpeg` refuses to grow a photo back, which is right for the preview // copy; the export needs the growth because a committed crop keeps only part of // the photo, so the whole photo has to be stretched a little for the crop to // land on the requested size — and a plain filter is the honest answer when the // photo already holds those pixels. Only ever called with a target the photo // already covers (see the export's gain guard). export async function scaledJpeg(bytes: Uint8Array, targetLongest: number, quality = 0.95): Promise { const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); try { const longest = Math.max(bitmap.width, bitmap.height); if (longest === targetLongest) return bytes; const scale = targetLongest / longest; const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) return bytes; ctx.drawImage(bitmap, 0, 0, w, h); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); return new Uint8Array(await blob.arrayBuffer()); } finally { bitmap.close(); } } // The colour under one point of a photo, for the HSL mixer's eyedropper. // `fx`/`fy` are fractions of the photo (0..1, from the click position over the // ), so the caller needs no knowledge of the stage's zoom, pan or box. // One pixel, read straight off the RENDER — the swatch then shows the colour // the user is actually looking at, grade and all, which is what makes the // band the eyedropper lands on the one they meant. export function samplePixel(bitmap: ImageBitmap, fx: number, fy: number): { r: number; g: number; b: number } { const sx = Math.min(bitmap.width - 1, Math.max(0, Math.floor(fx * bitmap.width))); const sy = Math.min(bitmap.height - 1, Math.max(0, Math.floor(fy * bitmap.height))); const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return { r: 0, g: 0, b: 0 }; ctx.drawImage(bitmap, sx, sy, 1, 1, 0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return { r, g, b }; } // Uploads are capped by the API (and by nginx in front of it), so a phone's // 12MP JPEG has to shrink before it goes up — the same downscale the preview // uses. The ORIGINAL bytes come back untouched (same reference) when the photo // already fits both the pixel and the byte budget, which is what keeps a small // PNG's declared content-type honest instead of silently turning it into JPEG. export async function shrinkForUpload( bytes: Uint8Array, maxDim: number, maxBytes: number ): Promise { try { return await shrinkOrKeep(bytes, maxDim, maxBytes); } catch { // Undecodable here (corrupt or exotic): hand it to the server unchanged and // let its magic-number sniff give the real answer. return bytes; } } async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number): Promise { const resized = await resizedJpeg(bytes, maxDim); if (resized !== bytes) return resized; if (bytes.length <= maxBytes) return bytes; // Already inside maxDim yet still over the byte cap: force one JPEG re-encode. const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); try { const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext('2d'); if (!ctx) return bytes; ctx.drawImage(bitmap, 0, 0); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 }); return new Uint8Array(await blob.arrayBuffer()); } finally { bitmap.close(); } } // EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect. // Returns null when the photo has none — the caller then offers manual entry. export async function readGps(bytes: Uint8Array): Promise { try { const gps = await exifr.gps(bytes); if (!gps || typeof gps.latitude !== 'number' || typeof gps.longitude !== 'number') return null; return { latitude: gps.latitude, longitude: gps.longitude, timestamp: Date.now(), source: 'exif', }; } catch { return null; } } // EXIF ISO of the loaded photo, for the strip's technical line. `pick` keeps // the parse to the tags that matter; a screenshot or a film scan has none, and // the caller then prints "AUTO" instead of inventing a number. export async function readIso(bytes: Uint8Array): Promise { try { const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as | { ISO?: unknown; ISOSpeedRatings?: unknown } | undefined; const raw = tags?.ISO ?? tags?.ISOSpeedRatings; const n = Number(Array.isArray(raw) ? raw[0] : raw); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; } catch { return null; } } export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void { const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type })); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); }