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.
This commit is contained in:
@@ -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)
|
||||
*
|
||||
|
||||
@@ -5,18 +5,55 @@
|
||||
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<div class="flex flex-1 flex-col gap-3 p-4">
|
||||
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
|
||||
|
||||
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
|
||||
{{ entry.message }}
|
||||
</p>
|
||||
|
||||
<pre
|
||||
v-else-if="entry.status === 'ready'"
|
||||
class="max-h-96 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
||||
>{{ entry.output }}</pre
|
||||
<template v-else-if="entry.status === 'ready'">
|
||||
<div
|
||||
class="inline-flex w-fit rounded-md border border-border p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Metadata view"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
:class="
|
||||
activeTab === 'original'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
:aria-selected="activeTab === 'original'"
|
||||
@click="activeTab = 'original'"
|
||||
>
|
||||
Original
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
:class="
|
||||
activeTab === 'stripped'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
:aria-selected="activeTab === 'stripped'"
|
||||
@click="activeTab = 'stripped'"
|
||||
>
|
||||
Stripped
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
role="tabpanel"
|
||||
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
||||
>{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}</pre
|
||||
>
|
||||
</template>
|
||||
|
||||
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
||||
</div>
|
||||
@@ -24,17 +61,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { ref, watch, type PropType } from 'vue';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import type { MetadataTab } from '@/types/metadata/MetadataTab';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
fileName: {
|
||||
type: String as PropType<string | null>,
|
||||
required: true
|
||||
default: null
|
||||
},
|
||||
entry: {
|
||||
type: Object as PropType<MetadataPreviewEntry>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const activeTab = ref<MetadataTab>('original');
|
||||
|
||||
watch(
|
||||
() => props.fileName,
|
||||
() => {
|
||||
activeTab.value = 'original';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -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<void> => {
|
||||
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<Blob> => {
|
||||
await initializeExiftool();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const workerInstance = worker.value;
|
||||
|
||||
if (!workerInstance) {
|
||||
throw new Error('ExifTool worker is unavailable');
|
||||
}
|
||||
|
||||
const data = await new Promise<ArrayBuffer>((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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<Record<string, MetadataPreviewEntry>>({});
|
||||
const strippedBlobByKey = ref<Record<string, Blob>>({});
|
||||
|
||||
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<MetadataPreviewEntry> => {
|
||||
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<MetadataPreviewEntry[]> =>
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type MetadataTab = 'original' | 'stripped';
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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<ExiftoolWorkerInMessage>)
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user