PNG/EXIF handling (#328)
* Refactor PNG/EXIF handling * Increase quality of client-side resizes
This commit is contained in:
parent
ee9f3f4dc2
commit
5140addc8d
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -34,6 +34,7 @@
|
||||
"hgetall",
|
||||
"Hoverable",
|
||||
"hset",
|
||||
"IEND",
|
||||
"IIIA",
|
||||
"ILCE",
|
||||
"ILIKE",
|
||||
@ -48,6 +49,8 @@
|
||||
"Oklab",
|
||||
"oklch",
|
||||
"parameterizes",
|
||||
"piexif",
|
||||
"piexifjs",
|
||||
"presigner",
|
||||
"Provia",
|
||||
"pushstate",
|
||||
|
||||
@ -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
16
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -2,7 +2,6 @@ export interface UploadState {
|
||||
isUploading: boolean
|
||||
uploadError: string
|
||||
debugDownload?: { href: string, fileName: string }
|
||||
image?: HTMLImageElement
|
||||
hideUploadPanel?: boolean
|
||||
fileUploadName: string
|
||||
fileUploadIndex: number
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { capitalize, capitalizeWords, parameterize } from '@/utility/string';
|
||||
import { capitalizeWords, parameterize } from '@/utility/string';
|
||||
import {
|
||||
addPhotoAlbumId,
|
||||
clearPhotoAlbumIds,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
264
src/utility/exif-client.ts
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user