Merge pull request 'Slave/metadata pipeline' (#6) from slave/metadata-pipeline into master
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -3,6 +3,7 @@ FROM node:22-alpine
|
|||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
COPY package*.json .
|
COPY package*.json .
|
||||||
|
COPY patches ./patches
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
|||||||
Generated
+47
@@ -7,10 +7,13 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@uswriting/exiftool": "1.0.9",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
|
"pretty-bytes": "^7.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
@@ -24,6 +27,12 @@
|
|||||||
"vue-tsc": "^3.2.6"
|
"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": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"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"
|
"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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.9",
|
"version": "6.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
|
||||||
@@ -1907,6 +1925,18 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -2156,6 +2186,23 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vue-tsc -b && vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@uswriting/exiftool": "1.0.9",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
|
"pretty-bytes": "^7.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card" aria-live="polite">
|
||||||
|
<header class="border-b border-border px-4 py-3">
|
||||||
|
<h2 class="text-sm font-medium text-card-foreground">Metadata</h2>
|
||||||
|
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
|
||||||
|
|
||||||
|
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
|
||||||
|
{{ entry.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="entry.status === 'ready'">
|
||||||
|
<div
|
||||||
|
class="inline-flex w-fit rounded-md border border-border p-0.5"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Metadata view"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeTab === 'original'
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
"
|
||||||
|
:aria-selected="activeTab === 'original'"
|
||||||
|
@click="activeTab = 'original'"
|
||||||
|
>
|
||||||
|
Original
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
activeTab === 'stripped'
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
"
|
||||||
|
:aria-selected="activeTab === 'stripped'"
|
||||||
|
@click="activeTab = 'stripped'"
|
||||||
|
>
|
||||||
|
Stripped
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
role="tabpanel"
|
||||||
|
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
||||||
|
>{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}</pre
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, type PropType } from 'vue';
|
||||||
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
|
import type { MetadataTab } from '@/types/metadata/MetadataTab';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fileName: {
|
||||||
|
type: String as PropType<string | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
entry: {
|
||||||
|
type: Object as PropType<MetadataPreviewEntry>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = ref<MetadataTab>('original');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.fileName,
|
||||||
|
() => {
|
||||||
|
activeTab.value = 'original';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -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);
|
||||||
@@ -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<ExiftoolInitStatus>('idle');
|
||||||
|
const initErrorMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
const worker = ref<Worker | null>(null);
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | 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<void> => {
|
||||||
|
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<void>((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<ExiftoolWorkerOutMessage>): 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<string> => {
|
||||||
|
await initializeExiftool();
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const workerInstance = worker.value;
|
||||||
|
|
||||||
|
if (!workerInstance) {
|
||||||
|
throw new Error('ExifTool worker is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
readMetadataWaiters.set(id, { resolve, reject });
|
||||||
|
workerInstance.postMessage({ type: 'read-metadata', id, file });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripAllMetadata = async (file: File): Promise<Blob> => {
|
||||||
|
await initializeExiftool();
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const workerInstance = worker.value;
|
||||||
|
|
||||||
|
if (!workerInstance) {
|
||||||
|
throw new Error('ExifTool worker is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||||
|
stripMetadataWaiters.set(id, { resolve, reject });
|
||||||
|
workerInstance.postMessage({ type: 'strip-metadata', id, file });
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Blob([data], { type: file.type || 'application/octet-stream' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
initStatus,
|
||||||
|
initErrorMessage,
|
||||||
|
initializeExiftool,
|
||||||
|
readMetadata,
|
||||||
|
stripAllMetadata
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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<Record<string, MetadataPreviewEntry>>({});
|
||||||
|
const strippedBlobByKey = ref<Record<string, Blob>>({});
|
||||||
|
|
||||||
|
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<MetadataPreviewEntry> => {
|
||||||
|
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<MetadataPreviewEntry[]> =>
|
||||||
|
Promise.all(files.map(file => loadPreview(file)));
|
||||||
|
|
||||||
|
const clearAllPreviews = (): void => {
|
||||||
|
previewsByKey.value = {};
|
||||||
|
strippedBlobByKey.value = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewsByKey,
|
||||||
|
strippedBlobByKey,
|
||||||
|
makeKeyFromFile,
|
||||||
|
getPreview,
|
||||||
|
getStrippedBlob,
|
||||||
|
loadPreview,
|
||||||
|
loadPreviews,
|
||||||
|
clearAllPreviews
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ExiftoolInitStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type MetadataTab = 'original' | 'stripped';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type PickedUploadAnalysis = {
|
||||||
|
accepted: File[];
|
||||||
|
nonImageCount: number;
|
||||||
|
oversizedImagesCount: number;
|
||||||
|
queueOmittedImagesCount: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ExiftoolWorkerInMessage =
|
||||||
|
| { type: 'init' }
|
||||||
|
| { type: 'read-metadata'; id: string; file: File }
|
||||||
|
| { type: 'strip-metadata'; id: string; file: File };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ExiftoolWorkerJob =
|
||||||
|
| { type: 'init' }
|
||||||
|
| { type: 'read-metadata'; id: string; file: File }
|
||||||
|
| { type: 'strip-metadata'; id: string; file: File };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,23 +1,40 @@
|
|||||||
import axios from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
|
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
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') {
|
if (typeof message === 'string') {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(message)) {
|
if (Array.isArray(message)) {
|
||||||
return message.map(String).join(', ');
|
const texts = message.filter((item): item is string => typeof item === 'string');
|
||||||
|
|
||||||
|
if (texts.length === 1) {
|
||||||
|
return texts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (texts.length > 1) {
|
||||||
|
return texts.join(', ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (error.message) {
|
|
||||||
return error.message;
|
return null;
|
||||||
}
|
};
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
export const resolveAxiosErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
return error.message;
|
if (!isAxiosError(error) || !error.response) {
|
||||||
}
|
return resolveErrorMessage(error, fallback);
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getAxiosErrorResponseMessage(error.response.data) ?? resolveErrorMessage(error, fallback);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function toArrayBuffer(data: unknown): ArrayBuffer | null {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
const { buffer, byteOffset, byteLength } = data;
|
||||||
|
|
||||||
|
if (buffer instanceof ArrayBuffer) {
|
||||||
|
return buffer.slice(byteOffset, byteOffset + byteLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,195 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="flex min-h-screen flex-col p-8">
|
<main class="mx-auto flex min-h-screen max-w-3xl flex-col gap-6 p-8">
|
||||||
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Upload</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Upload</h1>
|
||||||
<p class="mt-2 text-muted-foreground">Upload UI coming soon.</p>
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
Choose up to {{ maxQueueSize }} images, up to
|
||||||
|
{{ maxImageSizeFormatted }}
|
||||||
|
each.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||||
|
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||||
|
>
|
||||||
|
Choose images
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="sr-only"
|
||||||
|
:disabled="loadingMetadata"
|
||||||
|
@change="onFileInputChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
|
||||||
|
<li v-for="row in queueRows" :key="row.key">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||||
|
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
|
||||||
|
@click="selectedFile = row.file"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
|
||||||
|
{{ row.preview.message }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeMount, ref } from 'vue';
|
||||||
|
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import { useExiftoolStore } from '@/stores/exiftool';
|
||||||
|
import { useMetadataStore } from '@/stores/metadata';
|
||||||
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
|
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
|
||||||
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
||||||
|
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||||
|
|
||||||
|
const exiftoolStore = useExiftoolStore();
|
||||||
|
const metadataStore = useMetadataStore();
|
||||||
|
|
||||||
|
const { initializeExiftool } = exiftoolStore;
|
||||||
|
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
|
||||||
|
|
||||||
|
const queuedFiles = ref<File[]>([]);
|
||||||
|
const selectedFile = ref<File | null>(null);
|
||||||
|
const selectionError = ref<string | null>(null);
|
||||||
|
const loadingMetadata = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
try {
|
||||||
|
await initializeExiftool();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||||
|
|
||||||
|
const queueRows = computed(() =>
|
||||||
|
queuedFiles.value.map(file => ({
|
||||||
|
file,
|
||||||
|
key: makeKeyFromFile(file),
|
||||||
|
preview: getPreview(file)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
||||||
|
|
||||||
|
const isFileSelected = (file: File): boolean =>
|
||||||
|
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
|
||||||
|
|
||||||
|
const statusTextClass = (entry: MetadataPreviewEntry): string =>
|
||||||
|
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
|
||||||
|
|
||||||
|
const onFileInputChange = async (event: Event): Promise<void> => {
|
||||||
|
if (loadingMetadata.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const picked = input.files ? [...input.files] : [];
|
||||||
|
|
||||||
|
resetUploadSelection();
|
||||||
|
|
||||||
|
if (picked.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
|
||||||
|
|
||||||
|
if (nonImageCount === picked.length) {
|
||||||
|
selectionError.value = 'Only image files can be added.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length === 0) {
|
||||||
|
selectionError.value = `Each image must be at most ${maxImageSizeFormatted.value}.`;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
|
||||||
|
|
||||||
|
queuedFiles.value = accepted;
|
||||||
|
selectedFile.value = accepted[0] ?? null;
|
||||||
|
|
||||||
|
loadingMetadata.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPreviews(accepted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
loadingMetadata.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUploadSelection = (): void => {
|
||||||
|
selectionError.value = null;
|
||||||
|
clearAllPreviews();
|
||||||
|
queuedFiles.value = [];
|
||||||
|
selectedFile.value = null;
|
||||||
|
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzePickedUploadFiles = (picked: File[]): PickedUploadAnalysis => {
|
||||||
|
const images = picked.filter(file => file.type.startsWith('image/'));
|
||||||
|
const nonImageCount = picked.length - images.length;
|
||||||
|
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
|
||||||
|
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
|
||||||
|
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
|
||||||
|
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepted,
|
||||||
|
nonImageCount,
|
||||||
|
oversizedImagesCount,
|
||||||
|
queueOmittedImagesCount
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPickedUploadWarning = (
|
||||||
|
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
|
||||||
|
): string | null => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (summary.nonImageCount > 0) {
|
||||||
|
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.oversizedImagesCount > 0) {
|
||||||
|
parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.queueOmittedImagesCount > 0) {
|
||||||
|
parts.push(`Only the first ${maxQueueSize} valid images were added.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' ') : null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -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<void> | null = null;
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
let initializeWorkerPromise: Promise<void> | 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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
while (jobQueue.length > 0) {
|
||||||
|
const job = jobQueue.shift();
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processJob(job);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processJob = async (job: ExiftoolWorkerJob): Promise<void> => {
|
||||||
|
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<ExiftoolWorkerInMessage>) => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user