285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
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<Uint8Array> {
|
||
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<Uint8Array> {
|
||
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<Uint8Array> {
|
||
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
|
||
// <img>), 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<Uint8Array> {
|
||
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<Uint8Array> {
|
||
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<number | null> {
|
||
try {
|
||
const tags = (await exifr.parse(bytes, {
|
||
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'],
|
||
})) as Record<string, unknown> | 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 {
|
||
camera: string | null;
|
||
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<ShotSpecs | null> {
|
||
try {
|
||
const tags = (await exifr.parse(bytes, {
|
||
pick: ['Make', 'Model', 'ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
|
||
})) as Record<string, unknown> | 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;
|
||
};
|
||
const make = tags.Make ? String(tags.Make).trim() : '';
|
||
const model = tags.Model ? String(tags.Model).trim() : '';
|
||
let camera: string | null = null;
|
||
if (model) {
|
||
camera = make && !model.toLowerCase().startsWith(make.toLowerCase()) ? `${make} ${model}` : model;
|
||
} else if (make) {
|
||
camera = make;
|
||
}
|
||
return {
|
||
camera,
|
||
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, place?: string | null, dims?: { w: number; h: number } | null): string {
|
||
if (!specs && !place && !dims) return '';
|
||
const parts = [
|
||
specs?.camera ? specs.camera : '',
|
||
specs?.aperture ? `f/${round(specs.aperture)}` : '',
|
||
specs?.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||
specs?.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||
specs?.focal ? `${round(specs.focal)}mm` : '',
|
||
dims ? `${dims.w} × ${dims.h}` : (specs?.width && specs?.height ? `${specs.width} × ${specs.height}` : ''),
|
||
place ? place : '',
|
||
];
|
||
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<GPSInfo | null> {
|
||
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<number | null> {
|
||
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);
|
||
}
|