From 1826cef8fe1043eec710049955979c9b69ba74dd Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 18 Oct 2026 18:02:49 +0200 Subject: [PATCH] Strip metadata in the ExifTool worker during upload preview. Cache stripped blobs, show original vs stripped metadata tabs, and keep user-facing errors on fixed store messages with console logging for diagnostics. --- frontend/patches/patch-exiftool-stderr.mjs | 1 - .../components/upload/MetadataInspector.vue | 65 +++++++++++-- frontend/src/stores/exiftool.ts | 64 ++++++++++++- frontend/src/stores/metadata.ts | 96 ++++++++++++++++--- .../src/types/metadata/MetadataPreview.ts | 2 +- frontend/src/types/metadata/MetadataTab.ts | 1 + .../types/workers/exiftoolWorkerInMessage.ts | 3 +- .../src/types/workers/exiftoolWorkerJob.ts | 3 +- .../types/workers/exiftoolWorkerOutMessage.ts | 4 +- frontend/src/utils/toArrayBuffer.ts | 15 +++ frontend/src/workers/exiftoolWorker.ts | 83 +++++++++++++++- 11 files changed, 305 insertions(+), 32 deletions(-) create mode 100644 frontend/src/types/metadata/MetadataTab.ts create mode 100644 frontend/src/utils/toArrayBuffer.ts diff --git a/frontend/patches/patch-exiftool-stderr.mjs b/frontend/patches/patch-exiftool-stderr.mjs index b105307..85d923a 100644 --- a/frontend/patches/patch-exiftool-stderr.mjs +++ b/frontend/patches/patch-exiftool-stderr.mjs @@ -5,7 +5,6 @@ * even if exit code is 0 (no stdout / no strip bytes returned). * * Fix: remove the two stderr early-return checks in the published bundle. - * Upload safety still relies on post-strip re-read + isCleanAfterStrip (app code). * * Upstream: https://github.com/6over3/exiftool (issue: warnings incorrectly treated as errors) * diff --git a/frontend/src/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue index b23e788..6c78537 100644 --- a/frontend/src/components/upload/MetadataInspector.vue +++ b/frontend/src/components/upload/MetadataInspector.vue @@ -5,18 +5,55 @@

{{ fileName }}

-
+

Select an image to view its metadata.

{{ entry.message }}

-
{{ entry.output }}
+

{{ entry.message }}

@@ -24,17 +61,27 @@ diff --git a/frontend/src/stores/exiftool.ts b/frontend/src/stores/exiftool.ts index c4d0b04..935aed5 100644 --- a/frontend/src/stores/exiftool.ts +++ b/frontend/src/stores/exiftool.ts @@ -18,6 +18,11 @@ export const useExiftoolStore = defineStore('exiftool', () => { { resolve: (output: string) => void; reject: (error: Error) => void } >(); + const stripMetadataWaiters = new Map< + string, + { resolve: (data: ArrayBuffer) => void; reject: (error: Error) => void } + >(); + const initializeExiftool = async (): Promise => { if (initStatus.value === 'ready') { return; @@ -41,6 +46,14 @@ export const useExiftoolStore = defineStore('exiftool', () => { }); worker.value.addEventListener('message', handleWorkerMessage); + + worker.value.addEventListener('error', event => { + console.error('[exiftool] worker error', event.message, event.error ?? event); + }); + + worker.value.addEventListener('messageerror', event => { + console.error('[exiftool] worker messageerror', event); + }); } worker.value.postMessage({ type: 'init' }); @@ -66,6 +79,7 @@ export const useExiftoolStore = defineStore('exiftool', () => { } if (message.type === 'init-error') { + console.error('[exiftool] init-error', message.message); initStatus.value = 'error'; initErrorMessage.value = message.message; initPromiseReject?.(new Error(message.message)); @@ -96,6 +110,34 @@ export const useExiftoolStore = defineStore('exiftool', () => { } readMetadataWaiters.delete(message.id); + console.error('[exiftool] read-metadata-error', { id: message.id, message: message.message }); + waiter.reject(new Error(message.message)); + + return; + } + + if (message.type === 'strip-metadata-done') { + const waiter = stripMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + stripMetadataWaiters.delete(message.id); + waiter.resolve(message.data); + + return; + } + + if (message.type === 'strip-metadata-error') { + const waiter = stripMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + stripMetadataWaiters.delete(message.id); + console.error('[exiftool] strip-metadata-error', { id: message.id, message: message.message }); waiter.reject(new Error(message.message)); } }; @@ -117,10 +159,30 @@ export const useExiftoolStore = defineStore('exiftool', () => { }); }; + const stripAllMetadata = async (file: File): Promise => { + await initializeExiftool(); + + const id = crypto.randomUUID(); + + const workerInstance = worker.value; + + if (!workerInstance) { + throw new Error('ExifTool worker is unavailable'); + } + + const data = await new Promise((resolve, reject) => { + stripMetadataWaiters.set(id, { resolve, reject }); + workerInstance.postMessage({ type: 'strip-metadata', id, file }); + }); + + return new Blob([data], { type: file.type || 'application/octet-stream' }); + }; + return { initStatus, initErrorMessage, initializeExiftool, - readMetadata + readMetadata, + stripAllMetadata }; }); diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts index e8bbb8f..a06540b 100644 --- a/frontend/src/stores/metadata.ts +++ b/frontend/src/stores/metadata.ts @@ -7,8 +7,9 @@ const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool'; const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool'; const METADATA_LOADING_MESSAGE = 'Loading metadata'; const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata'; +const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata'; const METADATA_IDLE_MESSAGE = 'Waiting to load metadata'; -const METADATA_READY_MESSAGE = 'Metadata loaded'; +const METADATA_READY_MESSAGE = 'Metadata stripped'; const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = { status: 'idle', @@ -22,11 +23,12 @@ const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = { export const useMetadataStore = defineStore('metadata', () => { const previewsByKey = ref>({}); + const strippedBlobByKey = ref>({}); const exiftoolStore = useExiftoolStore(); const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore); - const { initializeExiftool, readMetadata } = exiftoolStore; + const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore; const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; @@ -40,6 +42,16 @@ export const useMetadataStore = defineStore('metadata', () => { return previewsByKey.value[key] ?? METADATA_IDLE_PREVIEW; }; + const getStrippedBlob = (file?: File | null): Blob | null => { + if (!file) { + return null; + } + + const key = makeKeyFromFile(file); + + return strippedBlobByKey.value[key] ?? null; + }; + const loadPreview = async (file: File): Promise => { const key = makeKeyFromFile(file); @@ -72,7 +84,9 @@ export const useMetadataStore = defineStore('metadata', () => { ...previewsByKey.value, [key]: METADATA_LOADING_PREVIEW }; - } catch { + } catch (error) { + console.error('[metadata] ExifTool init failed', { file: file.name, error }); + const entry: MetadataPreviewEntry = { status: 'error', message: EXIFTOOL_PREPARE_ERROR_MESSAGE @@ -84,19 +98,13 @@ export const useMetadataStore = defineStore('metadata', () => { } } + let output: string; + try { - const output = await readMetadata(file); + output = await readMetadata(file); + } catch (error) { + console.error('[metadata] read failed', { file: file.name, error }); - const entry: MetadataPreviewEntry = { - status: 'ready', - message: METADATA_READY_MESSAGE, - output - }; - - previewsByKey.value = { ...previewsByKey.value, [key]: entry }; - - return entry; - } catch { const entry: MetadataPreviewEntry = { status: 'error', message: METADATA_LOAD_ERROR_MESSAGE @@ -106,6 +114,63 @@ export const useMetadataStore = defineStore('metadata', () => { return entry; } + + let strippedBlob: Blob; + + try { + strippedBlob = await stripAllMetadata(file); + } catch (error) { + console.error('[metadata] strip failed', { + file: file.name, + type: file.type, + size: file.size, + error + }); + + const entry: MetadataPreviewEntry = { + status: 'error', + message: METADATA_STRIP_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + + const strippedFile = new File([strippedBlob], file.name, { + type: file.type || strippedBlob.type || 'application/octet-stream', + lastModified: file.lastModified + }); + + let afterStripOutput: string; + + try { + afterStripOutput = await readMetadata(strippedFile); + } catch (error) { + console.error('[metadata] read after strip failed', { file: file.name, error }); + + const entry: MetadataPreviewEntry = { + status: 'error', + message: METADATA_LOAD_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + + strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob }; + + const entry: MetadataPreviewEntry = { + status: 'ready', + message: METADATA_READY_MESSAGE, + output, + afterStripOutput + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; }; const loadPreviews = (files: readonly File[]): Promise => @@ -113,12 +178,15 @@ export const useMetadataStore = defineStore('metadata', () => { const clearAllPreviews = (): void => { previewsByKey.value = {}; + strippedBlobByKey.value = {}; }; return { previewsByKey, + strippedBlobByKey, makeKeyFromFile, getPreview, + getStrippedBlob, loadPreview, loadPreviews, clearAllPreviews diff --git a/frontend/src/types/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts index 35127c6..576afe2 100644 --- a/frontend/src/types/metadata/MetadataPreview.ts +++ b/frontend/src/types/metadata/MetadataPreview.ts @@ -2,5 +2,5 @@ export type MetadataPreviewEntry = | { status: 'idle'; message: string } | { status: 'pending'; message: string } | { status: 'loading'; message: string } - | { status: 'ready'; message: string; output: string } + | { status: 'ready'; message: string; output: string; afterStripOutput: string } | { status: 'error'; message: string }; diff --git a/frontend/src/types/metadata/MetadataTab.ts b/frontend/src/types/metadata/MetadataTab.ts new file mode 100644 index 0000000..959924b --- /dev/null +++ b/frontend/src/types/metadata/MetadataTab.ts @@ -0,0 +1 @@ +export type MetadataTab = 'original' | 'stripped'; diff --git a/frontend/src/types/workers/exiftoolWorkerInMessage.ts b/frontend/src/types/workers/exiftoolWorkerInMessage.ts index ef0a201..e8dfbec 100644 --- a/frontend/src/types/workers/exiftoolWorkerInMessage.ts +++ b/frontend/src/types/workers/exiftoolWorkerInMessage.ts @@ -1,3 +1,4 @@ export type ExiftoolWorkerInMessage = | { type: 'init' } - | { type: 'read-metadata'; id: string; file: File }; + | { type: 'read-metadata'; id: string; file: File } + | { type: 'strip-metadata'; id: string; file: File }; diff --git a/frontend/src/types/workers/exiftoolWorkerJob.ts b/frontend/src/types/workers/exiftoolWorkerJob.ts index 275f945..396bee2 100644 --- a/frontend/src/types/workers/exiftoolWorkerJob.ts +++ b/frontend/src/types/workers/exiftoolWorkerJob.ts @@ -1,3 +1,4 @@ export type ExiftoolWorkerJob = | { type: 'init' } - | { type: 'read-metadata'; id: string; file: File }; + | { type: 'read-metadata'; id: string; file: File } + | { type: 'strip-metadata'; id: string; file: File }; diff --git a/frontend/src/types/workers/exiftoolWorkerOutMessage.ts b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts index c1cae7e..330ea61 100644 --- a/frontend/src/types/workers/exiftoolWorkerOutMessage.ts +++ b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts @@ -2,4 +2,6 @@ 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 }; + | { type: 'read-metadata-error'; id: string; message: string } + | { type: 'strip-metadata-done'; id: string; data: ArrayBuffer } + | { type: 'strip-metadata-error'; id: string; message: string }; diff --git a/frontend/src/utils/toArrayBuffer.ts b/frontend/src/utils/toArrayBuffer.ts new file mode 100644 index 0000000..981f6d2 --- /dev/null +++ b/frontend/src/utils/toArrayBuffer.ts @@ -0,0 +1,15 @@ +export function toArrayBuffer(data: unknown): ArrayBuffer | null { + if (data instanceof ArrayBuffer) { + return data; + } + + if (data instanceof Uint8Array) { + const { buffer, byteOffset, byteLength } = data; + + if (buffer instanceof ArrayBuffer) { + return buffer.slice(byteOffset, byteOffset + byteLength); + } + } + + return null; +} diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts index 7b0990f..7b8e839 100644 --- a/frontend/src/workers/exiftoolWorker.ts +++ b/frontend/src/workers/exiftoolWorker.ts @@ -1,9 +1,10 @@ import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; -import { parseMetadata } from '@uswriting/exiftool'; +import { parseMetadata, writeMetadata } from '@uswriting/exiftool'; import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob'; import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; /** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */ const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) }; @@ -107,17 +108,87 @@ const processJob = async (job: ExiftoolWorkerJob): Promise => { const result = await parseMetadata(job.file, exiftoolParseOptions); if (!result.success) { - self.postMessage({ type: 'read-metadata-error', id: job.id, message: result.error }); + 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: resolveErrorMessage(error, 'Could not load image metadata') + 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 }); } } @@ -134,5 +205,11 @@ self.addEventListener('message', (event: MessageEvent) 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 }); } });