From d41c7f4617a1d74ceb7c6b75804e968f98e65b59 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 12:21:09 -0500 Subject: [PATCH 1/5] Setup CopyExif with mixed results --- next.config.js | 2 +- src/app/(auth-state)/admin/photos/page.tsx | 7 +- src/components/ImageInput.tsx | 136 ++++++++++++++++++ src/lib/CopyExif.ts | 36 +++++ src/photo/PhotoForm.tsx | 14 +- src/photo/PhotoUpload.tsx | 77 ++++++++++ src/photo/PhotoUploadInput.tsx | 70 --------- src/photo/actions.ts | 14 +- .../image-response/PhotoImageResponse.tsx | 4 +- src/photo/image-response/index.ts | 4 +- src/site/globals.css | 34 ++++- src/utility/blob.ts | 15 ++ src/utility/image.ts | 8 +- 13 files changed, 312 insertions(+), 109 deletions(-) create mode 100644 src/components/ImageInput.tsx create mode 100644 src/lib/CopyExif.ts create mode 100644 src/photo/PhotoUpload.tsx delete mode 100644 src/photo/PhotoUploadInput.tsx create mode 100644 src/utility/blob.ts diff --git a/next.config.js b/next.config.js index 5fd31a4c..1f6a9f76 100644 --- a/next.config.js +++ b/next.config.js @@ -6,7 +6,7 @@ const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', -}) +}); const nextConfig = { images: { diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 8e8ac617..1c360682 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from 'react'; -import PhotoUploadInput from '@/photo/PhotoUploadInput'; +import PhotoUpload from '@/photo/PhotoUpload'; import Link from 'next/link'; import PhotoTiny from '@/photo/PhotoTiny'; import { cc } from '@/utility/css'; @@ -27,6 +27,7 @@ import AdminGrid from '@/admin/AdminGrid'; import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; +import { PRO_MODE_ENABLED } from '@/site/config'; export const runtime = 'edge'; @@ -52,8 +53,8 @@ export default async function AdminTagsPage({ return ( - +
+ {blobPhotoUrls.length > 0 &&
void + maxSize?: number + quality?: number + loading?: boolean + debug?: boolean +}) { + const ref = useRef(null); + + const [fileName, setFileName] = useState(); + const [image, setImage] = useState(); + + return ( +
+
+ + {fileName && +
+ {fileName} +
} +
+ +
+ ); +} diff --git a/src/lib/CopyExif.ts b/src/lib/CopyExif.ts new file mode 100644 index 00000000..268759d1 --- /dev/null +++ b/src/lib/CopyExif.ts @@ -0,0 +1,36 @@ +export async function CopyExif( + src: Blob, + dest: Blob, + type = 'image/jpeg', +) { + const exif = await retrieveExif(src); + return new Blob([dest.slice(0, 2), exif, dest.slice(2)], { type }); +}; + +const SOS = 0xffda; +const APP1 = 0xffe1; +const EXIF = 0x45786966; + +const retrieveExif = (blob: Blob): any => + 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); + }); diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index 8e91c28f..e02dcde9 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, @@ -31,11 +31,6 @@ export default function PhotoForm({ const url = formData.url ?? ''; - const [requestOrigin, setRequestOrigin] = useState(); - useEffect(() => { - setRequestOrigin(window.location.origin); - }, []); - const updateBlurData = useCallback((blurData: string) => { if (type === 'create') { setFormData(data => ({ @@ -102,13 +97,6 @@ export default function PhotoForm({ loading={loadingMessage && !formData[key] ? true : false} type={checkbox ? 'checkbox' : undefined} />)} - {type === 'create' && - }
{type === 'edit' && (); + const [debugDownload, setDebugDownload] = useState<{ + href: string + fileName: string + }>(); + + const router = useRouter(); + + return ( +
+
+
+ { + setIsUploading(true); + setUploadError(''); + if (debug) { + setDebugDownload({ + href: URL.createObjectURL(blob), + fileName: `debug.${extension}`, + }); + } else { + uploadPhotoFromClient( + blob, + extension, + ) + .then(({ url }) => { + // Refresh page to update upload list, + // relevant only when a photo isn't added + router.refresh(); + // Redirect to photo detail page + router.push(pathForAdminUploadUrl(url)); + }) + .catch(error => { + setIsUploading(false); + setUploadError(`Upload Error: ${error.message}`); + }); + } + }} + debug={debug} + /> + +
+ {debugDownload && + + Download + } + {uploadError && +
+ {uploadError} +
} +
+ ); +}; diff --git a/src/photo/PhotoUploadInput.tsx b/src/photo/PhotoUploadInput.tsx deleted file mode 100644 index a09dd022..00000000 --- a/src/photo/PhotoUploadInput.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import Spinner from '@/components/Spinner'; -import { - ACCEPTED_PHOTO_FILE_TYPES, - uploadPhotoFromClient, -} from '@/services/blob'; -import { cc } from '@/utility/css'; -import { useRouter } from 'next/navigation'; -import { pathForAdminUploadUrl } from '@/site/paths'; - -export default function PhotoUploadInput() { - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(''); - - const router = useRouter(); - - return ( -
-
-
- { - const file = e.target.files?.[0]; - if (file) { - setIsUploading(true); - setUploadError(''); - const extension = file.name.split('.').pop(); - uploadPhotoFromClient( - file, - extension, - ) - .then(({ url }) => { - // Refresh page to update upload list, - // relevant only when a photo isn't added - router.refresh(); - // Redirect to photo detail page - router.push(pathForAdminUploadUrl(url)); - }) - .catch(error => { - setIsUploading(false); - setUploadError(`Upload Error: ${error.message}`); - }); - - } - }} - disabled={isUploading} - /> - {isUploading && -
- - Uploading... -
} -
-
- {uploadError && -
- {uploadError} -
} -
- ); -}; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 87abaf5e..2f145266 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -18,24 +18,12 @@ import { revalidateBlobKey, revalidatePhotosKey, } from '@/cache'; -import { PRO_MODE_ENABLED } from '@/site/config'; -import { getNextImageUrlForRequest } from '@/utility/image'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS } from '@/site/paths'; export async function createPhotoAction(formData: FormData) { - const requestOrigin = formData.get('requestOrigin') as string | undefined; - formData.delete('requestOrigin'); - const photo = convertFormDataToPhoto(formData, true); - const updatedUrl = await convertUploadToPhoto( - photo.url, - photo.id, - !PRO_MODE_ENABLED - ? getNextImageUrlForRequest(photo.url, 3840, 90, requestOrigin) - : undefined, - !PRO_MODE_ENABLED ? 'webp' : undefined, - ); + const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); if (updatedUrl) { photo.url = updatedUrl; } diff --git a/src/photo/image-response/PhotoImageResponse.tsx b/src/photo/image-response/PhotoImageResponse.tsx index 01ac40db..76892755 100644 --- a/src/photo/image-response/PhotoImageResponse.tsx +++ b/src/photo/image-response/PhotoImageResponse.tsx @@ -1,5 +1,5 @@ import { Photo } from '..'; -import { NextImageWidth } from '@/utility/image'; +import { NextImageSize } from '@/utility/image'; import { formatModelShort } from '@/utility/exif'; import { AiFillApple } from 'react-icons/ai'; import ImageCaption from './components/ImageCaption'; @@ -13,7 +13,7 @@ export default function PhotoImageResponse({ fontFamily, }: { photo: Photo - width: NextImageWidth + width: NextImageSize height: number fontFamily: string }) { diff --git a/src/photo/image-response/index.ts b/src/photo/image-response/index.ts index 4f231e34..cd6bb355 100644 --- a/src/photo/image-response/index.ts +++ b/src/photo/image-response/index.ts @@ -1,4 +1,4 @@ -import { NextImageWidth } from '@/utility/image'; +import { NextImageSize } from '@/utility/image'; export const MAX_PHOTOS_TO_SHOW_HOME = 12; export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6; @@ -7,7 +7,7 @@ export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12; // 16:9 og image ratio const IMAGE_OG_RATIO = 16 / 9; -const IMAGE_OG_WIDTH: NextImageWidth = 1200; +const IMAGE_OG_WIDTH: NextImageSize = 1200; const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO); export const IMAGE_OG_SIZE = { width: IMAGE_OG_WIDTH, diff --git a/src/site/globals.css b/src/site/globals.css index a27992f7..a0db0332 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -6,9 +6,9 @@ /* Core */ body { @apply + text-main font-mono text-sm md:text-base bg-white dark:bg-black - text-gray-900 dark:text-gray-100 } /* Forms */ label { @@ -22,7 +22,7 @@ @apply px-2 py-1.5 border rounded-md - dark:bg-black + bg-white dark:bg-black border-gray-200 dark:border-gray-700 font-mono text-base leading-none min-h-[2.25rem] @@ -73,6 +73,20 @@ disabled:bg-transparent dark:disabled:bg-transparent disabled:border-gray-100 dark:disabled:border-gray-900 } + button.primary, .button.primary { + @apply + text-invert + bg-gray-900 dark:bg-gray-100 + disabled:bg-gray-900 disabled:dark:bg-gray-100 + border-gray-900 dark:border-gray-100 + active:bg-gray-700 active:border-gray-700 + active:dark:bg-gray-300 active:dark:border-gray-300 + shadow-none + } + button.primary.disabled, .button.primary.disabled { + @apply + text-extra-dim + } /* Toasts */ .toaster [data-sonner-toast] { @apply @@ -80,8 +94,24 @@ !border-gray-200 dark:!border-gray-800 } /* Common Utilities */ + .text-main { + @apply + text-gray-900 dark:text-gray-100 + } + .text-invert { + @apply + text-gray-100 dark:text-gray-900 + } .text-dim { @apply text-gray-400 dark:text-gray-500 } + .text-extra-dim { + @apply + text-gray-500 dark:text-gray-400 + } + .text-error { + @apply + text-red-500 dark:text-red-400 + } } diff --git a/src/utility/blob.ts b/src/utility/blob.ts new file mode 100644 index 00000000..7db47191 --- /dev/null +++ b/src/utility/blob.ts @@ -0,0 +1,15 @@ +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/image.ts b/src/utility/image.ts index ec4c9986..5b57b5b2 100644 --- a/src/utility/image.ts +++ b/src/utility/image.ts @@ -4,18 +4,20 @@ import { BASE_URL } from '@/site/config'; type NextCustomSize = 200 | 400 | 1050; type NextImageDeviceSize = 640 | 750 | 828 | 1080 | 1200 | 1920 | 2048 | 3840; -export type NextImageWidth = NextCustomSize | NextImageDeviceSize; +export type NextImageSize = NextCustomSize | NextImageDeviceSize; + +export const MAX_IMAGE_SIZE: NextImageSize = 3840; export const getNextImageUrlForRequest = ( imageUrl: string, - width: NextImageWidth, + size: NextImageSize, quality = 75, baseUrl = BASE_URL, ) => { const url = new URL(`${baseUrl}/_next/image`); url.searchParams.append('url', imageUrl); - url.searchParams.append('w', width.toString()); + url.searchParams.append('w', size.toString()); url.searchParams.append('q', quality.toString()); return url.toString(); From 3dac053a90a49587a4575d4c8fa8487a60ca4cec Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 12:46:34 -0500 Subject: [PATCH 2/5] Debug file upload in production --- .vscode/settings.json | 4 + src/app/(auth-state)/admin/photos/page.tsx | 3 +- src/lib/ExifRestorer.ts | 178 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/lib/ExifRestorer.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ebeffbc6..6eb8f0ac 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,18 @@ { "cSpell.words": [ + "ABCDEFGHIJKLMNOP", "ARROWLEFT", "ARROWRIGHT", "camelcase", "exif", + "ghijklmnopqrstuv", "hgetall", "hset", "Lightbox", "nanoids", "nextjs", "qaub", + "QRSTUVWXYZ", "skippable", "sonner", "thephotoblog", @@ -17,6 +20,7 @@ "unnest", "UsKSGcbt", "WRHGZC", + "wxyz", "zadd", "zrange" ], diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 1c360682..c3347122 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -27,7 +27,6 @@ import AdminGrid from '@/admin/AdminGrid'; import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; -import { PRO_MODE_ENABLED } from '@/site/config'; export const runtime = 'edge'; @@ -54,7 +53,7 @@ export default async function AdminTagsPage({ - + {blobPhotoUrls.length > 0 &&
string; + restore: (origFileBase64: string, resizedFileBase64: string) => string; + exifManipulation: (resizedFileBase64: string, segments: any[]) => Uint8Array; + getExifArray: (segments: any[]) => any[]; + insertExif: (resizedFileBase64: string, exifArray: any[]) => any[]; + slice2Segments: (rawImageArray: any[]) => any[]; + decode64: (input: string) => any[]; + } = {} as any; + + ExifRestorer.KEY_STR = + 'ABCDEFGHIJKLMNOP' + + 'QRSTUVWXYZabcdef' + + 'ghijklmnopqrstuv' + + 'wxyz0123456789+/' + + '='; + + ExifRestorer.encode64 = function (input) { + let output: any = '', + chr1: any, chr2: any, chr3: any = '', + enc1: any, enc2: any, enc3: any, enc4: any = '', + i = 0; + + do { + chr1 = input[i++]; + chr2 = input[i++]; + chr3 = input[i++]; + + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + + output = output + + this.KEY_STR.charAt(enc1) + + this.KEY_STR.charAt(enc2) + + this.KEY_STR.charAt(enc3) + + this.KEY_STR.charAt(enc4); + chr1 = chr2 = chr3 = ''; + enc1 = enc2 = enc3 = enc4 = ''; + } while (i < input.length); + + return output; + }; + + ExifRestorer.restore = function (origFileBase64, resizedFileBase64) { + if (!origFileBase64.match('data:image/jpeg;base64,')) { + return resizedFileBase64; + } + + var rawImage = this.decode64(origFileBase64.replace('data:image/jpeg;base64,', '')); + var segments = this.slice2Segments(rawImage); + + var image = this.exifManipulation(resizedFileBase64, segments); + + return this.encode64(image); + }; + + + ExifRestorer.exifManipulation = function (resizedFileBase64, segments) { + var exifArray = this.getExifArray(segments), + newImageArray = this.insertExif(resizedFileBase64, exifArray), + aBuffer = new Uint8Array(newImageArray); + + return aBuffer; + }; + + + ExifRestorer.getExifArray = function (segments) { + var seg; + for (var x = 0; x < segments.length; x++) { + seg = segments[x]; + if (seg[0] == 255 && seg[1] == 225) //(ff e1) + { + return seg; + } + } + return []; + }; + + + ExifRestorer.insertExif = function (resizedFileBase64, exifArray) { + var imageData = resizedFileBase64.replace('data:image/jpeg;base64,', ''), + buf = this.decode64(imageData), + separatePoint = buf.indexOf(255, 3), + mae = buf.slice(0, separatePoint), + ato = buf.slice(separatePoint), + array = mae; + + array = array.concat(exifArray); + array = array.concat(ato); + return array; + }; + + + + ExifRestorer.slice2Segments = function (rawImageArray) { + var head = 0, + segments = []; + + while (1) { + if (rawImageArray[head] == 255 && rawImageArray[head + 1] == 218) { break; } + if (rawImageArray[head] == 255 && rawImageArray[head + 1] == 216) { + head += 2; + } + else { + var length = rawImageArray[head + 2] * 256 + rawImageArray[head + 3], + endPoint = head + length + 2, + seg = rawImageArray.slice(head, endPoint); + segments.push(seg); + head = endPoint; + } + if (head > rawImageArray.length) { break; } + } + + return segments; + }; + + + + ExifRestorer.decode64 = function (input) { + let + chr1: any, chr2: any, chr3: any = '', + enc1: any, enc2: any, enc3: any, enc4: any = '', + i = 0, + buf = []; + + // remove all characters that are not A-Z, a-z, 0-9, +, /, or = + var base64test = /[^A-Za-z0-9\+\/\=]/g; + if (base64test.exec(input)) { + alert('There were invalid base64 characters in the input text.\n' + + 'Valid base64 characters are A-Z, a-z, 0-9, \'+\', \'/\',and \'=\'\n' + + 'Expect errors in decoding.'); + } + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ''); + + do { + enc1 = this.KEY_STR.indexOf(input.charAt(i++)); + enc2 = this.KEY_STR.indexOf(input.charAt(i++)); + enc3 = this.KEY_STR.indexOf(input.charAt(i++)); + enc4 = this.KEY_STR.indexOf(input.charAt(i++)); + + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + buf.push(chr1); + + if (enc3 != 64) { + buf.push(chr2); + } + if (enc4 != 64) { + buf.push(chr3); + } + + chr1 = chr2 = chr3 = ''; + enc1 = enc2 = enc3 = enc4 = ''; + + } while (i < input.length); + + return buf; + }; + + + return ExifRestorer; +})(); From 5e75025aadb73c8cb4804ec994e37cc56772e7c2 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 13:14:48 -0500 Subject: [PATCH 3/5] Turn off upload debugging --- src/app/(auth-state)/admin/photos/page.tsx | 2 +- .../(auth-state)/admin/uploads/blob/route.tsx | 6 +- src/components/ImageInput.tsx | 14 +- src/lib/ExifRestorer.ts | 178 ------------------ src/photo/PhotoUpload.tsx | 6 +- src/photo/index.ts | 5 + src/services/blob.ts | 6 - src/utility/promise.ts | 7 + 8 files changed, 25 insertions(+), 199 deletions(-) delete mode 100644 src/lib/ExifRestorer.ts create mode 100644 src/utility/promise.ts diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index c3347122..05ca0477 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -53,7 +53,7 @@ export default async function AdminTagsPage({ - + {blobPhotoUrls.length > 0 &&
{ const file = e.currentTarget.files?.[0]; setFileName(file?.name); - const extension = file?.name.split('.').pop(); + const extension = file?.name.split('.').pop()?.toLowerCase(); const canvas = ref.current; if (file) { if (!maxSize) { @@ -103,10 +103,10 @@ export default function ImageInput({ canvas.toBlob( async blob => { if (blob) { - onBlobReady( - await CopyExif(file, blob, imageType), - extension, - ); + // await sleep(); + const newBlob = await CopyExif(file, blob, imageType); + // await sleep(); + onBlobReady(newBlob, extension); } }, imageType, diff --git a/src/lib/ExifRestorer.ts b/src/lib/ExifRestorer.ts deleted file mode 100644 index 5504d4f0..00000000 --- a/src/lib/ExifRestorer.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* eslint-disable max-len */ -//Based on MinifyJpeg -//http://elicon.blog57.fc2.com/blog-entry-206.html - -export const ExifRestorer = (function () { - const ExifRestorer: { - KEY_STR: string; - encode64: (input: Uint8Array) => string; - restore: (origFileBase64: string, resizedFileBase64: string) => string; - exifManipulation: (resizedFileBase64: string, segments: any[]) => Uint8Array; - getExifArray: (segments: any[]) => any[]; - insertExif: (resizedFileBase64: string, exifArray: any[]) => any[]; - slice2Segments: (rawImageArray: any[]) => any[]; - decode64: (input: string) => any[]; - } = {} as any; - - ExifRestorer.KEY_STR = - 'ABCDEFGHIJKLMNOP' + - 'QRSTUVWXYZabcdef' + - 'ghijklmnopqrstuv' + - 'wxyz0123456789+/' + - '='; - - ExifRestorer.encode64 = function (input) { - let output: any = '', - chr1: any, chr2: any, chr3: any = '', - enc1: any, enc2: any, enc3: any, enc4: any = '', - i = 0; - - do { - chr1 = input[i++]; - chr2 = input[i++]; - chr3 = input[i++]; - - enc1 = chr1 >> 2; - enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); - enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); - enc4 = chr3 & 63; - - if (isNaN(chr2)) { - enc3 = enc4 = 64; - } else if (isNaN(chr3)) { - enc4 = 64; - } - - output = output + - this.KEY_STR.charAt(enc1) + - this.KEY_STR.charAt(enc2) + - this.KEY_STR.charAt(enc3) + - this.KEY_STR.charAt(enc4); - chr1 = chr2 = chr3 = ''; - enc1 = enc2 = enc3 = enc4 = ''; - } while (i < input.length); - - return output; - }; - - ExifRestorer.restore = function (origFileBase64, resizedFileBase64) { - if (!origFileBase64.match('data:image/jpeg;base64,')) { - return resizedFileBase64; - } - - var rawImage = this.decode64(origFileBase64.replace('data:image/jpeg;base64,', '')); - var segments = this.slice2Segments(rawImage); - - var image = this.exifManipulation(resizedFileBase64, segments); - - return this.encode64(image); - }; - - - ExifRestorer.exifManipulation = function (resizedFileBase64, segments) { - var exifArray = this.getExifArray(segments), - newImageArray = this.insertExif(resizedFileBase64, exifArray), - aBuffer = new Uint8Array(newImageArray); - - return aBuffer; - }; - - - ExifRestorer.getExifArray = function (segments) { - var seg; - for (var x = 0; x < segments.length; x++) { - seg = segments[x]; - if (seg[0] == 255 && seg[1] == 225) //(ff e1) - { - return seg; - } - } - return []; - }; - - - ExifRestorer.insertExif = function (resizedFileBase64, exifArray) { - var imageData = resizedFileBase64.replace('data:image/jpeg;base64,', ''), - buf = this.decode64(imageData), - separatePoint = buf.indexOf(255, 3), - mae = buf.slice(0, separatePoint), - ato = buf.slice(separatePoint), - array = mae; - - array = array.concat(exifArray); - array = array.concat(ato); - return array; - }; - - - - ExifRestorer.slice2Segments = function (rawImageArray) { - var head = 0, - segments = []; - - while (1) { - if (rawImageArray[head] == 255 && rawImageArray[head + 1] == 218) { break; } - if (rawImageArray[head] == 255 && rawImageArray[head + 1] == 216) { - head += 2; - } - else { - var length = rawImageArray[head + 2] * 256 + rawImageArray[head + 3], - endPoint = head + length + 2, - seg = rawImageArray.slice(head, endPoint); - segments.push(seg); - head = endPoint; - } - if (head > rawImageArray.length) { break; } - } - - return segments; - }; - - - - ExifRestorer.decode64 = function (input) { - let - chr1: any, chr2: any, chr3: any = '', - enc1: any, enc2: any, enc3: any, enc4: any = '', - i = 0, - buf = []; - - // remove all characters that are not A-Z, a-z, 0-9, +, /, or = - var base64test = /[^A-Za-z0-9\+\/\=]/g; - if (base64test.exec(input)) { - alert('There were invalid base64 characters in the input text.\n' + - 'Valid base64 characters are A-Z, a-z, 0-9, \'+\', \'/\',and \'=\'\n' + - 'Expect errors in decoding.'); - } - input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ''); - - do { - enc1 = this.KEY_STR.indexOf(input.charAt(i++)); - enc2 = this.KEY_STR.indexOf(input.charAt(i++)); - enc3 = this.KEY_STR.indexOf(input.charAt(i++)); - enc4 = this.KEY_STR.indexOf(input.charAt(i++)); - - chr1 = (enc1 << 2) | (enc2 >> 4); - chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); - chr3 = ((enc3 & 3) << 6) | enc4; - - buf.push(chr1); - - if (enc3 != 64) { - buf.push(chr2); - } - if (enc4 != 64) { - buf.push(chr3); - } - - chr1 = chr2 = chr3 = ''; - enc1 = enc2 = enc3 = enc4 = ''; - - } while (i < input.length); - - return buf; - }; - - - return ExifRestorer; -})(); diff --git a/src/photo/PhotoUpload.tsx b/src/photo/PhotoUpload.tsx index 1d75b347..b55be584 100644 --- a/src/photo/PhotoUpload.tsx +++ b/src/photo/PhotoUpload.tsx @@ -31,14 +31,14 @@ export default function PhotoUpload({ maxSize={shouldResize ? MAX_IMAGE_SIZE : undefined} loading={isUploading} onBlobReady={(blob, extension) => { - setIsUploading(true); - setUploadError(''); if (debug) { setDebugDownload({ href: URL.createObjectURL(blob), fileName: `debug.${extension}`, }); } else { + setIsUploading(true); + setUploadError(''); uploadPhotoFromClient( blob, extension, @@ -60,7 +60,7 @@ export default function PhotoUpload({ />
- {debugDownload && + {debug && debugDownload && { + return new Promise((resolve) => { + setTimeout(() => { + resolve('Ready'); + }, delay); + }); +}; From 9f8829bb0a32767d243ed9888d9e820b5beec53b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 13:39:02 -0500 Subject: [PATCH 4/5] Improve photo upload state handling --- src/components/ImageInput.tsx | 9 ++++++--- src/photo/PhotoUpload.tsx | 14 +++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 60329bd8..3fa1b470 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -11,13 +11,15 @@ import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; const INPUT_ID = 'file'; export default function ImageInput({ + onStart, onBlobReady, maxSize, quality = 0.8, loading, debug, }: { - onBlobReady: (blob: Blob, extension?: string) => void + onStart?: () => void + onBlobReady?: (blob: Blob, extension?: string) => void maxSize?: number quality?: number loading?: boolean @@ -62,6 +64,7 @@ export default function ImageInput({ accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')} disabled={loading} onChange={async e => { + onStart?.(); const file = e.currentTarget.files?.[0]; setFileName(file?.name); const extension = file?.name.split('.').pop()?.toLowerCase(); @@ -69,7 +72,7 @@ export default function ImageInput({ if (file) { if (!maxSize) { // No need to resize - onBlobReady(file); + onBlobReady?.(file); } else if (canvas) { const image = await blobToImage(file); setImage(image); @@ -106,7 +109,7 @@ export default function ImageInput({ // await sleep(); const newBlob = await CopyExif(file, blob, imageType); // await sleep(); - onBlobReady(newBlob, extension); + onBlobReady?.(newBlob, extension); } }, imageType, diff --git a/src/photo/PhotoUpload.tsx b/src/photo/PhotoUpload.tsx index b55be584..29122afa 100644 --- a/src/photo/PhotoUpload.tsx +++ b/src/photo/PhotoUpload.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { pathForAdminUploadUrl } from '@/site/paths'; import ImageInput from '../components/ImageInput'; import { MAX_IMAGE_SIZE } from '@/utility/image'; +import { cc } from '@/utility/css'; export default function PhotoUpload({ shouldResize, @@ -24,21 +25,28 @@ export default function PhotoUpload({ const router = useRouter(); return ( -
+
{ + setIsUploading(true); + setUploadError(''); + }} onBlobReady={(blob, extension) => { if (debug) { setDebugDownload({ href: URL.createObjectURL(blob), fileName: `debug.${extension}`, }); - } else { - setIsUploading(true); + setIsUploading(false); setUploadError(''); + } else { uploadPhotoFromClient( blob, extension, From bda7dea21891af04cc88622f66538609cd9f7d6d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 14 Oct 2023 17:01:20 -0500 Subject: [PATCH 5/5] Enable client resizing for non-pro customers --- src/app/(auth-state)/admin/photos/page.tsx | 3 ++- src/components/ImageInput.tsx | 22 +++++++++------------- src/lib/CopyExif.ts | 2 +- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 05ca0477..02a54941 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -27,6 +27,7 @@ import AdminGrid from '@/admin/AdminGrid'; import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; import BlobUrls from '@/admin/BlobUrls'; +import { PRO_MODE_ENABLED } from '@/site/config'; export const runtime = 'edge'; @@ -53,7 +54,7 @@ export default async function AdminTagsPage({ - + {blobPhotoUrls.length > 0 &&
{ if (blob) { - // await sleep(); - const newBlob = await CopyExif(file, blob, imageType); - // await sleep(); - onBlobReady?.(newBlob, extension); + const blobWithExif = await CopyExif(file, blob); + onBlobReady?.(blobWithExif, extension); } }, - imageType, + 'image/jpeg', quality, ); + } else { + // No need to process + onBlobReady?.(file); } } }} diff --git a/src/lib/CopyExif.ts b/src/lib/CopyExif.ts index 268759d1..edb8b4b4 100644 --- a/src/lib/CopyExif.ts +++ b/src/lib/CopyExif.ts @@ -11,7 +11,7 @@ const SOS = 0xffda; const APP1 = 0xffe1; const EXIF = 0x45786966; -const retrieveExif = (blob: Blob): any => +const retrieveExif = (blob: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener('load', e => {