Files
private_image/frontend/src/workers/exiftoolWorker.ts
T
nobswebdev b5e50f786b Show upload progress percent on queue rows during POST.
Share percentFromAxiosProgress for axios upload and ExifTool WASM download; update queue entries from upload helpers via queue key.
2026-11-03 17:41:33 +01:00

244 lines
7.4 KiB
TypeScript

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<Response> => {
if (!cachedZeroperlWasm) {
const { data } = await axios.get<ArrayBuffer>(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<void> | null = null;
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}.
*
* @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error).
* @see Main thread `initializeExiftool()` 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 },
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<void> => {
while (jobQueue.length > 0) {
const job = jobQueue.shift();
if (!job) {
return;
}
await processJob(job);
}
};
const processJob = async (job: ExiftoolWorkerJob): Promise<void> => {
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<ExiftoolWorkerInMessage>) => {
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 });
}
});