f201deee46
The export menu measured the photo off the 1600px preview copy, so a 2400px photo was believed to be 1600px across: the hint named the wrong size, the model was asked to upscale a photo that already had more pixels than the target, and a guest's 2048 ceiling was skipped because 1600 never crossed it. A committed crop made it worse — the crop's longest edge was taken from the wider side of the crop rect rather than the side the frame actually keeps, so a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the model over 80 tiles (158.7s) to reach 2K. The photo's own dimensions are now read off the original bytes, and the crop's long edge is the same axis-aware fraction the stage already uses. The export asks the model only when the photo itself is short of the requested size, or when the crop would have to be stretched past 1.5x to get there; otherwise it resamples — down, or a hair up to make up for the crop — which is what a photo that already holds the pixels deserves. Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to 3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a 1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact, with the model's 16.6 edge energy against bilinear's 4.8.
157 lines
6.7 KiB
TypeScript
157 lines
6.7 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;
|
|
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<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;
|
|
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 };
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
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<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);
|
|
}
|