Slave/metadata pipeline #6

Merged
nobswebdev merged 9 commits from slave/metadata-pipeline into master 2026-10-18 16:57:39 +00:00
8 changed files with 274 additions and 32 deletions
Showing only changes of commit 18597f4127 - Show all commits
+13
View File
@@ -13,6 +13,7 @@
"@uswriting/exiftool": "1.0.9", "@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"
@@ -1924,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",
+1
View File
@@ -14,6 +14,7 @@
"@uswriting/exiftool": "1.0.9", "@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,40 @@
<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 p-4">
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
{{ entry.message }}
</p>
<pre
v-else-if="entry.status === 'ready'"
class="max-h-96 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
>{{ entry.output }}</pre
>
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
</div>
</section>
</template>
<script setup lang="ts">
import type { PropType } from 'vue';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
defineProps({
fileName: {
type: String as PropType<string | null>,
required: true
},
entry: {
type: Object as PropType<MetadataPreviewEntry>,
required: true
}
});
</script>
+28 -22
View File
@@ -5,8 +5,20 @@ import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool'; const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool'; const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
const METADATA_READING_MESSAGE = 'Reading metadata'; const METADATA_LOADING_MESSAGE = 'Loading metadata';
const METADATA_READ_ERROR_MESSAGE = 'Could not read image 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', () => { export const useMetadataStore = defineStore('metadata', () => {
const previewsByKey = ref<Record<string, MetadataPreviewEntry>>({}); const previewsByKey = ref<Record<string, MetadataPreviewEntry>>({});
@@ -18,10 +30,14 @@ export const useMetadataStore = defineStore('metadata', () => {
const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`; 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); const key = makeKeyFromFile(file);
return previewsByKey.value[key] ?? { status: 'idle' }; return previewsByKey.value[key] ?? METADATA_IDLE_PREVIEW;
}; };
const loadPreview = async (file: File): Promise<MetadataPreviewEntry> => { const loadPreview = async (file: File): Promise<MetadataPreviewEntry> => {
@@ -41,7 +57,7 @@ export const useMetadataStore = defineStore('metadata', () => {
if (exiftoolInitStatus.value === 'ready') { if (exiftoolInitStatus.value === 'ready') {
previewsByKey.value = { previewsByKey.value = {
...previewsByKey.value, ...previewsByKey.value,
[key]: { status: 'loading', message: METADATA_READING_MESSAGE } [key]: METADATA_LOADING_PREVIEW
}; };
} else { } else {
previewsByKey.value = { previewsByKey.value = {
@@ -54,7 +70,7 @@ export const useMetadataStore = defineStore('metadata', () => {
previewsByKey.value = { previewsByKey.value = {
...previewsByKey.value, ...previewsByKey.value,
[key]: { status: 'loading', message: METADATA_READING_MESSAGE } [key]: METADATA_LOADING_PREVIEW
}; };
} catch { } catch {
const entry: MetadataPreviewEntry = { const entry: MetadataPreviewEntry = {
@@ -71,7 +87,11 @@ export const useMetadataStore = defineStore('metadata', () => {
try { try {
const output = await readMetadata(file); 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 }; previewsByKey.value = { ...previewsByKey.value, [key]: entry };
@@ -79,7 +99,7 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch { } catch {
const entry: MetadataPreviewEntry = { const entry: MetadataPreviewEntry = {
status: 'error', status: 'error',
message: METADATA_READ_ERROR_MESSAGE message: METADATA_LOAD_ERROR_MESSAGE
}; };
previewsByKey.value = { ...previewsByKey.value, [key]: entry }; previewsByKey.value = { ...previewsByKey.value, [key]: entry };
@@ -91,19 +111,6 @@ export const useMetadataStore = defineStore('metadata', () => {
const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> => const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> =>
Promise.all(files.map(file => loadPreview(file))); 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 => { const clearAllPreviews = (): void => {
previewsByKey.value = {}; previewsByKey.value = {};
}; };
@@ -114,7 +121,6 @@ export const useMetadataStore = defineStore('metadata', () => {
getPreview, getPreview,
loadPreview, loadPreview,
loadPreviews, loadPreviews,
clearPreview,
clearAllPreviews clearAllPreviews
}; };
}); });
@@ -1,6 +1,6 @@
export type MetadataPreviewEntry = export type MetadataPreviewEntry =
| { status: 'idle' } | { status: 'idle'; message: string }
| { status: 'pending'; message: string } | { status: 'pending'; message: string }
| { status: 'loading'; message: string } | { status: 'loading'; message: string }
| { status: 'ready'; output: string } | { status: 'ready'; message: string; output: string }
| { status: 'error'; message: string }; | { status: 'error'; message: string };
@@ -0,0 +1,6 @@
export type PickedUploadAnalysis = {
accepted: File[];
nonImageCount: number;
oversizedImagesCount: number;
queueOmittedImagesCount: number;
};
+181 -5
View File
@@ -1,19 +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"> <script setup lang="ts">
import { onBeforeMount } from 'vue'; import { computed, onBeforeMount, ref } from 'vue';
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool'; 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 exiftoolStore = useExiftoolStore();
const metadataStore = useMetadataStore();
const { initializeExiftool } = exiftoolStore; const { initializeExiftool } = exiftoolStore;
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
onBeforeMount(() => { const queuedFiles = ref<File[]>([]);
initializeExiftool(); 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> </script>
+2 -2
View File
@@ -93,7 +93,7 @@ const processJob = async (job: ExiftoolWorkerJob): Promise<void> => {
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
type: 'init-error', 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<void> => {
self.postMessage({ self.postMessage({
type: 'read-metadata-error', type: 'read-metadata-error',
id: job.id, id: job.id,
message: resolveErrorMessage(error, 'Could not read image metadata') message: resolveErrorMessage(error, 'Could not load image metadata')
}); });
} }
} }