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 }}
+
+
+
+
+
+
+ {{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
+
{{ 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 });
}
});