Files
RecipesCam/docker/frontend/src/engine/imageOps.ts
T

285 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}