PNG/EXIF handling (#328)

* Refactor PNG/EXIF handling
* Increase quality of client-side resizes
This commit is contained in:
Sam Becker 2025-09-20 16:13:25 -05:00 committed by GitHub
parent ee9f3f4dc2
commit 5140addc8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 322 additions and 217 deletions

View File

@ -34,6 +34,7 @@
"hgetall",
"Hoverable",
"hset",
"IEND",
"IIIA",
"ILCE",
"ILIKE",
@ -48,6 +49,8 @@
"Oklab",
"oklch",
"parameterizes",
"piexif",
"piexifjs",
"presigner",
"Provia",
"pushstate",

View File

@ -8,7 +8,7 @@
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build"
},
"packageManager": "pnpm@10.16.1",
"packageManager": "pnpm@10.17.0",
"dependencies": {
"@ai-sdk/openai": "^2.0.28",
"@ai-sdk/rsc": "^1.0.41",
@ -18,6 +18,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@types/piexifjs": "^1.0.0",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.3",
"@vercel/analytics": "^1.5.0",
@ -40,6 +41,7 @@
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"piexifjs": "^1.0.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-icons": "^5.5.0",

16
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@radix-ui/react-visually-hidden':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@types/piexifjs':
specifier: ^1.0.0
version: 1.0.0
'@upstash/ratelimit':
specifier: ^2.0.6
version: 2.0.6(@upstash/redis@1.35.3)
@ -98,6 +101,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.16.3
piexifjs:
specifier: ^1.0.6
version: 1.0.6
react:
specifier: 19.1.1
version: 19.1.1
@ -1777,6 +1783,9 @@ packages:
'@types/pg@8.15.5':
resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==}
'@types/piexifjs@1.0.0':
resolution: {integrity: sha512-PPiGeCkmkZQgYjvqtjD3kp4OkbCox2vEFVuK4DaLVOIazJLAXk+/ujbizkIPH5CN4AnN9Clo5ckzUlaj3+SzCA==}
'@types/react-dom@19.1.9':
resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==}
peerDependencies:
@ -3746,6 +3755,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
piexifjs@1.0.6:
resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@ -6564,6 +6576,8 @@ snapshots:
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/piexifjs@1.0.0': {}
'@types/react-dom@19.1.9(@types/react@19.1.12)':
dependencies:
'@types/react': 19.1.12
@ -8804,6 +8818,8 @@ snapshots:
picomatch@4.0.3: {}
piexifjs@1.0.6: {}
pirates@4.0.7: {}
pkg-dir@4.2.0:

View File

@ -2,7 +2,6 @@ export interface UploadState {
isUploading: boolean
uploadError: string
debugDownload?: { href: string, fileName: string }
image?: HTMLImageElement
hideUploadPanel?: boolean
fileUploadName: string
fileUploadIndex: number

View File

@ -1,4 +1,4 @@
import { capitalize, capitalizeWords, parameterize } from '@/utility/string';
import { capitalizeWords, parameterize } from '@/utility/string';
import {
addPhotoAlbumId,
clearPhotoAlbumIds,

View File

@ -1,8 +1,7 @@
'use client';
import { blobToImage } from '@/utility/blob';
import { useRef, RefObject } from 'react';
import { CopyExif, getOrientation } from '@/utility/exif';
import { pngToJpegWithExif, jpgToJpegWithExif } from '@/utility/exif-client';
import { clsx } from 'clsx/lite';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
@ -18,10 +17,10 @@ export default function ImageInput({
onBlobReady,
shouldResize,
maxSize = MAX_IMAGE_SIZE,
quality = 0.8,
quality = 0.9,
showButton,
disabled: disabledProp,
debug,
debug: _debug,
}: {
ref?: RefObject<HTMLInputElement | null>
id?: string
@ -40,14 +39,12 @@ export default function ImageInput({
debug?: boolean
}) {
const inputRefInternal = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const inputRef = inputRefExternal ?? inputRefInternal;
const {
uploadState: {
isUploading,
image,
filesLength,
fileUploadIndex,
},
@ -117,127 +114,50 @@ export default function ImageInput({
fileUploadIndex: i,
fileUploadName: file.name,
});
const inputExtension = file.name
.split('.')
.pop()?.toLowerCase();
const isInputPng = inputExtension === 'png';
const outputExtension = shouldResize
? 'jpeg'
: inputExtension;
const callbackArgs = {
extension: file.name.split('.').pop()?.toLowerCase(),
extension: outputExtension,
hasMultipleUploads: files.length > 1,
isLastBlob: i === files.length - 1,
};
const isPng = callbackArgs.extension === 'png';
let blob: Blob | File = file;
const canvas = canvasRef.current;
// Specify wide gamut to avoid data loss while resizing
const ctx = canvas?.getContext(
'2d', { colorSpace: 'display-p3' },
);
if (shouldResize && canvas && ctx) {
// Process images that need resizing
const image = await blobToImage(file);
setUploadState?.({ image });
ctx.save();
let orientation = await getOrientation(file)
.catch(() => 1) ?? 1;
// Preserve EXIF data for PNGs
if (!isPng) {
// Reverse engineer orientation
// so preserved EXIF data can be copied
switch (orientation) {
case 1: orientation = 1; break;
case 2: orientation = 1; break;
case 3: orientation = 3; break;
case 4: orientation = 1; break;
case 5: orientation = 1; break;
case 6: orientation = 8; break;
case 7: orientation = 1; break;
case 8: orientation = 6; break;
}
if (shouldResize) {
if (isInputPng) {
// Use specialized PNG <> JPEG converter
// for EXIF preservation
blob = await pngToJpegWithExif(
file,
{ maxSize, quality },
).catch(() => file);
} else {
// Use specialized JPG <> JPEG converter
// for EXIF preservation
blob = await jpgToJpegWithExif(
file,
{ maxSize, quality },
).catch(() => file);
}
const ratio = image.width / image.height;
const width =
Math.round(ratio >= 1 ? maxSize : maxSize * ratio);
const height =
Math.round(ratio >= 1 ? maxSize / ratio : maxSize);
canvas.width = width;
canvas.height = height;
// Orientation transforms from:
// eslint-disable-next-line max-len
// https://gist.github.com/SagiMedina/f00a57de4e211456225d3114fd10b0d0
switch(orientation) {
case 2:
ctx.translate(width, 0);
ctx.scale(-1, 1);
break;
case 3:
ctx.translate(width, height);
ctx.rotate((180 / 180) * Math.PI);
break;
case 4:
ctx.translate(0, height);
ctx.scale(1, -1);
break;
case 5:
canvas.width = height;
canvas.height = width;
ctx.rotate((90 / 180) * Math.PI);
ctx.scale(1, -1);
break;
case 6:
canvas.width = height;
canvas.height = width;
ctx.rotate((90 / 180) * Math.PI);
ctx.translate(0, -height);
break;
case 7:
canvas.width = height;
canvas.height = width;
ctx.rotate((270 / 180) * Math.PI);
ctx.translate(-width, height);
ctx.scale(1, -1);
break;
case 8:
canvas.width = height;
canvas.height = width;
ctx.translate(0, width);
ctx.rotate((270 / 180) * Math.PI);
break;
}
ctx.drawImage(image, 0, 0, width, height);
ctx.restore();
canvas.toBlob(
async blob => {
if (blob) {
const blobWithExif = await CopyExif(file, blob)
// Fallback to original blob if EXIF data is missing
// or image is in PNG format which cannot be parsed
.catch(() => blob);
await onBlobReady?.({
...callbackArgs,
blob: blobWithExif,
blob,
});
}
},
'image/jpeg',
quality,
);
} else {
// No need to process
await onBlobReady?.({
...callbackArgs,
blob: file,
blob,
});
}
}
@ -248,15 +168,6 @@ export default function ImageInput({
/>
</label>
</div>
<canvas
ref={canvasRef}
className={clsx(
'bg-gray-50 dark:bg-gray-900/50 rounded-md',
'border border-gray-200 dark:border-gray-800',
'w-[400px]',
(!image || !debug) && 'hidden',
)}
/>
</div>
);
}

View File

@ -1,15 +0,0 @@
export const blobToImage = (blob: Blob): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject('Error reading image');
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject('Error reading image');
reader.onload = e => {
const result = (e.currentTarget as any).result as string;
image.src = result;
};
reader.readAsDataURL(blob);
});

264
src/utility/exif-client.ts Normal file
View File

@ -0,0 +1,264 @@
import piexif from 'piexifjs';
interface ImageConversionOptions {
maxSize: number
quality: number
}
// Read a Blob/File as ArrayBuffer
const readAsArrayBuffer = (blob: Blob): Promise<ArrayBuffer> =>
new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as ArrayBuffer);
fr.onerror = () => rej(fr.error ?? new Error('FileReader error'));
fr.readAsArrayBuffer(blob);
});
// Read a Blob/File as DataURL
const readAsDataURL = (blob: Blob): Promise<string> =>
new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result));
fr.onerror = () => rej(fr.error ?? new Error('FileReader error'));
fr.readAsDataURL(blob);
});
// Convert DataURL string -> Blob
const dataURLtoBlob = (dataURL: string): Blob => {
const [header, b64] = dataURL.split(',');
const mimeMatch = header.match(/data:([^;]+);base64/);
if (!mimeMatch) throw new Error('Invalid data URL');
const mime = mimeMatch[1];
const bin = atob(b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new Blob([u8], { type: mime });
};
// Extract raw EXIF (TIFF) bytes from a PNG eXIf chunk.
// Returns Uint8Array or null if no EXIF found.
const extractExifFromPNG = (
arrayBuffer: ArrayBuffer,
): Uint8Array | null => {
const u8 = new Uint8Array(arrayBuffer);
// PNG signature
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < 8; i++) {
if (u8[i] !== sig[i]) throw new Error('Not a PNG');
}
let p = 8; // after signature
while (p + 8 <= u8.length) {
const len =
(u8[p] << 24) | (u8[p + 1] << 16) | (u8[p + 2] << 8) | u8[p + 3];
const type = String.fromCharCode(
u8[p + 4],
u8[p + 5],
u8[p + 6],
u8[p + 7],
);
const dataStart = p + 8;
const dataEnd = dataStart + len;
const crcEnd = dataEnd + 4;
if (crcEnd > u8.length) break;
if (type === 'eXIf') {
// Payload is the pure TIFF bytes (no "Exif\0\0" header)
return u8.slice(dataStart, dataEnd);
}
if (type === 'IEND') break;
p = crcEnd;
}
return null;
};
// Extract raw EXIF (TIFF) bytes from a JPEG file.
// Returns Uint8Array or null if no EXIF found.
const extractExifFromJpeg = (
arrayBuffer: ArrayBuffer,
): Uint8Array | null => {
const u8 = new Uint8Array(arrayBuffer);
// Check JPEG signature (SOI marker)
if (u8[0] !== 0xFF || u8[1] !== 0xD8) {
throw new Error('Not a JPEG');
}
let p = 2; // after SOI marker
while (p + 4 <= u8.length) {
// Check for marker
if (u8[p] !== 0xFF) break;
const marker = u8[p + 1];
const length = (u8[p + 2] << 8) | u8[p + 3];
const dataStart = p + 4;
// length includes the 2 bytes for length itself
const dataEnd = dataStart + length - 2;
if (dataEnd > u8.length) break;
// APP1 marker contains EXIF data
if (marker === 0xE1) {
// Check for EXIF header "Exif\0\0"
if (dataEnd >= dataStart + 6) {
const exifHeader = String.fromCharCode(
u8[dataStart], u8[dataStart + 1], u8[dataStart + 2],
u8[dataStart + 3], u8[dataStart + 4], u8[dataStart + 5],
);
if (exifHeader === 'Exif\0\0') {
// Return the TIFF data (skip the "Exif\0\0" header)
return u8.slice(dataStart + 6, dataEnd);
}
}
}
p = dataEnd;
}
return null;
};
// Draw onto a canvas, resize, and encode to JPEG Blob
const resizeToJpegBlob = async (
source: ImageBitmap | HTMLImageElement,
{ maxSize, quality }: ImageConversionOptions,
): Promise<Blob> => {
let w = source.width;
let h = source.height;
if (Math.max(w, h) > maxSize) {
const scale = maxSize / Math.max(w, h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { colorSpace: 'display-p3' });
if (!ctx) throw new Error('2D context unavailable');
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(source as CanvasImageSource, 0, 0, w, h);
const blob = await new Promise<Blob>((res) =>
canvas.toBlob((b) => res(b as Blob), 'image/jpeg', quality),
);
if (!blob) throw new Error('canvas.toBlob failed');
return blob;
};
// Insert raw EXIF bytes into a JPEG Blob using piexifjs.
// Also normalizes Orientation -> 1 (since pixels are already upright).
const insertExifIntoJpegBlob = async (
jpegBlob: Blob,
rawExifUint8: Uint8Array | null,
): Promise<Blob> => {
const jpegDataURL = await readAsDataURL(jpegBlob);
let withExifDataURL = jpegDataURL;
if (rawExifUint8 && rawExifUint8.length > 0) {
// Build the EXIF binary string ("Exif\0\0" + TIFF bytes)
const exifHeader = 'Exif\0\0';
let exifBody = '';
for (let i = 0; i < rawExifUint8.length; i++) {
exifBody += String.fromCharCode(rawExifUint8[i]);
}
const exifBinaryString = exifHeader + exifBody;
withExifDataURL = piexif.insert(exifBinaryString, jpegDataURL);
}
// Normalize Orientation to 1
const exifObj = piexif.load(withExifDataURL);
if (exifObj?.['0th']) {
exifObj['0th'][piexif.ImageIFD.Orientation] = 1;
}
const normalizedDataURL = piexif
.insert(piexif.dump(exifObj), withExifDataURL);
return dataURLtoBlob(normalizedDataURL);
};
/**
* Main: PNG File -> JPEG Blob with EXIF preserved, resized via canvas.toBlob()
* - Honors EXIF orientation at decode using:
* createImageBitmap(..., { imageOrientation: 'from-image' })
* - Sets Orientation=1 in the written EXIF so viewers don't rotate again
*/
export const pngToJpegWithExif = async (
file: File,
options: ImageConversionOptions,
): Promise<Blob> => {
// Extract EXIF from PNG (if present)
const ab = await readAsArrayBuffer(file);
const rawExif = extractExifFromPNG(ab); // Uint8Array | null
// Decode the PNG for drawing
const img = document.createElement('img');
img.crossOrigin = 'anonymous'; // for remote URLs with CORS, harmless for File
img.src = URL.createObjectURL(file);
await new Promise<void>((res, rej) => {
img.onload = () => res();
img.onerror = () => rej(new Error('Image load failed'));
});
// Prefer ImageBitmap with orientation applied by decoder
const bitmap = await createImageBitmap(
img,
{ imageOrientation: 'from-image' },
);
// Resize on canvas -> JPEG Blob
const jpegBlob = await resizeToJpegBlob(bitmap, options);
// Insert EXIF (and normalize Orientation)
const outBlob = await insertExifIntoJpegBlob(jpegBlob, rawExif);
// cleanup
URL.revokeObjectURL(img.src);
bitmap.close?.();
return outBlob;
};
/**
* Main: JPEG File -> JPEG Blob with EXIF preserved, resized via canvas.toBlob()
* - Honors EXIF orientation at decode using:
* createImageBitmap(..., { imageOrientation: 'from-image' })
* - Sets Orientation=1 in the written EXIF so viewers don't rotate again
*/
export const jpgToJpegWithExif = async (
file: File,
options: ImageConversionOptions,
): Promise<Blob> => {
// Extract EXIF from JPEG (if present)
const ab = await readAsArrayBuffer(file);
const rawExif = extractExifFromJpeg(ab); // Uint8Array | null
// Decode the JPEG for drawing
const img = document.createElement('img');
img.crossOrigin = 'anonymous'; // for remote URLs with CORS, harmless for File
img.src = URL.createObjectURL(file);
await new Promise<void>((res, rej) => {
img.onload = () => res();
img.onerror = () => rej(new Error('Image load failed'));
});
// Prefer ImageBitmap with orientation applied by decoder
const bitmap = await createImageBitmap(
img,
{ imageOrientation: 'from-image' },
);
// Resize on canvas -> JPEG Blob
const jpegBlob = await resizeToJpegBlob(bitmap, options);
// Insert EXIF (and normalize Orientation)
const outBlob = await insertExifIntoJpegBlob(jpegBlob, rawExif);
// cleanup
URL.revokeObjectURL(img.src);
bitmap.close?.();
return outBlob;
};

View File

@ -71,78 +71,3 @@ export const convertApertureValueToFNumber = (
return undefined;
}
};
const SOS = 0xffda;
const APP1 = 0xffe1;
const EXIF = 0x45786966;
const retrieveExif = (blob: Blob): Promise<Blob> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', e => {
const buffer = e.target!.result as ArrayBuffer;
const view = new DataView(buffer);
let offset = 0;
if (view.getUint16(offset) !== 0xffd8)
return reject('not a valid jpeg');
offset += 2;
while (true) {
const marker = view.getUint16(offset);
if (marker === SOS) break;
const size = view.getUint16(offset + 2);
if (marker === APP1 && view.getUint32(offset + 4) === EXIF)
return resolve(blob.slice(offset, offset + 2 + size));
offset += 2 + size;
}
return resolve(new Blob());
});
reader.readAsArrayBuffer(blob);
});
export const CopyExif = async (
src: Blob,
dest: Blob,
type = 'image/jpeg',
) => {
const exif = await retrieveExif(src);
return new Blob([dest.slice(0, 2), exif, dest.slice(2)], { type });
};
export const getOrientation = (file: File): Promise<number> =>
file.arrayBuffer().then(buffer => {
const view = new DataView(buffer);
if (view.getUint16(0, false) !== 0xFFD8) {
return -2;
} else {
const length = view.byteLength;
let offset = 2;
while (offset < length) {
if (view.getUint16(offset + 2, false) <= 8) return -1;
const marker = view.getUint16(offset, false);
offset += 2;
if (marker === 0xFFE1) {
if (view.getUint32(offset += 2, false) !== 0x45786966) {
return -1;
} else {
const little = view.getUint16(offset += 6, false) === 0x4949;
offset += view.getUint32(offset + 4, little);
const tags = view.getUint16(offset, little);
offset += 2;
for (let i = 0; i < tags; i++) {
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
return view.getUint16(offset + (i * 12) + 8, little);
}
}
}
} else if ((marker & 0xFF00) !== 0xFF00) {
break;
} else {
offset += view.getUint16(offset, false);
}
}
return -1;
};
});