From cab25ad5af781d2bb895c64b2b488e9070f99171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Mar 2026 04:03:13 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20WebWorker=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/ts/imagePreprocessor.ts | 212 +++++++++++++++++++++++ src/utils/ts/imagePreprocessor.worker.ts | 132 ++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/utils/ts/imagePreprocessor.ts create mode 100644 src/utils/ts/imagePreprocessor.worker.ts diff --git a/src/utils/ts/imagePreprocessor.ts b/src/utils/ts/imagePreprocessor.ts new file mode 100644 index 00000000..040206a7 --- /dev/null +++ b/src/utils/ts/imagePreprocessor.ts @@ -0,0 +1,212 @@ +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.9; +const JPEG_MIME_TYPE = 'image/jpeg'; +const PNG_MIME_TYPE = 'image/png'; +const WEBP_MIME_TYPE = 'image/webp'; +const SUPPORTED_IMAGE_MIME_TYPES = new Set([JPEG_MIME_TYPE, PNG_MIME_TYPE, WEBP_MIME_TYPE]); + +interface ImagePreprocessRequest { + id: number; + file: File; + maxDimension?: number; + quality?: number; +} + +interface ImagePreprocessResponse { + file: File; + id: number; +} + +interface ImagePreprocessErrorResponse { + errorMessage: string; + id: number; +} + +interface PrepareImageFileOptions { + maxDimension?: number; + quality?: number; +} + +interface PendingRequestHandlers { + reject: (error: unknown) => void; + resolve: (value: ImagePreprocessResponse) => void; +} + +let imagePreprocessWorker: Worker | null = null; +let messageId = 0; +const pendingImagePreprocessRequests = new Map(); + +function getOutputMimeType() { + return WEBP_MIME_TYPE; +} + +function getOutputFileName(fileName: string, mimeType: string) { + const nextExtension = mimeType === WEBP_MIME_TYPE ? 'webp' : 'jpg'; + const sanitizedFileName = fileName.replace(/\.[^.]+$/, ''); + + return `${sanitizedFileName}.${nextExtension}`; +} + +function getTargetDimensions(width: number, height: number, maxDimension: number) { + const largestDimension = Math.max(width, height); + + if (largestDimension <= maxDimension) { + return { height, width }; + } + + const scale = maxDimension / largestDimension; + + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +function getWorker() { + if (typeof Worker === 'undefined') { + return null; + } + + if (!imagePreprocessWorker) { + imagePreprocessWorker = new Worker(new URL('./imagePreprocessor.worker.ts', import.meta.url), { + type: 'module', + }); + imagePreprocessWorker.onmessage = (event: MessageEvent) => { + const currentRequest = pendingImagePreprocessRequests.get(event.data.id); + + if (!currentRequest) { + return; + } + + pendingImagePreprocessRequests.delete(event.data.id); + + if ('errorMessage' in event.data) { + currentRequest.reject(new Error(event.data.errorMessage)); + return; + } + + currentRequest.resolve(event.data); + }; + imagePreprocessWorker.onerror = (event) => { + pendingImagePreprocessRequests.forEach(({ reject }) => { + reject(new Error(event.message || '이미지 전처리 worker에서 오류가 발생했습니다.')); + }); + pendingImagePreprocessRequests.clear(); + imagePreprocessWorker?.terminate(); + imagePreprocessWorker = null; + }; + } + + return imagePreprocessWorker; +} + +async function runWorkerPreprocess(file: File, options: PrepareImageFileOptions) { + const worker = getWorker(); + + if (!worker || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') { + return null; + } + + const id = messageId++; + const requestPayload: ImagePreprocessRequest = { + file, + id, + maxDimension: options.maxDimension ?? DEFAULT_MAX_DIMENSION, + quality: options.quality ?? DEFAULT_QUALITY, + }; + + return new Promise((resolve, reject) => { + pendingImagePreprocessRequests.set(id, { reject, resolve }); + worker.postMessage(requestPayload); + }); +} + +async function loadImageElement(file: File) { + const previewUrl = URL.createObjectURL(file); + + try { + return await new Promise((resolve, reject) => { + const imageElement = new Image(); + imageElement.onload = () => resolve(imageElement); + imageElement.onerror = () => reject(new Error('이미지 로딩에 실패했습니다.')); + imageElement.src = previewUrl; + }); + } finally { + URL.revokeObjectURL(previewUrl); + } +} + +async function convertCanvasToBlob( + canvasElement: HTMLCanvasElement, + outputMimeType: string, + quality: number | undefined +) { + return new Promise((resolve, reject) => { + canvasElement.toBlob( + (blob) => { + if (!blob) { + reject(new Error('이미지 Blob 생성에 실패했습니다.')); + return; + } + + resolve(blob); + }, + outputMimeType, + quality + ); + }); +} + +async function runMainThreadPreprocess(file: File, options: PrepareImageFileOptions) { + const imageElement = await loadImageElement(file); + const originalWidth = imageElement.naturalWidth || imageElement.width; + const originalHeight = imageElement.naturalHeight || imageElement.height; + const outputMimeType = getOutputMimeType(); + const { width: outputWidth, height: outputHeight } = getTargetDimensions( + originalWidth, + originalHeight, + options.maxDimension ?? DEFAULT_MAX_DIMENSION + ); + + if (outputWidth === originalWidth && outputHeight === originalHeight && file.type === outputMimeType) { + return file; + } + + const canvasElement = document.createElement('canvas'); + canvasElement.width = outputWidth; + canvasElement.height = outputHeight; + const context = canvasElement.getContext('2d', { + alpha: true, + }); + + if (!context) { + throw new Error('2D canvas context를 생성하지 못했습니다.'); + } + + context.drawImage(imageElement, 0, 0, outputWidth, outputHeight); + + const blob = await convertCanvasToBlob(canvasElement, outputMimeType, options.quality ?? DEFAULT_QUALITY); + + return new File([blob], getOutputFileName(file.name, blob.type || outputMimeType), { + lastModified: Date.now(), + type: blob.type || outputMimeType, + }); +} + +export async function prepareImageFile(file: File, options: PrepareImageFileOptions = {}) { + if (!SUPPORTED_IMAGE_MIME_TYPES.has(file.type)) { + return file; + } + + try { + const workerResponse = await runWorkerPreprocess(file, options); + + if (workerResponse) { + return workerResponse.file; + } + + return await runMainThreadPreprocess(file, options); + } catch { + return file; + } +} diff --git a/src/utils/ts/imagePreprocessor.worker.ts b/src/utils/ts/imagePreprocessor.worker.ts new file mode 100644 index 00000000..0f0b8195 --- /dev/null +++ b/src/utils/ts/imagePreprocessor.worker.ts @@ -0,0 +1,132 @@ +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.9; +const WEBP_MIME_TYPE = 'image/webp'; + +interface ImagePreprocessRequest { + id: number; + file: File; + maxDimension?: number; + quality?: number; +} + +interface ImagePreprocessResponse { + durationMs: number; + file: File; + id: number; + originalHeight: number; + originalWidth: number; + outputHeight: number; + outputMimeType: string; + outputWidth: number; + skipped: boolean; + workerUsed: boolean; +} + +function getOutputMimeType() { + return WEBP_MIME_TYPE; +} + +function getOutputFileName(fileName: string, mimeType: string) { + const nextExtension = mimeType === WEBP_MIME_TYPE ? 'webp' : 'jpg'; + const sanitizedFileName = fileName.replace(/\.[^.]+$/, ''); + + return `${sanitizedFileName}.${nextExtension}`; +} + +function getTargetDimensions(width: number, height: number, maxDimension: number) { + const largestDimension = Math.max(width, height); + + if (largestDimension <= maxDimension) { + return { height, width }; + } + + const scale = maxDimension / largestDimension; + + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +self.onmessage = async (event: MessageEvent) => { + const startedAt = performance.now(); + const { file, id } = event.data; + const maxDimension = event.data.maxDimension ?? DEFAULT_MAX_DIMENSION; + const quality = event.data.quality ?? DEFAULT_QUALITY; + + try { + const bitmap = await createImageBitmap(file); + const originalWidth = bitmap.width; + const originalHeight = bitmap.height; + const { width: outputWidth, height: outputHeight } = getTargetDimensions( + originalWidth, + originalHeight, + maxDimension + ); + const outputMimeType = getOutputMimeType(); + + if (outputWidth === originalWidth && outputHeight === originalHeight && file.type === outputMimeType) { + bitmap.close(); + const response: ImagePreprocessResponse = { + durationMs: Math.round(performance.now() - startedAt), + file, + id, + originalHeight, + originalWidth, + outputHeight, + outputMimeType: file.type || outputMimeType, + outputWidth, + skipped: true, + workerUsed: true, + }; + self.postMessage(response); + return; + } + + const canvas = new OffscreenCanvas(outputWidth, outputHeight); + const context = canvas.getContext('2d', { + alpha: true, + desynchronized: true, + }); + + if (!context) { + bitmap.close(); + throw new Error('2D canvas context를 생성하지 못했습니다.'); + } + + context.drawImage(bitmap, 0, 0, outputWidth, outputHeight); + bitmap.close(); + + const blob = await canvas.convertToBlob({ + quality, + type: outputMimeType, + }); + + const nextFile = new File([blob], getOutputFileName(file.name, blob.type || outputMimeType), { + lastModified: Date.now(), + type: blob.type || outputMimeType, + }); + + const response: ImagePreprocessResponse = { + durationMs: Math.round(performance.now() - startedAt), + file: nextFile, + id, + originalHeight, + originalWidth, + outputHeight, + outputMimeType: nextFile.type || outputMimeType, + outputWidth, + skipped: false, + workerUsed: true, + }; + + self.postMessage(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '이미지 전처리에 실패했습니다.'; + + self.postMessage({ + errorMessage, + id, + }); + } +}; From b28773f7a09aa65a3a2669402e880cc8974a9440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Mar 2026 04:03:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20preview=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Club/Application/clubFeePage.tsx | 25 +++-- .../Manager/ManagedClubProfile/index.tsx | 47 +++++++--- .../Manager/ManagedRecruitmentWrite/index.tsx | 91 ++++++++++++++----- src/utils/ts/promise.ts | 32 +++++++ 4 files changed, 153 insertions(+), 42 deletions(-) create mode 100644 src/utils/ts/promise.ts diff --git a/src/pages/Club/Application/clubFeePage.tsx b/src/pages/Club/Application/clubFeePage.tsx index f1c59be8..31a3dd7e 100644 --- a/src/pages/Club/Application/clubFeePage.tsx +++ b/src/pages/Club/Application/clubFeePage.tsx @@ -7,6 +7,7 @@ import Portal from '@/components/common/Portal'; import { useClubApplicationStore } from '@/stores/clubApplicationStore'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useUploadImage from '@/utils/hooks/useUploadImage'; +import { prepareImageFile } from '@/utils/ts/imagePreprocessor'; import AccountInfoCard from './components/AccountInfo'; import useApplyToClub from './hooks/useApplyToClub'; import { useGetClubFee } from './hooks/useGetClubFee'; @@ -28,8 +29,9 @@ function ClubFeePage() { const fileInputRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(null); const [imageFile, setImageFile] = useState(null); + const [isPreparingImage, setIsPreparingImage] = useState(false); const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState(); - const isSubmitting = isApplyingToClub || isUploadingImage; + const isSubmitting = isApplyingToClub || isPreparingImage || isUploadingImage; useEffect(() => { return () => { @@ -38,17 +40,26 @@ function ClubFeePage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleImageSelect = (e: React.ChangeEvent) => { + const handleImageSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || isPreparingImage) return; - if (previewUrl) URL.revokeObjectURL(previewUrl); - setImageFile(file); - setPreviewUrl(URL.createObjectURL(file)); + setIsPreparingImage(true); + + try { + const preparedFile = await prepareImageFile(file); + + if (previewUrl) URL.revokeObjectURL(previewUrl); + setImageFile(preparedFile); + setPreviewUrl(URL.createObjectURL(preparedFile)); + } finally { + setIsPreparingImage(false); + } }; const handleSubmit = async () => { if (!imageFile) return; + const { fileUrl } = await uploadImage(imageFile); await applyToClub({ answers, feePaymentImageUrl: fileUrl }); }; @@ -123,7 +134,7 @@ function ClubFeePage() { onClick={handleSubmit} disabled={!imageFile || isSubmitting} > - {isSubmitting ? '제출 중...' : '제출하기'} + {isPreparingImage ? '이미지 준비 중...' : isSubmitting ? '제출 중...' : '제출하기'} {isImageOpen && previewUrl && ( diff --git a/src/pages/Manager/ManagedClubProfile/index.tsx b/src/pages/Manager/ManagedClubProfile/index.tsx index d1e14669..3e9e09eb 100644 --- a/src/pages/Manager/ManagedClubProfile/index.tsx +++ b/src/pages/Manager/ManagedClubProfile/index.tsx @@ -7,6 +7,7 @@ import { useGetClubDetail } from '@/pages/Club/ClubDetail/hooks/useGetClubDetail import { useUpdateClubInfo } from '@/pages/Manager/hooks/useManagedClubs'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useUploadImage from '@/utils/hooks/useUploadImage'; +import { prepareImageFile } from '@/utils/ts/imagePreprocessor'; const DESCRIPTION_MAX_LENGTH = 25; @@ -48,6 +49,7 @@ function ManagedClubInfo() { const [introduce, setIntroduce] = useState(initialIntroduce); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(initialImageUrl); + const [isPreparingImage, setIsPreparingImage] = useState(false); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); @@ -86,22 +88,31 @@ function ManagedClubInfo() { } }; - const handleImageSelect = (e: ChangeEvent) => { + const handleImageSelect = async (e: ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || isPreparingImage) return; - clearLocalPreviewUrl(localPreviewUrlRef); + setIsPreparingImage(true); + + try { + const preparedFile = await prepareImageFile(file); - const previewUrl = URL.createObjectURL(file); - localPreviewUrlRef.current = previewUrl; + clearLocalPreviewUrl(localPreviewUrlRef); - setImageFile(file); - setImagePreview(previewUrl); - e.target.value = ''; + const previewUrl = URL.createObjectURL(preparedFile); + localPreviewUrlRef.current = previewUrl; + + setImageFile(preparedFile); + setImagePreview(previewUrl); + e.target.value = ''; + } finally { + setIsPreparingImage(false); + } }; const handleImageClick = () => { + if (isPreparingImage) return; fileInputRef.current?.click(); }; @@ -262,10 +273,16 @@ function ManagedClubInfo() { @@ -276,11 +293,17 @@ function ManagedClubInfo() {
) : (
{`업로드 × @@ -447,16 +484,18 @@ function ManagedRecruitmentWrite() { @@ -509,9 +548,15 @@ function ManagedRecruitmentWrite() {
diff --git a/src/utils/ts/promise.ts b/src/utils/ts/promise.ts new file mode 100644 index 00000000..89bb1fc5 --- /dev/null +++ b/src/utils/ts/promise.ts @@ -0,0 +1,32 @@ +export async function mapWithConcurrencyLimit( + items: T[], + concurrency: number, + iteratee: (item: T, index: number) => Promise, + onResolved?: (result: TResult, index: number) => void +) { + if (items.length === 0) { + return []; + } + + const results = new Array(items.length); + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex++; + + if (currentIndex >= items.length) { + return; + } + + const result = await iteratee(items[currentIndex], currentIndex); + results[currentIndex] = result; + onResolved?.(result, currentIndex); + } + }); + + await Promise.all(workers); + + return results; +} From bfd4cef71fd2efca4e74d980d55bdd52d58b33cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Mar 2026 04:27:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Manager/ManagedClubProfile/index.tsx | 8 ++ .../Manager/ManagedRecruitmentWrite/index.tsx | 28 ++++-- src/utils/ts/imagePreprocessor.ts | 89 +++++++++++++------ 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/src/pages/Manager/ManagedClubProfile/index.tsx b/src/pages/Manager/ManagedClubProfile/index.tsx index 3e9e09eb..1d8964eb 100644 --- a/src/pages/Manager/ManagedClubProfile/index.tsx +++ b/src/pages/Manager/ManagedClubProfile/index.tsx @@ -53,6 +53,7 @@ function ManagedClubInfo() { const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); + const imageDeletedRef = useRef(false); const localPreviewUrlRef = useRef(null); const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB'); @@ -93,11 +94,17 @@ function ManagedClubInfo() { if (!file || isPreparingImage) return; + imageDeletedRef.current = false; setIsPreparingImage(true); try { const preparedFile = await prepareImageFile(file); + if (imageDeletedRef.current) { + e.target.value = ''; + return; + } + clearLocalPreviewUrl(localPreviewUrlRef); const previewUrl = URL.createObjectURL(preparedFile); @@ -117,6 +124,7 @@ function ManagedClubInfo() { }; const handleDeleteImage = () => { + imageDeletedRef.current = true; clearLocalPreviewUrl(localPreviewUrlRef); setImageFile(null); setImagePreview(''); diff --git a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx index 1ad833ba..1c10257e 100644 --- a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx +++ b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx @@ -163,24 +163,38 @@ function ManagedRecruitmentWrite() { const selectedFiles = Array.from(files); const previousImageCount = images.length; - let completedCount = 0; + let visiblePreparedCount = 0; + const preparedItems: Array = new Array(selectedFiles.length).fill(null); setIsPreparingImages(true); mapWithConcurrencyLimit( selectedFiles, IMAGE_PREPARATION_CONCURRENCY, (file) => prepareImageFile(file), - (preparedFile) => { - completedCount += 1; - - const newItem = { + (preparedFile, index) => { + preparedItems[index] = { file: preparedFile, previewUrl: URL.createObjectURL(preparedFile), }; + let nextVisiblePreparedCount = visiblePreparedCount; + + while (nextVisiblePreparedCount < preparedItems.length && preparedItems[nextVisiblePreparedCount]) { + nextVisiblePreparedCount += 1; + } + + if (nextVisiblePreparedCount === visiblePreparedCount) { + return; + } + + const shouldFocusFirstPreparedImage = visiblePreparedCount === 0 && nextVisiblePreparedCount > 0; + visiblePreparedCount = nextVisiblePreparedCount; + const orderedPreparedItems = preparedItems.slice(0, visiblePreparedCount).filter(Boolean) as ImageItem[]; + startTransition(() => { - setImages((prev) => [...prev, newItem]); - if (completedCount === 1) { + setImages((prev) => [...prev.slice(0, previousImageCount), ...orderedPreparedItems]); + + if (shouldFocusFirstPreparedImage) { setCurrentImageIndex(previousImageCount); } }); diff --git a/src/utils/ts/imagePreprocessor.ts b/src/utils/ts/imagePreprocessor.ts index 040206a7..9ac2ce0d 100644 --- a/src/utils/ts/imagePreprocessor.ts +++ b/src/utils/ts/imagePreprocessor.ts @@ -29,7 +29,7 @@ interface PrepareImageFileOptions { interface PendingRequestHandlers { reject: (error: unknown) => void; - resolve: (value: ImagePreprocessResponse) => void; + resolve: (value: ImagePreprocessResponse | null) => void; } let imagePreprocessWorker: Worker | null = null; @@ -62,46 +62,65 @@ function getTargetDimensions(width: number, height: number, maxDimension: number }; } +function resetImagePreprocessWorker() { + imagePreprocessWorker?.terminate(); + imagePreprocessWorker = null; +} + function getWorker() { if (typeof Worker === 'undefined') { return null; } if (!imagePreprocessWorker) { - imagePreprocessWorker = new Worker(new URL('./imagePreprocessor.worker.ts', import.meta.url), { - type: 'module', - }); - imagePreprocessWorker.onmessage = (event: MessageEvent) => { - const currentRequest = pendingImagePreprocessRequests.get(event.data.id); + try { + const worker = new Worker(new URL('./imagePreprocessor.worker.ts', import.meta.url), { + type: 'module', + }); + + worker.onmessage = (event: MessageEvent) => { + const currentRequest = pendingImagePreprocessRequests.get(event.data.id); - if (!currentRequest) { - return; - } + if (!currentRequest) { + return; + } - pendingImagePreprocessRequests.delete(event.data.id); + pendingImagePreprocessRequests.delete(event.data.id); - if ('errorMessage' in event.data) { - currentRequest.reject(new Error(event.data.errorMessage)); - return; - } + if ('errorMessage' in event.data) { + currentRequest.reject(new Error(event.data.errorMessage)); + return; + } - currentRequest.resolve(event.data); - }; - imagePreprocessWorker.onerror = (event) => { - pendingImagePreprocessRequests.forEach(({ reject }) => { - reject(new Error(event.message || '이미지 전처리 worker에서 오류가 발생했습니다.')); - }); - pendingImagePreprocessRequests.clear(); - imagePreprocessWorker?.terminate(); - imagePreprocessWorker = null; - }; + currentRequest.resolve(event.data); + }; + worker.onerror = (event) => { + pendingImagePreprocessRequests.forEach(({ reject }) => { + reject(new Error(event.message || '이미지 전처리 worker에서 오류가 발생했습니다.')); + }); + pendingImagePreprocessRequests.clear(); + resetImagePreprocessWorker(); + }; + + imagePreprocessWorker = worker; + } catch { + resetImagePreprocessWorker(); + return null; + } } return imagePreprocessWorker; } async function runWorkerPreprocess(file: File, options: PrepareImageFileOptions) { - const worker = getWorker(); + let worker: Worker | null; + + try { + worker = getWorker(); + } catch { + resetImagePreprocessWorker(); + return null; + } if (!worker || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') { return null; @@ -115,9 +134,15 @@ async function runWorkerPreprocess(file: File, options: PrepareImageFileOptions) quality: options.quality ?? DEFAULT_QUALITY, }; - return new Promise((resolve, reject) => { - pendingImagePreprocessRequests.set(id, { reject, resolve }); - worker.postMessage(requestPayload); + return new Promise((resolve, reject) => { + try { + pendingImagePreprocessRequests.set(id, { reject, resolve }); + worker.postMessage(requestPayload); + } catch { + pendingImagePreprocessRequests.delete(id); + resetImagePreprocessWorker(); + resolve(null); + } }); } @@ -199,7 +224,13 @@ export async function prepareImageFile(file: File, options: PrepareImageFileOpti } try { - const workerResponse = await runWorkerPreprocess(file, options); + let workerResponse: ImagePreprocessResponse | null = null; + + try { + workerResponse = await runWorkerPreprocess(file, options); + } catch { + resetImagePreprocessWorker(); + } if (workerResponse) { return workerResponse.file;