From b47c5c76969549b526b73a7875f356a5685d712a Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Thu, 15 Oct 2026 17:18:21 +0200 Subject: [PATCH] Add ExifTool worker and Pinia store for off-thread metadata reads. Warm up WASM in a dedicated worker with a tiny JPEG probe and expose init/read RPC to the main thread. --- frontend/src/consts/exiftoolWorkerInitJpeg.ts | 6 + frontend/src/stores/exiftool.ts | 126 ++++++++++++++++++ .../src/types/metadata/ExiftoolInitStatus.ts | 1 + .../types/workers/exiftoolWorkerInMessage.ts | 3 + .../types/workers/exiftoolWorkerOutMessage.ts | 5 + frontend/src/utils/base64ToBytes.ts | 10 ++ frontend/src/workers/exiftoolWorker.ts | 89 +++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 frontend/src/consts/exiftoolWorkerInitJpeg.ts create mode 100644 frontend/src/stores/exiftool.ts create mode 100644 frontend/src/types/metadata/ExiftoolInitStatus.ts create mode 100644 frontend/src/types/workers/exiftoolWorkerInMessage.ts create mode 100644 frontend/src/types/workers/exiftoolWorkerOutMessage.ts create mode 100644 frontend/src/utils/base64ToBytes.ts create mode 100644 frontend/src/workers/exiftoolWorker.ts diff --git a/frontend/src/consts/exiftoolWorkerInitJpeg.ts b/frontend/src/consts/exiftoolWorkerInitJpeg.ts new file mode 100644 index 0000000..b2d0d60 --- /dev/null +++ b/frontend/src/consts/exiftoolWorkerInitJpeg.ts @@ -0,0 +1,6 @@ +import { base64ToBytes } from '@/utils/base64ToBytes'; + +const EXIFTOOL_WORKER_INIT_JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z'; + +export const EXIFTOOL_WORKER_INIT_JPEG = base64ToBytes(EXIFTOOL_WORKER_INIT_JPEG_BASE64); diff --git a/frontend/src/stores/exiftool.ts b/frontend/src/stores/exiftool.ts new file mode 100644 index 0000000..c4d0b04 --- /dev/null +++ b/frontend/src/stores/exiftool.ts @@ -0,0 +1,126 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type { ExiftoolInitStatus } from '@/types/metadata/ExiftoolInitStatus'; +import type { ExiftoolWorkerOutMessage } from '@/types/workers/exiftoolWorkerOutMessage'; + +export const useExiftoolStore = defineStore('exiftool', () => { + const initStatus = ref('idle'); + const initErrorMessage = ref(null); + + const worker = ref(null); + + let initPromise: Promise | null = null; + let initPromiseResolve: (() => void) | null = null; + let initPromiseReject: ((error: Error) => void) | null = null; + + const readMetadataWaiters = new Map< + string, + { resolve: (output: string) => void; reject: (error: Error) => void } + >(); + + const initializeExiftool = async (): Promise => { + if (initStatus.value === 'ready') { + return; + } + + if (initStatus.value === 'error') { + throw new Error(initErrorMessage.value ?? 'ExifTool is unavailable'); + } + + if (initStatus.value === 'idle') { + initStatus.value = 'loading'; + initErrorMessage.value = null; + initPromise = new Promise((resolve, reject) => { + initPromiseResolve = resolve; + initPromiseReject = reject; + }); + + if (!worker.value) { + worker.value = new Worker(new URL('../workers/exiftoolWorker.ts', import.meta.url), { + type: 'module' + }); + + worker.value.addEventListener('message', handleWorkerMessage); + } + + worker.value.postMessage({ type: 'init' }); + } + + if (!initPromise) { + throw new Error('ExifTool is not initializing'); + } + + await initPromise; + }; + + const handleWorkerMessage = (event: MessageEvent): void => { + const message = event.data; + + if (message.type === 'init-done') { + initStatus.value = 'ready'; + initPromiseResolve?.(); + initPromiseResolve = null; + initPromiseReject = null; + + return; + } + + if (message.type === 'init-error') { + initStatus.value = 'error'; + initErrorMessage.value = message.message; + initPromiseReject?.(new Error(message.message)); + initPromiseResolve = null; + initPromiseReject = null; + + return; + } + + if (message.type === 'read-metadata-done') { + const waiter = readMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + readMetadataWaiters.delete(message.id); + waiter.resolve(message.output); + + return; + } + + if (message.type === 'read-metadata-error') { + const waiter = readMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + readMetadataWaiters.delete(message.id); + waiter.reject(new Error(message.message)); + } + }; + + const readMetadata = async (file: File): Promise => { + await initializeExiftool(); + + const id = crypto.randomUUID(); + + const workerInstance = worker.value; + + if (!workerInstance) { + throw new Error('ExifTool worker is unavailable'); + } + + return new Promise((resolve, reject) => { + readMetadataWaiters.set(id, { resolve, reject }); + workerInstance.postMessage({ type: 'read-metadata', id, file }); + }); + }; + + return { + initStatus, + initErrorMessage, + initializeExiftool, + readMetadata + }; +}); diff --git a/frontend/src/types/metadata/ExiftoolInitStatus.ts b/frontend/src/types/metadata/ExiftoolInitStatus.ts new file mode 100644 index 0000000..f5c4ff1 --- /dev/null +++ b/frontend/src/types/metadata/ExiftoolInitStatus.ts @@ -0,0 +1 @@ +export type ExiftoolInitStatus = 'idle' | 'loading' | 'ready' | 'error'; diff --git a/frontend/src/types/workers/exiftoolWorkerInMessage.ts b/frontend/src/types/workers/exiftoolWorkerInMessage.ts new file mode 100644 index 0000000..ef0a201 --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerInMessage.ts @@ -0,0 +1,3 @@ +export type ExiftoolWorkerInMessage = + | { type: 'init' } + | { type: 'read-metadata'; id: string; file: File }; diff --git a/frontend/src/types/workers/exiftoolWorkerOutMessage.ts b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts new file mode 100644 index 0000000..c1cae7e --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts @@ -0,0 +1,5 @@ +export type ExiftoolWorkerOutMessage = + | { type: 'init-done' } + | { type: 'init-error'; message: string } + | { type: 'read-metadata-done'; id: string; output: string } + | { type: 'read-metadata-error'; id: string; message: string }; diff --git a/frontend/src/utils/base64ToBytes.ts b/frontend/src/utils/base64ToBytes.ts new file mode 100644 index 0000000..961118a --- /dev/null +++ b/frontend/src/utils/base64ToBytes.ts @@ -0,0 +1,10 @@ +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts new file mode 100644 index 0000000..ee42806 --- /dev/null +++ b/frontend/src/workers/exiftoolWorker.ts @@ -0,0 +1,89 @@ +import { parseMetadata } from '@uswriting/exiftool'; +import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; +import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; +import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; + +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}. + * + * Concurrent callers (init message, read-metadata, future write) share one in-flight warmup. + * + * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). + * @see Main thread `initialize()` 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 }); + + if (!result.success) { + throw new Error(result.error); + } + + initialized = true; + } catch (error) { + initializeWorkerPromise = null; + + throw error; + } + })(); + } + + return initializeWorkerPromise; +}; + +self.addEventListener('message', async (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'init') { + try { + await initializeWorker(); + + self.postMessage({ type: 'init-done' }); + } catch (error) { + self.postMessage({ + type: 'init-error', + message: resolveErrorMessage(error, 'Failed to prepare ExifTool') + }); + } + + return; + } + + if (message.type === 'read-metadata') { + try { + await initializeWorker(); + + const result = await parseMetadata(message.file); + + if (!result.success) { + self.postMessage({ type: 'read-metadata-error', id: message.id, message: result.error }); + + return; + } + + self.postMessage({ type: 'read-metadata-done', id: message.id, output: result.data }); + } catch (error) { + self.postMessage({ + type: 'read-metadata-error', + id: message.id, + message: resolveErrorMessage(error, 'Could not read image metadata') + }); + } + } +});