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.
This commit is contained in:
@@ -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);
|
||||||
@@ -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<ExiftoolInitStatus>('idle');
|
||||||
|
const initErrorMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
const worker = ref<Worker | null>(null);
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | 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<void> => {
|
||||||
|
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<void>((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<ExiftoolWorkerOutMessage>): 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<string> => {
|
||||||
|
await initializeExiftool();
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const workerInstance = worker.value;
|
||||||
|
|
||||||
|
if (!workerInstance) {
|
||||||
|
throw new Error('ExifTool worker is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
readMetadataWaiters.set(id, { resolve, reject });
|
||||||
|
workerInstance.postMessage({ type: 'read-metadata', id, file });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
initStatus,
|
||||||
|
initErrorMessage,
|
||||||
|
initializeExiftool,
|
||||||
|
readMetadata
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ExiftoolInitStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type ExiftoolWorkerInMessage =
|
||||||
|
| { type: 'init' }
|
||||||
|
| { type: 'read-metadata'; id: string; file: File };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void> | 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<void> => {
|
||||||
|
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<ExiftoolWorkerInMessage>) => {
|
||||||
|
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')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user