Share percentFromAxiosProgress for axios upload and ExifTool WASM download; update queue entries from upload helpers via queue key.
244 lines
7.4 KiB
TypeScript
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 });
|
|
}
|
|
});
|