import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; import { parseMetadata, writeMetadata } from '@uswriting/exiftool'; import axios from 'axios'; import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob'; import { percentFromAxiosProgress } from '@/utils/percentFromAxiosProgress'; import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; import { toArrayBuffer } from '@/utils/toArrayBuffer'; let cachedZeroperlWasm: ArrayBuffer | null = null; /** * Load `zeroperl.wasm` once per worker lifetime. Tor/Firefox often skip HTTP cache for * worker fetches and may not store responses above the memory-cache entry size limit. */ const fetchZeroperlWasm = async (): Promise => { if (!cachedZeroperlWasm) { const { data } = await axios.get(zeroperlWasmUrl, { responseType: 'arraybuffer', onDownloadProgress: progressEvent => { const percent = percentFromAxiosProgress(progressEvent); if (percent === null) { return; } self.postMessage({ type: 'wasm-download-progress', percent }); } }); cachedZeroperlWasm = data; } return new Response(cachedZeroperlWasm, { status: 200, headers: { 'Content-Type': 'application/wasm' } }); }; /** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */ const exiftoolParseOptions = { fetch: fetchZeroperlWasm }; const jobQueue: ExiftoolWorkerJob[] = []; let drainPromise: Promise | null = null; let initialized = false; let initializeWorkerPromise: Promise | null = null; /** * Cold-start the ExifTool WASM stack inside this worker (once per worker lifetime). * * `@uswriting/exiftool` has no public `init()` API: the first `parseMetadata` / `writeMetadata` * call downloads `zeroperl.wasm`, boots Perl, and mounts the bundled ExifTool script. To init * before the user picks a file, we must trigger that path ourselves. * * We run `parseMetadata` on {@link EXIFTOOL_WORKER_INIT_JPEG} so the library performs * a real read without needing a user `File`. Output is discarded; sets {@link initialized}. * * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). * @see Main thread `initializeExiftool()` posts `{ type: 'init' }` when idle. */ const initializeWorker = async (): Promise => { if (initialized) { return; } if (!initializeWorkerPromise) { initializeWorkerPromise = (async () => { try { const result = await parseMetadata( { name: 'init.jpg', data: EXIFTOOL_WORKER_INIT_JPEG }, exiftoolParseOptions ); if (!result.success) { throw new Error(result.error); } initialized = true; } catch (error) { initializeWorkerPromise = null; throw error; } })(); } return initializeWorkerPromise; }; const enqueue = (job: ExiftoolWorkerJob): void => { jobQueue.push(job); if (!drainPromise) { drainPromise = (async () => { try { await drain(); } catch { console.error('Failed to drain ExifTool worker'); } drainPromise = null; })(); } }; const drain = async (): Promise => { while (jobQueue.length > 0) { const job = jobQueue.shift(); if (!job) { return; } await processJob(job); } }; const processJob = async (job: ExiftoolWorkerJob): Promise => { if (job.type === 'init') { try { await initializeWorker(); self.postMessage({ type: 'init-done' }); } catch (error) { self.postMessage({ type: 'init-error', message: resolveErrorMessage(error, 'Could not prepare ExifTool') }); } return; } if (job.type === 'read-metadata') { try { await initializeWorker(); const result = await parseMetadata(job.file, exiftoolParseOptions); if (!result.success) { const message = result.error ?? 'Could not load image metadata'; console.error('[exiftool-worker] read failed', { id: job.id, file: job.file.name, message }); self.postMessage({ type: 'read-metadata-error', id: job.id, message }); return; } self.postMessage({ type: 'read-metadata-done', id: job.id, output: result.data }); } catch (error) { const message = resolveErrorMessage(error, 'Could not load image metadata'); console.error('[exiftool-worker] read threw', { id: job.id, file: job.file.name, error }); self.postMessage({ type: 'read-metadata-error', id: job.id, message }); } return; } if (job.type === 'strip-metadata') { try { await initializeWorker(); const result = await writeMetadata(job.file, {}, { args: ['-all='], ...exiftoolParseOptions }); if (!result.success) { const message = result.error ?? 'Could not strip image metadata'; console.error('[exiftool-worker] strip failed', { id: job.id, file: job.file.name, type: job.file.type, exitCode: result.exitCode, message }); self.postMessage({ type: 'strip-metadata-error', id: job.id, message }); return; } const data = toArrayBuffer(result.data); if (!data) { const message = 'Strip succeeded but returned no image bytes'; console.error('[exiftool-worker] strip missing data', { id: job.id, file: job.file.name, dataType: result.data === null ? 'null' : typeof result.data }); self.postMessage({ type: 'strip-metadata-error', id: job.id, message }); return; } self.postMessage({ type: 'strip-metadata-done', id: job.id, data }, { transfer: [data] }); } catch (error) { const message = resolveErrorMessage(error, 'Failed to strip metadata'); console.error('[exiftool-worker] strip failed', { id: job.id, file: job.file.name, error }); self.postMessage({ type: 'strip-metadata-error', id: job.id, message }); } } }; self.addEventListener('message', (event: MessageEvent) => { const message = event.data; if (message.type === 'init') { enqueue({ type: 'init' }); return; } if (message.type === 'read-metadata') { enqueue({ type: 'read-metadata', id: message.id, file: message.file }); return; } if (message.type === 'strip-metadata') { enqueue({ type: 'strip-metadata', id: message.id, file: message.file }); } });