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..2075cd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,10 +7,13 @@ "": { "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", + "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", "vue": "^3.5.32", "vue-router": "^4.6.4" @@ -24,6 +27,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 +895,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", @@ -1907,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", @@ -2156,6 +2186,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..246539e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,12 +6,15 @@ "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 && node patches/patch-zeroperl-worker-fetch.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@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/patches/README.md b/frontend/patches/README.md new file mode 100644 index 0000000..1965824 --- /dev/null +++ b/frontend/patches/README.md @@ -0,0 +1,29 @@ +# 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). + +## `@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-exiftool-stderr.mjs b/frontend/patches/patch-exiftool-stderr.mjs new file mode 100644 index 0000000..85d923a --- /dev/null +++ b/frontend/patches/patch-exiftool-stderr.mjs @@ -0,0 +1,60 @@ +/** + * 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. + * + * 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); +} 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/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue new file mode 100644 index 0000000..6c78537 --- /dev/null +++ b/frontend/src/components/upload/MetadataInspector.vue @@ -0,0 +1,87 @@ + + + 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..935aed5 --- /dev/null +++ b/frontend/src/stores/exiftool.ts @@ -0,0 +1,188 @@ +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 stripMetadataWaiters = new Map< + string, + { resolve: (data: ArrayBuffer) => 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.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' }); + } + + 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') { + console.error('[exiftool] init-error', message.message); + 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); + 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)); + } + }; + + 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 }); + }); + }; + + 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, + stripAllMetadata + }; +}); diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts new file mode 100644 index 0000000..a06540b --- /dev/null +++ b/frontend/src/stores/metadata.ts @@ -0,0 +1,194 @@ +import { defineStore, storeToRefs } from 'pinia'; +import { ref } from 'vue'; +import { useExiftoolStore } from '@/stores/exiftool'; +import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; + +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 stripped'; + +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>({}); + const strippedBlobByKey = ref>({}); + + const exiftoolStore = useExiftoolStore(); + + const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore); + const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore; + + const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; + + const getPreview = (file?: File | null): MetadataPreviewEntry => { + if (!file) { + return METADATA_IDLE_PREVIEW; + } + + const key = makeKeyFromFile(file); + + 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); + + 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]: METADATA_LOADING_PREVIEW + }; + } else { + previewsByKey.value = { + ...previewsByKey.value, + [key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE } + }; + + try { + await initializeExiftool(); + + previewsByKey.value = { + ...previewsByKey.value, + [key]: METADATA_LOADING_PREVIEW + }; + } catch (error) { + console.error('[metadata] ExifTool init failed', { file: file.name, error }); + + const entry: MetadataPreviewEntry = { + status: 'error', + message: EXIFTOOL_PREPARE_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + return entry; + } + } + + let output: string; + + try { + output = await readMetadata(file); + } catch (error) { + console.error('[metadata] read failed', { file: file.name, error }); + + const entry: MetadataPreviewEntry = { + status: 'error', + message: METADATA_LOAD_ERROR_MESSAGE + }; + + previewsByKey.value = { ...previewsByKey.value, [key]: entry }; + + 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 => + Promise.all(files.map(file => loadPreview(file))); + + const clearAllPreviews = (): void => { + previewsByKey.value = {}; + strippedBlobByKey.value = {}; + }; + + return { + previewsByKey, + strippedBlobByKey, + makeKeyFromFile, + getPreview, + getStrippedBlob, + loadPreview, + loadPreviews, + clearAllPreviews + }; +}); 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/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts new file mode 100644 index 0000000..576afe2 --- /dev/null +++ b/frontend/src/types/metadata/MetadataPreview.ts @@ -0,0 +1,6 @@ +export type MetadataPreviewEntry = + | { status: 'idle'; message: string } + | { status: 'pending'; message: string } + | { status: 'loading'; message: 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/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/types/workers/exiftoolWorkerInMessage.ts b/frontend/src/types/workers/exiftoolWorkerInMessage.ts new file mode 100644 index 0000000..e8dfbec --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerInMessage.ts @@ -0,0 +1,4 @@ +export type ExiftoolWorkerInMessage = + | { type: 'init' } + | { 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 new file mode 100644 index 0000000..396bee2 --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerJob.ts @@ -0,0 +1,4 @@ +export type ExiftoolWorkerJob = + | { type: 'init' } + | { 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 new file mode 100644 index 0000000..330ea61 --- /dev/null +++ b/frontend/src/types/workers/exiftoolWorkerOutMessage.ts @@ -0,0 +1,7 @@ +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: 'strip-metadata-done'; id: string; data: ArrayBuffer } + | { type: 'strip-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/utils/http/resolveAxiosErrorMessage.ts b/frontend/src/utils/http/resolveAxiosErrorMessage.ts index 6747a60..d7e35d2 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) ?? resolveErrorMessage(error, 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; +} 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/views/UploadView.vue b/frontend/src/views/UploadView.vue index 1d82b92..849998b 100644 --- a/frontend/src/views/UploadView.vue +++ b/frontend/src/views/UploadView.vue @@ -1,6 +1,195 @@ + + diff --git a/frontend/src/workers/exiftoolWorker.ts b/frontend/src/workers/exiftoolWorker.ts new file mode 100644 index 0000000..7b8e839 --- /dev/null +++ b/frontend/src/workers/exiftoolWorker.ts @@ -0,0 +1,215 @@ +import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; +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) }; + +const jobQueue: ExiftoolWorkerJob[] = []; +let drainPromise: Promise | null = null; + +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}. + * + * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). + * @see Main thread `initializeExiftool()` 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 }, + exiftoolParseOptions + ); + + if (!result.success) { + throw new Error(result.error); + } + + initialized = true; + } catch (error) { + initializeWorkerPromise = null; + + throw error; + } + })(); + } + + return initializeWorkerPromise; +}; + +const enqueue = (job: ExiftoolWorkerJob): void => { + jobQueue.push(job); + + if (!drainPromise) { + drainPromise = (async () => { + try { + await drain(); + } catch { + console.error('Failed to drain ExifTool worker'); + } + + drainPromise = null; + })(); + } +}; + +const drain = async (): Promise => { + 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(); + + self.postMessage({ type: 'init-done' }); + } catch (error) { + self.postMessage({ + type: 'init-error', + message: resolveErrorMessage(error, 'Could not prepare ExifTool') + }); + } + + return; + } + + if (job.type === 'read-metadata') { + try { + await initializeWorker(); + + const result = await parseMetadata(job.file, exiftoolParseOptions); + + if (!result.success) { + const message = result.error ?? 'Could not load image metadata'; + + console.error('[exiftool-worker] read failed', { id: job.id, file: job.file.name, message }); + + self.postMessage({ type: 'read-metadata-error', id: job.id, message }); + + return; + } + + self.postMessage({ type: 'read-metadata-done', id: job.id, output: result.data }); + } catch (error) { + const message = resolveErrorMessage(error, 'Could not load image metadata'); + + console.error('[exiftool-worker] read threw', { id: job.id, file: job.file.name, error }); + + self.postMessage({ + type: 'read-metadata-error', + id: job.id, + message + }); + } + + return; + } + + if (job.type === 'strip-metadata') { + try { + await initializeWorker(); + + const result = await writeMetadata( + job.file, + {}, + { args: ['-all='], ...exiftoolParseOptions } + ); + + if (!result.success) { + const message = result.error ?? 'Could not strip image metadata'; + + console.error('[exiftool-worker] strip failed', { + id: job.id, + file: job.file.name, + type: job.file.type, + exitCode: result.exitCode, + message + }); + + self.postMessage({ + type: 'strip-metadata-error', + id: job.id, + message + }); + + return; + } + + const data = toArrayBuffer(result.data); + + if (!data) { + const message = 'Strip succeeded but returned no image bytes'; + + console.error('[exiftool-worker] strip missing data', { + id: job.id, + file: job.file.name, + dataType: result.data === null ? 'null' : typeof result.data + }); + + self.postMessage({ type: 'strip-metadata-error', id: job.id, message }); + + return; + } + + self.postMessage({ type: 'strip-metadata-done', id: job.id, data }, { transfer: [data] }); + } catch (error) { + const message = resolveErrorMessage(error, 'Failed to strip metadata'); + + console.error('[exiftool-worker] strip failed', { id: job.id, file: job.file.name, error }); + + self.postMessage({ + type: 'strip-metadata-error', + id: job.id, + message + }); + } + } +}; + +self.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'init') { + enqueue({ type: 'init' }); + + return; + } + + if (message.type === 'read-metadata') { + enqueue({ type: 'read-metadata', id: message.id, file: message.file }); + + return; + } + + if (message.type === 'strip-metadata') { + enqueue({ type: 'strip-metadata', id: message.id, file: message.file }); + } +});