From e89f76a673e20286625096b0a802ac3178bf6fed Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 13 Oct 2026 21:07:04 +0200 Subject: [PATCH 1/9] Pin @uswriting/exiftool and patch exit-0 stderr guard on install. Postinstall removes upstream guards that mark parseMetadata failed when stderr is non-empty after success; Docker dev image copies patches before npm ci. --- frontend/Dockerfile.dev | 1 + frontend/package-lock.json | 34 ++++++++++++ frontend/package.json | 4 +- frontend/patches/README.md | 16 ++++++ frontend/patches/patch-exiftool-stderr.mjs | 61 ++++++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 frontend/patches/README.md create mode 100644 frontend/patches/patch-exiftool-stderr.mjs diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 553bfe0..92b8c9e 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -3,6 +3,7 @@ FROM node:22-alpine WORKDIR /frontend COPY package*.json . +COPY patches ./patches RUN npm ci diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b4d43e..39e1685 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,8 +7,10 @@ "": { "name": "frontend", "version": "0.0.0", + "hasInstallScript": true, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", "pinia": "^4.0.3", "tailwindcss": "^4.3.3", @@ -24,6 +26,12 @@ "vue-tsc": "^3.2.6" } }, + "node_modules/@6over3/zeroperl-ts": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@6over3/zeroperl-ts/-/zeroperl-ts-1.0.10.tgz", + "integrity": "sha512-d7fBgN1UaP9BR6kZMyNsjIrxPd0QLqqx29VSprhK66m1csm/7E/anr+rEfp3gJDSne5FfDpM6aKtDGHyXG/vYQ==", + "license": "Apache-2.0" + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -886,6 +894,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@uswriting/exiftool": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@uswriting/exiftool/-/exiftool-1.0.9.tgz", + "integrity": "sha512-tziE5oH/Wf4gJ/g/ZVGBxCjnoHz27SxXRZ4KoFa4c0qN/6uBfvBFEQ272dq7LxbzhXkvGqHdmDyNJe/WUZCV5A==", + "license": "Apache-2.0", + "dependencies": { + "@6over3/zeroperl-ts": "1.0.10" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", @@ -2156,6 +2173,23 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 7cc412d..66aa4f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,10 +6,12 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "postinstall": "node patches/patch-exiftool-stderr.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", "pinia": "^4.0.3", "tailwindcss": "^4.3.3", diff --git a/frontend/patches/README.md b/frontend/patches/README.md new file mode 100644 index 0000000..dee9429 --- /dev/null +++ b/frontend/patches/README.md @@ -0,0 +1,16 @@ +# Dependency patches + +## `@uswriting/exiftool@1.0.9` — stderr vs warnings + +We do **not** use a `patch-package` diff here: the published `dist/*.js` files are one minified line each, so unified diffs are huge and hard to review. + +Instead, `patches/patch-exiftool-stderr.mjs` runs on `postinstall` and removes two **documented** guard strings: + +| Guard | Role | +| ----- | ---- | +| `READ_STDERR_GUARD` | After exit 0 on **read**, fail if stderr is non-empty | +| `WRITE_STDERR_GUARD` | After exit 0 on **write**, fail if stderr is non-empty | + +See the script for exact literals and upstream context. + +When upgrading `@uswriting/exiftool`, run `npm install` and confirm the script still finds both guards (or update the script for the new bundle). diff --git a/frontend/patches/patch-exiftool-stderr.mjs b/frontend/patches/patch-exiftool-stderr.mjs new file mode 100644 index 0000000..b105307 --- /dev/null +++ b/frontend/patches/patch-exiftool-stderr.mjs @@ -0,0 +1,61 @@ +/** + * Patch @uswriting/exiftool@1.0.9 after install. + * + * Problem: parseMetadata / writeMetadata fail when ExifTool prints warnings to stderr + * 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) + * + * Re-verify when bumping @uswriting/exiftool: search dist for these exact snippets. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const READ_STDERR_GUARD = + 'if(o&&o.trim())return{success:!1,data:void 0,error:o,exitCode:0};'; + +const WRITE_STDERR_GUARD = + 'if(d&&d.trim())return{success:!1,data:void 0,error:d,exitCode:0};'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../node_modules/@uswriting/exiftool/dist' +); + +const targets = ['esm/index.js', 'cjs/index.cjs']; + +const patchFile = (relativePath) => { + const filePath = path.join(packageRoot, relativePath); + + if (!fs.existsSync(filePath)) { + throw new Error(`patch-exiftool-stderr: missing ${filePath} (is @uswriting/exiftool installed?)`); + } + + const original = fs.readFileSync(filePath, 'utf8'); + const alreadyPatched = + !original.includes(READ_STDERR_GUARD) && !original.includes(WRITE_STDERR_GUARD); + + if (alreadyPatched) { + return; + } + + if (!original.includes(READ_STDERR_GUARD) || !original.includes(WRITE_STDERR_GUARD)) { + throw new Error( + `patch-exiftool-stderr: ${relativePath} does not match @uswriting/exiftool@1.0.9; update or remove this script` + ); + } + + const patched = original + .replaceAll(READ_STDERR_GUARD, '') + .replaceAll(WRITE_STDERR_GUARD, ''); + + fs.writeFileSync(filePath, patched); +}; + +for (const relativePath of targets) { + patchFile(relativePath); +} -- 2.54.0 From 321c67fa3d9b90dcc5f7835d1305f09639fa5d24 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 13 Oct 2026 22:18:48 +0200 Subject: [PATCH 2/9] Add metadata Pinia store for ExifTool queue preview reads. Introduce preview entry types, resolveErrorMessage, and nullcart-style resolveAxiosErrorMessage for upcoming upload API errors. --- frontend/src/stores/metadata.ts | 83 +++++++++++++++++++ .../src/types/metadata/MetadataPreview.ts | 5 ++ .../utils/http/resolveAxiosErrorMessage.ts | 53 ++++++++---- frontend/src/utils/resolveErrorMessage.ts | 7 ++ 4 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 frontend/src/stores/metadata.ts create mode 100644 frontend/src/types/metadata/MetadataPreview.ts create mode 100644 frontend/src/utils/resolveErrorMessage.ts diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts new file mode 100644 index 0000000..d9dfbed --- /dev/null +++ b/frontend/src/stores/metadata.ts @@ -0,0 +1,83 @@ +import { parseMetadata } from '@uswriting/exiftool'; +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; +import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; + +export const useMetadataStore = defineStore('metadata', () => { + const previewsByKey = ref>({}); + + const readExifToolStdout = async (file: File): Promise => { + const result = await parseMetadata(file); + + if (!result.success) { + throw new Error(result.error); + } + + return result.data; + }; + + const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; + + const getPreview = (file: File): MetadataPreviewEntry => { + const key = makeKeyFromFile(file); + + return previewsByKey.value[key] ?? { status: 'idle' }; + }; + + const loadPreview = async (file: File): Promise => { + const key = makeKeyFromFile(file); + + previewsByKey.value = { ...previewsByKey.value, [key]: { status: 'loading' } }; + + try { + const output = await readExifToolStdout(file); + + const entry: MetadataPreviewEntry = { status: 'ready', output }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } catch (error) { + const entry: MetadataPreviewEntry = { + status: 'error', + message: resolveErrorMessage(error, 'Could not read image metadata') + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + }; + + const loadPreviews = async (files: readonly File[]): Promise => { + await Promise.all(files.map(file => loadPreview(file))); + }; + + const clearPreview = (file: File): void => { + const key = makeKeyFromFile(file); + + if (!(key in previewsByKey.value)) { + return; + } + + const next = { ...previewsByKey.value }; + + delete next[key]; + previewsByKey.value = next; + }; + + const clearAllPreviews = (): void => { + previewsByKey.value = {}; + }; + + return { + previewsByKey, + makeKeyFromFile, + getPreview, + loadPreview, + loadPreviews, + clearPreview, + clearAllPreviews + }; +}); diff --git a/frontend/src/types/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts new file mode 100644 index 0000000..9462b8b --- /dev/null +++ b/frontend/src/types/metadata/MetadataPreview.ts @@ -0,0 +1,5 @@ +export type MetadataPreviewEntry = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; output: string } + | { status: 'error'; message: string }; diff --git a/frontend/src/utils/http/resolveAxiosErrorMessage.ts b/frontend/src/utils/http/resolveAxiosErrorMessage.ts index 6747a60..86ddee7 100644 --- a/frontend/src/utils/http/resolveAxiosErrorMessage.ts +++ b/frontend/src/utils/http/resolveAxiosErrorMessage.ts @@ -1,23 +1,40 @@ -import axios from 'axios'; +import { isAxiosError } from 'axios'; +import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; -export function resolveAxiosErrorMessage(error: unknown, fallback = 'Request failed'): string { - if (axios.isAxiosError(error)) { - const data = error.response?.data; - if (typeof data === 'object' && data !== null && 'message' in data) { - const message = (data as { message: unknown }).message; - if (typeof message === 'string') { - return message; - } - if (Array.isArray(message)) { - return message.map(String).join(', '); - } +const getAxiosErrorResponseMessage = (data: unknown): string | null => { + if (typeof data === 'string') { + return data; + } + + if (typeof data !== 'object' || data === null || !('message' in data)) { + return null; + } + + const { message } = data; + + if (typeof message === 'string') { + return message; + } + + if (Array.isArray(message)) { + const texts = message.filter((item): item is string => typeof item === 'string'); + + if (texts.length === 1) { + return texts[0]; } - if (error.message) { - return error.message; + + if (texts.length > 1) { + return texts.join(', '); } } - if (error instanceof Error) { - return error.message; + + return null; +}; + +export const resolveAxiosErrorMessage = (error: unknown, fallback: string): string => { + if (!isAxiosError(error) || !error.response) { + return resolveErrorMessage(error, fallback); } - return fallback; -} + + return getAxiosErrorResponseMessage(error.response.data) ?? fallback; +}; diff --git a/frontend/src/utils/resolveErrorMessage.ts b/frontend/src/utils/resolveErrorMessage.ts new file mode 100644 index 0000000..60a375f --- /dev/null +++ b/frontend/src/utils/resolveErrorMessage.ts @@ -0,0 +1,7 @@ +export function resolveErrorMessage(error: unknown, fallback = 'Something went wrong'): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return fallback; +} -- 2.54.0 From b47c5c76969549b526b73a7875f356a5685d712a Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Thu, 15 Oct 2026 17:18:21 +0200 Subject: [PATCH 3/9] Add ExifTool worker and Pinia store for off-thread metadata reads. Warm up WASM in a dedicated worker with a tiny JPEG probe and expose init/read RPC to the main thread. --- frontend/src/consts/exiftoolWorkerInitJpeg.ts | 6 + frontend/src/stores/exiftool.ts | 126 ++++++++++++++++++ .../src/types/metadata/ExiftoolInitStatus.ts | 1 + .../types/workers/exiftoolWorkerInMessage.ts | 3 + .../types/workers/exiftoolWorkerOutMessage.ts | 5 + frontend/src/utils/base64ToBytes.ts | 10 ++ frontend/src/workers/exiftoolWorker.ts | 89 +++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 frontend/src/consts/exiftoolWorkerInitJpeg.ts create mode 100644 frontend/src/stores/exiftool.ts create mode 100644 frontend/src/types/metadata/ExiftoolInitStatus.ts create mode 100644 frontend/src/types/workers/exiftoolWorkerInMessage.ts create mode 100644 frontend/src/types/workers/exiftoolWorkerOutMessage.ts create mode 100644 frontend/src/utils/base64ToBytes.ts create mode 100644 frontend/src/workers/exiftoolWorker.ts diff --git a/frontend/src/consts/exiftoolWorkerInitJpeg.ts b/frontend/src/consts/exiftoolWorkerInitJpeg.ts new file mode 100644 index 0000000..b2d0d60 --- /dev/null +++ b/frontend/src/consts/exiftoolWorkerInitJpeg.ts @@ -0,0 +1,6 @@ +import { base64ToBytes } from '@/utils/base64ToBytes'; + +const EXIFTOOL_WORKER_INIT_JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z'; + +export const EXIFTOOL_WORKER_INIT_JPEG = base64ToBytes(EXIFTOOL_WORKER_INIT_JPEG_BASE64); diff --git a/frontend/src/stores/exiftool.ts b/frontend/src/stores/exiftool.ts new file mode 100644 index 0000000..c4d0b04 --- /dev/null +++ b/frontend/src/stores/exiftool.ts @@ -0,0 +1,126 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type { ExiftoolInitStatus } from '@/types/metadata/ExiftoolInitStatus'; +import type { ExiftoolWorkerOutMessage } from '@/types/workers/exiftoolWorkerOutMessage'; + +export const useExiftoolStore = defineStore('exiftool', () => { + const initStatus = ref('idle'); + const initErrorMessage = ref(null); + + const worker = ref(null); + + let initPromise: Promise | null = null; + let initPromiseResolve: (() => void) | null = null; + let initPromiseReject: ((error: Error) => void) | null = null; + + const readMetadataWaiters = new Map< + string, + { resolve: (output: string) => void; reject: (error: Error) => void } + >(); + + const initializeExiftool = async (): Promise => { + if (initStatus.value === 'ready') { + return; + } + + if (initStatus.value === 'error') { + throw new Error(initErrorMessage.value ?? 'ExifTool is unavailable'); + } + + if (initStatus.value === 'idle') { + initStatus.value = 'loading'; + initErrorMessage.value = null; + initPromise = new Promise((resolve, reject) => { + initPromiseResolve = resolve; + initPromiseReject = reject; + }); + + if (!worker.value) { + worker.value = new Worker(new URL('../workers/exiftoolWorker.ts', import.meta.url), { + type: 'module' + }); + + worker.value.addEventListener('message', handleWorkerMessage); + } + + worker.value.postMessage({ type: 'init' }); + } + + if (!initPromise) { + throw new Error('ExifTool is not initializing'); + } + + await initPromise; + }; + + const handleWorkerMessage = (event: MessageEvent): void => { + const message = event.data; + + if (message.type === 'init-done') { + initStatus.value = 'ready'; + initPromiseResolve?.(); + initPromiseResolve = null; + initPromiseReject = null; + + return; + } + + if (message.type === 'init-error') { + initStatus.value = 'error'; + initErrorMessage.value = message.message; + initPromiseReject?.(new Error(message.message)); + initPromiseResolve = null; + initPromiseReject = null; + + return; + } + + if (message.type === 'read-metadata-done') { + const waiter = readMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + readMetadataWaiters.delete(message.id); + waiter.resolve(message.output); + + return; + } + + if (message.type === 'read-metadata-error') { + const waiter = readMetadataWaiters.get(message.id); + + if (!waiter) { + return; + } + + readMetadataWaiters.delete(message.id); + waiter.reject(new Error(message.message)); + } + }; + + const readMetadata = async (file: File): Promise => { + await initializeExiftool(); + + const id = crypto.randomUUID(); + + const workerInstance = worker.value; + + if (!workerInstance) { + throw new Error('ExifTool worker is unavailable'); + } + + return new Promise((resolve, reject) => { + readMetadataWaiters.set(id, { resolve, reject }); + workerInstance.postMessage({ type: 'read-metadata', id, file }); + }); + }; + + return { + initStatus, + initErrorMessage, + initializeExiftool, + readMetadata + }; +}); diff --git a/frontend/src/types/metadata/ExiftoolInitStatus.ts b/frontend/src/types/metadata/ExiftoolInitStatus.ts new file mode 100644 index 0000000..f5c4ff1 --- /dev/null +++ b/frontend/src/types/metadata/ExiftoolInitStatus.ts @@ -0,0 +1 @@ +export type ExiftoolInitStatus = 'idle' | 'loading' | 'ready' | 'error'; diff --git a/frontend/src/types/workers/exiftoolWorkerInMessage.ts b/frontend/src/types/workers/exiftoolWorkerInMessage.ts new file mode 100644 index 0000000..ef0a201 --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerInMessage.ts @@ -0,0 +1,3 @@ +export type ExiftoolWorkerInMessage = + | { type: 'init' } + | { type: 'read-metadata'; id: string; file: File }; diff --git a/frontend/src/types/workers/exiftoolWorkerOutMessage.ts b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts new file mode 100644 index 0000000..c1cae7e --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts @@ -0,0 +1,5 @@ +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 }; diff --git a/frontend/src/utils/base64ToBytes.ts b/frontend/src/utils/base64ToBytes.ts new file mode 100644 index 0000000..961118a --- /dev/null +++ b/frontend/src/utils/base64ToBytes.ts @@ -0,0 +1,10 @@ +export function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts new file mode 100644 index 0000000..ee42806 --- /dev/null +++ b/frontend/src/workers/exiftoolWorker.ts @@ -0,0 +1,89 @@ +import { parseMetadata } from '@uswriting/exiftool'; +import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; +import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; +import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; + +let initialized = false; +let initializeWorkerPromise: Promise | 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}. + * + * Concurrent callers (init message, read-metadata, future write) share one in-flight warmup. + * + * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). + * @see Main thread `initialize()` posts `{ type: 'init' }` when idle. + */ +const initializeWorker = async (): Promise => { + if (initialized) { + return; + } + + if (!initializeWorkerPromise) { + initializeWorkerPromise = (async () => { + try { + const result = await parseMetadata({ name: 'init.jpg', data: EXIFTOOL_WORKER_INIT_JPEG }); + + if (!result.success) { + throw new Error(result.error); + } + + initialized = true; + } catch (error) { + initializeWorkerPromise = null; + + throw error; + } + })(); + } + + return initializeWorkerPromise; +}; + +self.addEventListener('message', async (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'init') { + try { + await initializeWorker(); + + self.postMessage({ type: 'init-done' }); + } catch (error) { + self.postMessage({ + type: 'init-error', + message: resolveErrorMessage(error, 'Failed to prepare ExifTool') + }); + } + + return; + } + + if (message.type === 'read-metadata') { + try { + await initializeWorker(); + + const result = await parseMetadata(message.file); + + if (!result.success) { + self.postMessage({ type: 'read-metadata-error', id: message.id, message: result.error }); + + return; + } + + self.postMessage({ type: 'read-metadata-done', id: message.id, output: result.data }); + } catch (error) { + self.postMessage({ + type: 'read-metadata-error', + id: message.id, + message: resolveErrorMessage(error, 'Could not read image metadata') + }); + } + } +}); -- 2.54.0 From 42f9a3eafc9ffb5fa328a402655388fe7ea5556e Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Thu, 15 Oct 2026 17:18:25 +0200 Subject: [PATCH 4/9] Wire metadata previews through ExifTool store with friendly status messages. Prefetch ExifTool on upload mount and track pending/loading/ready per file for batch preview loads. --- frontend/src/stores/metadata.ts | 73 ++++++++++++++----- .../src/types/metadata/MetadataPreview.ts | 3 +- frontend/src/views/UploadView.vue | 13 ++++ 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts index d9dfbed..2ca494e 100644 --- a/frontend/src/stores/metadata.ts +++ b/frontend/src/stores/metadata.ts @@ -1,21 +1,20 @@ -import { parseMetadata } from '@uswriting/exiftool'; -import { defineStore } from 'pinia'; +import { defineStore, storeToRefs } from 'pinia'; import { ref } from 'vue'; +import { useExiftoolStore } from '@/stores/exiftool'; import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; -import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; + +const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool'; +const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool'; +const METADATA_READING_MESSAGE = 'Reading metadata'; +const METADATA_READ_ERROR_MESSAGE = 'Could not read image metadata'; export const useMetadataStore = defineStore('metadata', () => { const previewsByKey = ref>({}); - const readExifToolStdout = async (file: File): Promise => { - const result = await parseMetadata(file); + const exiftoolStore = useExiftoolStore(); - if (!result.success) { - throw new Error(result.error); - } - - return result.data; - }; + const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore); + const { initializeExiftool, readMetadata } = exiftoolStore; const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; @@ -28,20 +27,59 @@ export const useMetadataStore = defineStore('metadata', () => { const loadPreview = async (file: File): Promise => { const key = makeKeyFromFile(file); - previewsByKey.value = { ...previewsByKey.value, [key]: { status: 'loading' } }; + if (exiftoolInitStatus.value === 'error') { + const entry: MetadataPreviewEntry = { + status: 'error', + message: EXIFTOOL_PREPARE_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + + if (exiftoolInitStatus.value === 'ready') { + previewsByKey.value = { + ...previewsByKey.value, + [key]: { status: 'loading', message: METADATA_READING_MESSAGE } + }; + } else { + previewsByKey.value = { + ...previewsByKey.value, + [key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE } + }; + + try { + await initializeExiftool(); + + previewsByKey.value = { + ...previewsByKey.value, + [key]: { status: 'loading', message: METADATA_READING_MESSAGE } + }; + } catch { + const entry: MetadataPreviewEntry = { + status: 'error', + message: EXIFTOOL_PREPARE_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + } try { - const output = await readExifToolStdout(file); + const output = await readMetadata(file); const entry: MetadataPreviewEntry = { status: 'ready', output }; previewsByKey.value = { ...previewsByKey.value, [key]: entry }; return entry; - } catch (error) { + } catch { const entry: MetadataPreviewEntry = { status: 'error', - message: resolveErrorMessage(error, 'Could not read image metadata') + message: METADATA_READ_ERROR_MESSAGE }; previewsByKey.value = { ...previewsByKey.value, [key]: entry }; @@ -50,9 +88,8 @@ export const useMetadataStore = defineStore('metadata', () => { } }; - const loadPreviews = async (files: readonly File[]): Promise => { - await Promise.all(files.map(file => loadPreview(file))); - }; + const loadPreviews = (files: readonly File[]): Promise => + Promise.all(files.map(file => loadPreview(file))); const clearPreview = (file: File): void => { const key = makeKeyFromFile(file); diff --git a/frontend/src/types/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts index 9462b8b..d517470 100644 --- a/frontend/src/types/metadata/MetadataPreview.ts +++ b/frontend/src/types/metadata/MetadataPreview.ts @@ -1,5 +1,6 @@ export type MetadataPreviewEntry = | { status: 'idle' } - | { status: 'loading' } + | { status: 'pending'; message: string } + | { status: 'loading'; message: string } | { status: 'ready'; output: string } | { status: 'error'; message: string }; diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue index 1d82b92..a613591 100644 --- a/frontend/src/views/UploadView.vue +++ b/frontend/src/views/UploadView.vue @@ -4,3 +4,16 @@

Upload UI coming soon.

+ + -- 2.54.0 From e10341d44ba9771b8431bb582c15597b55f0c0b0 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Thu, 15 Oct 2026 18:57:43 +0200 Subject: [PATCH 5/9] Patch zeroperl-ts so worker WASM load honors custom fetch. Postinstall updates @6over3/zeroperl-ts and wires the worker to load zeroperl.wasm via Vite ?url. --- frontend/package.json | 2 +- frontend/patches/README.md | 13 ++++ .../patches/patch-zeroperl-worker-fetch.mjs | 60 +++++++++++++++++++ frontend/src/workers/exiftoolWorker.ts | 13 +++- 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 frontend/patches/patch-zeroperl-worker-fetch.mjs diff --git a/frontend/package.json b/frontend/package.json index 66aa4f9..297625e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", "preview": "vite preview", - "postinstall": "node patches/patch-exiftool-stderr.mjs" + "postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/frontend/patches/README.md b/frontend/patches/README.md index dee9429..1965824 100644 --- a/frontend/patches/README.md +++ b/frontend/patches/README.md @@ -14,3 +14,16 @@ Instead, `patches/patch-exiftool-stderr.mjs` runs on `postinstall` and removes t See the script for exact literals and upstream context. When upgrading `@uswriting/exiftool`, run `npm install` and confirm the script still finds both guards (or update the script for the new bundle). + +## `@6over3/zeroperl-ts@1.0.10` — WASM load in module workers + +`patches/patch-zeroperl-worker-fetch.mjs` changes the WASM loader so a custom `fetch` (from `@uswriting/exiftool` options) is honored even when `document` is missing (Web Workers). + +| Snippet | Role | +| ----- | ---- | +| `if(Ee())a=await...` → `if(Ee()\|\|e)a=await...` (ESM) | Use fetch path when `options.fetch` is set | +| `if(pe())a=await...` → `if(pe()\|\|e)a=await...` (CJS) | Same for CJS bundle | + +Workers still pass Vite’s WASM URL via `zeroperl.wasm?url` and `exiftoolParseOptions.fetch` in app code. + +When upgrading `@6over3/zeroperl-ts`, re-run `npm install` and confirm the script still matches (or update literals). diff --git a/frontend/patches/patch-zeroperl-worker-fetch.mjs b/frontend/patches/patch-zeroperl-worker-fetch.mjs new file mode 100644 index 0000000..9a2dc72 --- /dev/null +++ b/frontend/patches/patch-zeroperl-worker-fetch.mjs @@ -0,0 +1,60 @@ +/** + * Patch @6over3/zeroperl-ts@1.0.10 after install. + * + * Problem: WASM loader treats only `window` + `document` as browser. Module workers have + * neither, so it dynamic-imports `node:fs/promises` and fails under Vite. + * Custom `fetch` from @uswriting/exiftool is ignored on that branch. + * + * Fix: use the fetch path when a custom fetch is provided (`Ee() || e`). + * + * Upstream: https://github.com/6over3/zeroperl-ts + * + * Re-verify when bumping @6over3/zeroperl-ts: search dist for the ORIGINAL snippets below. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const patches = [ + { + relativePath: 'esm/index.js', + original: 'if(Ee())a=await(await(e??fetch)(q)).arrayBuffer();', + patched: 'if(Ee()||e)a=await(await(e??fetch)(q)).arrayBuffer();' + }, + { + relativePath: 'cjs/index.cjs', + original: 'if(pe())a=await(await(e??fetch)(w)).arrayBuffer();', + patched: 'if(pe()||e)a=await(await(e??fetch)(w)).arrayBuffer();' + } +]; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../node_modules/@6over3/zeroperl-ts/dist' +); + +const patchFile = ({ relativePath, original, patched }) => { + const filePath = path.join(packageRoot, relativePath); + + if (!fs.existsSync(filePath)) { + throw new Error(`patch-zeroperl-worker-fetch: missing ${filePath} (is @6over3/zeroperl-ts installed?)`); + } + + const content = fs.readFileSync(filePath, 'utf8'); + + if (content.includes(patched)) { + return; + } + + if (!content.includes(original)) { + throw new Error( + `patch-zeroperl-worker-fetch: ${relativePath} does not match @6over3/zeroperl-ts@1.0.10; update or remove this script` + ); + } + + fs.writeFileSync(filePath, content.replaceAll(original, patched)); +}; + +for (const entry of patches) { + patchFile(entry); +} diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts index ee42806..603a4e3 100644 --- a/frontend/src/workers/exiftoolWorker.ts +++ b/frontend/src/workers/exiftoolWorker.ts @@ -1,8 +1,12 @@ +import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; import { parseMetadata } from '@uswriting/exiftool'; import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; +/** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */ +const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) }; + let initialized = false; let initializeWorkerPromise: Promise | null = null; @@ -19,7 +23,7 @@ let initializeWorkerPromise: Promise | null = null; * Concurrent callers (init message, read-metadata, future write) share one in-flight warmup. * * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). - * @see Main thread `initialize()` posts `{ type: 'init' }` when idle. + * @see Main thread `initializeExiftool()` posts `{ type: 'init' }` when idle. */ const initializeWorker = async (): Promise => { if (initialized) { @@ -29,7 +33,10 @@ const initializeWorker = async (): Promise => { if (!initializeWorkerPromise) { initializeWorkerPromise = (async () => { try { - const result = await parseMetadata({ name: 'init.jpg', data: EXIFTOOL_WORKER_INIT_JPEG }); + const result = await parseMetadata( + { name: 'init.jpg', data: EXIFTOOL_WORKER_INIT_JPEG }, + exiftoolParseOptions + ); if (!result.success) { throw new Error(result.error); @@ -69,7 +76,7 @@ self.addEventListener('message', async (event: MessageEvent Date: Thu, 15 Oct 2026 20:43:22 +0200 Subject: [PATCH 6/9] Serialize ExifTool work in the worker with a FIFO job queue. Overlapping parseMetadata calls on one WASM instance caused wrong metadata when multiple files were read in parallel; callers can still postMessage concurrently. --- .../src/types/workers/exiftoolWorkerJob.ts | 3 + frontend/src/workers/exiftoolWorker.ts | 62 ++++++++++++++++--- 2 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 frontend/src/types/workers/exiftoolWorkerJob.ts diff --git a/frontend/src/types/workers/exiftoolWorkerJob.ts b/frontend/src/types/workers/exiftoolWorkerJob.ts new file mode 100644 index 0000000..275f945 --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerJob.ts @@ -0,0 +1,3 @@ +export type ExiftoolWorkerJob = + | { type: 'init' } + | { type: 'read-metadata'; id: string; file: File }; diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts index 603a4e3..a383a04 100644 --- a/frontend/src/workers/exiftoolWorker.ts +++ b/frontend/src/workers/exiftoolWorker.ts @@ -2,11 +2,15 @@ import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; import { parseMetadata } 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'; /** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */ const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) }; +const jobQueue: ExiftoolWorkerJob[] = []; +let drainPromise: Promise | null = null; + let initialized = false; let initializeWorkerPromise: Promise | null = null; @@ -20,8 +24,6 @@ let initializeWorkerPromise: Promise | null = null; * 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}. * - * Concurrent callers (init message, read-metadata, future write) share one in-flight warmup. - * * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). * @see Main thread `initializeExiftool()` posts `{ type: 'init' }` when idle. */ @@ -54,10 +56,36 @@ const initializeWorker = async (): Promise => { return initializeWorkerPromise; }; -self.addEventListener('message', async (event: MessageEvent) => { - const message = event.data; +const enqueue = (job: ExiftoolWorkerJob): void => { + jobQueue.push(job); - if (message.type === 'init') { + if (!drainPromise) { + drainPromise = (async () => { + try { + await drain(); + } catch { + console.error('Failed to drain ExifTool worker'); + } + + drainPromise = null; + })(); + } +}; + +const drain = async (): Promise => { + while (jobQueue.length > 0) { + const job = jobQueue.shift(); + + if (!job) { + return; + } + + await processJob(job); + } +}; + +const processJob = async (job: ExiftoolWorkerJob): Promise => { + if (job.type === 'init') { try { await initializeWorker(); @@ -72,25 +100,39 @@ self.addEventListener('message', async (event: MessageEvent) => { + 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 }); + } }); -- 2.54.0 From 18597f4127f6c2a1a5a2ef73607fe91cffa30bf8 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sat, 17 Oct 2026 21:39:59 +0200 Subject: [PATCH 7/9] Add upload queue UI with ExifTool metadata preview on pick. Users can select images within configured limits, see per-row load status, and inspect raw ExifTool output before upload work continues. --- frontend/package-lock.json | 13 ++ frontend/package.json | 1 + .../components/upload/MetadataInspector.vue | 40 ++++ frontend/src/stores/metadata.ts | 50 +++-- .../src/types/metadata/MetadataPreview.ts | 4 +- .../src/types/upload/PickedUploadAnalysis.ts | 6 + frontend/src/views/UploadView.vue | 188 +++++++++++++++++- frontend/src/workers/exiftoolWorker.ts | 4 +- 8 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/upload/MetadataInspector.vue create mode 100644 frontend/src/types/upload/PickedUploadAnalysis.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39e1685..2075cd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", "pinia": "^4.0.3", + "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", "vue": "^3.5.32", "vue-router": "^4.6.4" @@ -1924,6 +1925,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-bytes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.2.0.tgz", + "integrity": "sha512-T2kroepGB5DRVbkN/nOpW03+VRWqdtJhZNuCW3wYk3chdPjgi4GcTBOaxWfHq1MIM0ypidxsSWbQaAiRpxnXmg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 297625e..246539e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", "pinia": "^4.0.3", + "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", "vue": "^3.5.32", "vue-router": "^4.6.4" diff --git a/frontend/src/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue new file mode 100644 index 0000000..b23e788 --- /dev/null +++ b/frontend/src/components/upload/MetadataInspector.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts index 2ca494e..e8bbb8f 100644 --- a/frontend/src/stores/metadata.ts +++ b/frontend/src/stores/metadata.ts @@ -5,8 +5,20 @@ import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool'; const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool'; -const METADATA_READING_MESSAGE = 'Reading metadata'; -const METADATA_READ_ERROR_MESSAGE = 'Could not read image metadata'; +const METADATA_LOADING_MESSAGE = 'Loading metadata'; +const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata'; +const METADATA_IDLE_MESSAGE = 'Waiting to load metadata'; +const METADATA_READY_MESSAGE = 'Metadata loaded'; + +const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = { + status: 'idle', + message: METADATA_IDLE_MESSAGE +}; + +const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = { + status: 'loading', + message: METADATA_LOADING_MESSAGE +}; export const useMetadataStore = defineStore('metadata', () => { const previewsByKey = ref>({}); @@ -18,10 +30,14 @@ export const useMetadataStore = defineStore('metadata', () => { const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; - const getPreview = (file: File): MetadataPreviewEntry => { + const getPreview = (file?: File | null): MetadataPreviewEntry => { + if (!file) { + return METADATA_IDLE_PREVIEW; + } + const key = makeKeyFromFile(file); - return previewsByKey.value[key] ?? { status: 'idle' }; + return previewsByKey.value[key] ?? METADATA_IDLE_PREVIEW; }; const loadPreview = async (file: File): Promise => { @@ -41,7 +57,7 @@ export const useMetadataStore = defineStore('metadata', () => { if (exiftoolInitStatus.value === 'ready') { previewsByKey.value = { ...previewsByKey.value, - [key]: { status: 'loading', message: METADATA_READING_MESSAGE } + [key]: METADATA_LOADING_PREVIEW }; } else { previewsByKey.value = { @@ -54,7 +70,7 @@ export const useMetadataStore = defineStore('metadata', () => { previewsByKey.value = { ...previewsByKey.value, - [key]: { status: 'loading', message: METADATA_READING_MESSAGE } + [key]: METADATA_LOADING_PREVIEW }; } catch { const entry: MetadataPreviewEntry = { @@ -71,7 +87,11 @@ export const useMetadataStore = defineStore('metadata', () => { try { const output = await readMetadata(file); - const entry: MetadataPreviewEntry = { status: 'ready', output }; + const entry: MetadataPreviewEntry = { + status: 'ready', + message: METADATA_READY_MESSAGE, + output + }; previewsByKey.value = { ...previewsByKey.value, [key]: entry }; @@ -79,7 +99,7 @@ export const useMetadataStore = defineStore('metadata', () => { } catch { const entry: MetadataPreviewEntry = { status: 'error', - message: METADATA_READ_ERROR_MESSAGE + message: METADATA_LOAD_ERROR_MESSAGE }; previewsByKey.value = { ...previewsByKey.value, [key]: entry }; @@ -91,19 +111,6 @@ export const useMetadataStore = defineStore('metadata', () => { const loadPreviews = (files: readonly File[]): Promise => Promise.all(files.map(file => loadPreview(file))); - const clearPreview = (file: File): void => { - const key = makeKeyFromFile(file); - - if (!(key in previewsByKey.value)) { - return; - } - - const next = { ...previewsByKey.value }; - - delete next[key]; - previewsByKey.value = next; - }; - const clearAllPreviews = (): void => { previewsByKey.value = {}; }; @@ -114,7 +121,6 @@ export const useMetadataStore = defineStore('metadata', () => { getPreview, loadPreview, loadPreviews, - clearPreview, clearAllPreviews }; }); diff --git a/frontend/src/types/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts index d517470..35127c6 100644 --- a/frontend/src/types/metadata/MetadataPreview.ts +++ b/frontend/src/types/metadata/MetadataPreview.ts @@ -1,6 +1,6 @@ export type MetadataPreviewEntry = - | { status: 'idle' } + | { status: 'idle'; message: string } | { status: 'pending'; message: string } | { status: 'loading'; message: string } - | { status: 'ready'; output: string } + | { status: 'ready'; message: string; output: string } | { status: 'error'; message: string }; diff --git a/frontend/src/types/upload/PickedUploadAnalysis.ts b/frontend/src/types/upload/PickedUploadAnalysis.ts new file mode 100644 index 0000000..ad4539e --- /dev/null +++ b/frontend/src/types/upload/PickedUploadAnalysis.ts @@ -0,0 +1,6 @@ +export type PickedUploadAnalysis = { + accepted: File[]; + nonImageCount: number; + oversizedImagesCount: number; + queueOmittedImagesCount: number; +}; diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue index a613591..849998b 100644 --- a/frontend/src/views/UploadView.vue +++ b/frontend/src/views/UploadView.vue @@ -1,19 +1,195 @@ diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts index a383a04..7b0990f 100644 --- a/frontend/src/workers/exiftoolWorker.ts +++ b/frontend/src/workers/exiftoolWorker.ts @@ -93,7 +93,7 @@ const processJob = async (job: ExiftoolWorkerJob): Promise => { } catch (error) { self.postMessage({ type: 'init-error', - message: resolveErrorMessage(error, 'Failed to prepare ExifTool') + message: resolveErrorMessage(error, 'Could not prepare ExifTool') }); } @@ -117,7 +117,7 @@ const processJob = async (job: ExiftoolWorkerJob): Promise => { self.postMessage({ type: 'read-metadata-error', id: job.id, - message: resolveErrorMessage(error, 'Could not read image metadata') + message: resolveErrorMessage(error, 'Could not load image metadata') }); } } -- 2.54.0 From 1826cef8fe1043eec710049955979c9b69ba74dd Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 18 Oct 2026 18:02:49 +0200 Subject: [PATCH 8/9] 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 }); } }); -- 2.54.0 From e1f0d014675c7e21301a7cb69f976a11758639ac Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 18 Oct 2026 18:22:15 +0200 Subject: [PATCH 9/9] Restore Axios error.message when response body has no API message. --- frontend/src/utils/http/resolveAxiosErrorMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/http/resolveAxiosErrorMessage.ts b/frontend/src/utils/http/resolveAxiosErrorMessage.ts index 86ddee7..d7e35d2 100644 --- a/frontend/src/utils/http/resolveAxiosErrorMessage.ts +++ b/frontend/src/utils/http/resolveAxiosErrorMessage.ts @@ -36,5 +36,5 @@ export const resolveAxiosErrorMessage = (error: unknown, fallback: string): stri return resolveErrorMessage(error, fallback); } - return getAxiosErrorResponseMessage(error.response.data) ?? fallback; + return getAxiosErrorResponseMessage(error.response.data) ?? resolveErrorMessage(error, fallback); }; -- 2.54.0