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; // The default is 'low', which point-samples: a one-pixel stripe in the // source comes out as full-amplitude ripple (255 against the 36 an exact // area average gives) instead of being averaged away. ctx.imageSmoothingQuality = 'high'; 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 for the crop to land on the // requested size — and a plain filter is the honest answer while it still holds // the detail (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; // 'high' for the same reason as `resizedJpeg`: the default aliases, and // this path is now the export's upscale, where a ripple shows. ctx.imageSmoothingQuality = 'high'; 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 }; } // A sampler over a whole decoded photo: fractions in, one pixel out. The heal // brush reads a few hundred points around a spot while it looks for a patch to // borrow, and per-sample that is a drawImage through `samplePixel` — which is // the right shape for one eyedropper click and the wrong one for a search, so // the pixels are read once and then indexed. export function bitmapReader(bitmap: ImageBitmap): (fx: number, fy: number) => { r: number; g: number; b: number } { const w = bitmap.width; const h = bitmap.height; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); let data: Uint8ClampedArray | null = null; if (ctx && w > 0 && h > 0) { ctx.drawImage(bitmap, 0, 0); data = ctx.getImageData(0, 0, w, h).data; } return (fx, fy) => { if (!data) return { r: 0, g: 0, b: 0 }; const x = Math.min(w - 1, Math.max(0, Math.floor(fx * w))); const y = Math.min(h - 1, Math.max(0, Math.floor(fy * h))); const i = (y * w + x) * 4; return { r: data[i], g: data[i + 1], b: data[i + 2] }; }; } // 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(); } } // When the photo was taken, per its own EXIF. DateTimeOriginal is the shutter; // the two file timestamps behind it are what an editor rewrote along the way. // EXIF stores the time without a timezone, in the camera's local time, and exifr // hands it back as a Date in ours — which is what a camera prints on a stamp, and // what someone opening a 2019 photo today expects to read under the coordinates. // Null when the file carries no date at all; the caller falls back to its clock. export async function readCapturedAt(bytes: Uint8Array): Promise { try { const tags = (await exifr.parse(bytes, { pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'], })) as Record | undefined; for (const tag of ['DateTimeOriginal', 'CreateDate', 'ModifyDate']) { const value = tags?.[tag]; if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime(); } } catch { // Unreadable EXIF is the same as no EXIF here. } return null; } // What the camera recorded about the shutter, the glass and the frame itself — // the numbers a photographer reads first. All of them sit in the EXIF segment at // the front of the file, so this costs the same few hundred kilobytes the date // does. Each one is null where the file does not carry it, which is what a // screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows // and nothing about a file it does not know is invented here. export interface ShotSpecs { iso: number | null; // A focal length in millimetres, an f-number, and an exposure in seconds. focal: number | null; aperture: number | null; exposure: number | null; // The frame's own size, which only a JPEG states outright. A RAW leaves it to // the decoder, and half a number is worse than none. width: number | null; height: number | null; } export async function readSpecs(bytes: Uint8Array): Promise { try { const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'], })) as Record | undefined; if (!tags) return null; const num = (v: unknown): number | null => { const n = Number(Array.isArray(v) ? v[0] : v); return Number.isFinite(n) && n > 0 ? n : null; }; return { iso: num(tags.ISO ?? tags.ISOSpeedRatings), focal: num(tags.FocalLength), aperture: num(tags.FNumber), exposure: num(tags.ExposureTime), // Null together: a frame with one edge and not the other has no size. width: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageWidth) : null, height: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageHeight) : null, }; } catch { return null; } } // The rounded number as it is printed: one decimal, and none where there is // none — `f/2.8` rather than `f/2.8000000000000003`. const round = (n: number) => String(Math.round(n * 10) / 10); // Those numbers as the line under a frame. A shutter faster than a second is the // fraction a camera prints (`1/320s`), and everything the file does not have is // left out rather than stood in for — an empty line is what a file with no EXIF // gets, and the caller prints nothing. export function specsLine(specs: ShotSpecs | null): string { if (!specs) return ''; const parts = [ specs.iso ? `ISO ${Math.round(specs.iso)}` : '', specs.focal ? `${round(specs.focal)}mm` : '', specs.aperture ? `f/${round(specs.aperture)}` : '', specs.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '', specs.width && specs.height ? `${specs.width}×${specs.height}` : '', ]; return parts.filter(Boolean).join(' · '); } // 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, // The stamp prints when the photo was taken, not when it was opened. timestamp: (await readCapturedAt(bytes)) ?? 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); }