diff --git a/.vscode/settings.json b/.vscode/settings.json index 60274468..25cc34e6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,6 +34,7 @@ "hgetall", "Hoverable", "hset", + "IEND", "IIIA", "ILCE", "ILIKE", @@ -48,6 +49,8 @@ "Oklab", "oklch", "parameterizes", + "piexif", + "piexifjs", "presigner", "Provia", "pushstate", diff --git a/package.json b/package.json index 38e403ee..7a915dc2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5680b2..394c8bde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/admin/upload/index.ts b/src/admin/upload/index.ts index c083e22e..6bb91853 100644 --- a/src/admin/upload/index.ts +++ b/src/admin/upload/index.ts @@ -2,7 +2,6 @@ export interface UploadState { isUploading: boolean uploadError: string debugDownload?: { href: string, fileName: string } - image?: HTMLImageElement hideUploadPanel?: boolean fileUploadName: string fileUploadIndex: number diff --git a/src/album/server.ts b/src/album/server.ts index 1632ee26..0af61f77 100644 --- a/src/album/server.ts +++ b/src/album/server.ts @@ -1,4 +1,4 @@ -import { capitalize, capitalizeWords, parameterize } from '@/utility/string'; +import { capitalizeWords, parameterize } from '@/utility/string'; import { addPhotoAlbumId, clearPhotoAlbumIds, diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 35993005..95469822 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -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 id?: string @@ -40,14 +39,12 @@ export default function ImageInput({ debug?: boolean }) { const inputRefInternal = useRef(null); - const canvasRef = useRef(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, - }); - } - }, - 'image/jpeg', - quality, - ); + await onBlobReady?.({ + ...callbackArgs, + blob, + }); } else { // No need to process await onBlobReady?.({ ...callbackArgs, - blob: file, + blob, }); } } @@ -248,15 +168,6 @@ export default function ImageInput({ /> - ); } diff --git a/src/utility/blob.ts b/src/utility/blob.ts deleted file mode 100644 index 7db47191..00000000 --- a/src/utility/blob.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const blobToImage = (blob: Blob): Promise => - 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); - }); diff --git a/src/utility/exif-client.ts b/src/utility/exif-client.ts new file mode 100644 index 00000000..360d13a3 --- /dev/null +++ b/src/utility/exif-client.ts @@ -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 => + 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 => + 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 => { + 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((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 => { + 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 => { + // 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((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 => { + // 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((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; +}; diff --git a/src/utility/exif.ts b/src/utility/exif.ts index 8fa6b4d5..bb645cc6 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -71,78 +71,3 @@ export const convertApertureValueToFNumber = ( return undefined; } }; - -const SOS = 0xffda; -const APP1 = 0xffe1; -const EXIF = 0x45786966; - -const retrieveExif = (blob: Blob): Promise => - 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 => - 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; - }; - });