feat: add images store
- Image queue with concurrent processing - File validation (format, size) - Processing status tracking - Pipeline override per image - Batch reprocessing Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
219
src/lib/stores/images.svelte.ts
Normal file
219
src/lib/stores/images.svelte.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import type { ImageEntry, Dimensions, Device, PipelineConfig } from '$lib/types';
|
||||||
|
import { CONSTRAINTS, isSupportedFormat, DEFAULT_PIPELINE_CONFIG } from '$lib/types';
|
||||||
|
import { processImageWithPipeline } from '$lib/processing/pipeline';
|
||||||
|
|
||||||
|
function createImagesStore() {
|
||||||
|
let images = $state<ImageEntry[]>([]);
|
||||||
|
|
||||||
|
const processingQueue: Array<{ id: string; file: File; device: Device }> = [];
|
||||||
|
let isProcessingQueue = false;
|
||||||
|
const MAX_CONCURRENT = 2;
|
||||||
|
let activeProcessing = 0;
|
||||||
|
|
||||||
|
const pendingCount = $derived(images.filter((img) => img.status === 'pending').length);
|
||||||
|
const processingCount = $derived(images.filter((img) => img.status === 'processing').length);
|
||||||
|
const completeCount = $derived(images.filter((img) => img.status === 'complete').length);
|
||||||
|
const errorCount = $derived(images.filter((img) => img.status === 'error').length);
|
||||||
|
const hasImages = $derived(images.length > 0);
|
||||||
|
const completedImages = $derived(images.filter((img) => img.status === 'complete'));
|
||||||
|
|
||||||
|
async function processQueue(): Promise<void> {
|
||||||
|
if (isProcessingQueue) return;
|
||||||
|
isProcessingQueue = true;
|
||||||
|
|
||||||
|
while (processingQueue.length > 0 && activeProcessing < MAX_CONCURRENT) {
|
||||||
|
const job = processingQueue.shift();
|
||||||
|
if (!job) break;
|
||||||
|
|
||||||
|
activeProcessing++;
|
||||||
|
processImageEntry(job.id, job.file, job.device).finally(() => {
|
||||||
|
activeProcessing--;
|
||||||
|
if (processingQueue.length > 0) {
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addImages(files: FileList | File[], device: Device): Promise<void> {
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
for (const file of fileArray) {
|
||||||
|
if (!isSupportedFormat(file)) {
|
||||||
|
const entry = createErrorEntry(file, 'Unsupported format. Use JPG, PNG, or WebP');
|
||||||
|
images = [...images, entry];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > CONSTRAINTS.MAX_FILE_SIZE_BYTES) {
|
||||||
|
const entry = createErrorEntry(file, `File exceeds ${CONSTRAINTS.MAX_FILE_SIZE_MB}MB limit`);
|
||||||
|
images = [...images, entry];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createPendingEntry(file);
|
||||||
|
images = [...images, entry];
|
||||||
|
processingQueue.push({ id: entry.id, file, device });
|
||||||
|
}
|
||||||
|
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorEntry(file: File, error: string): ImageEntry {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
filename: file.name,
|
||||||
|
originalDimensions: null,
|
||||||
|
originalDataUrl: null,
|
||||||
|
processedBlob: null,
|
||||||
|
processedDataUrl: null,
|
||||||
|
status: 'error',
|
||||||
|
error,
|
||||||
|
pipelineOverride: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPendingEntry(file: File): ImageEntry {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
filename: file.name,
|
||||||
|
originalDimensions: null,
|
||||||
|
originalDataUrl: null,
|
||||||
|
processedBlob: null,
|
||||||
|
processedDataUrl: null,
|
||||||
|
status: 'pending',
|
||||||
|
error: null,
|
||||||
|
pipelineOverride: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processImageEntry(id: string, file: File, device: Device): Promise<void> {
|
||||||
|
updateImage(id, { status: 'processing' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalDataUrl = await readFileAsDataUrl(file);
|
||||||
|
const originalDimensions = await getImageDimensions(originalDataUrl);
|
||||||
|
|
||||||
|
updateImage(id, { originalDataUrl, originalDimensions });
|
||||||
|
|
||||||
|
const img = getImage(id);
|
||||||
|
const pipelineConfig = img?.pipelineOverride ?? DEFAULT_PIPELINE_CONFIG;
|
||||||
|
|
||||||
|
const { blob, dataUrl } = await processImageWithPipeline(file, device, pipelineConfig);
|
||||||
|
|
||||||
|
updateImage(id, {
|
||||||
|
processedBlob: blob,
|
||||||
|
processedDataUrl: dataUrl,
|
||||||
|
status: 'complete',
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Processing failed';
|
||||||
|
updateImage(id, { status: 'error', error: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageDimensions(dataUrl: string): Promise<Dimensions> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImage(id: string, updates: Partial<ImageEntry>): void {
|
||||||
|
images = images.map((img) => (img.id === id ? { ...img, ...updates } : img));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(id: string): void {
|
||||||
|
images = images.filter((img) => img.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll(): void {
|
||||||
|
images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reprocessAll(device: Device): Promise<void> {
|
||||||
|
const toReprocess = images.filter((img) => img.file && img.status !== 'error');
|
||||||
|
|
||||||
|
for (const img of toReprocess) {
|
||||||
|
updateImage(img.id, {
|
||||||
|
status: 'processing',
|
||||||
|
processedBlob: null,
|
||||||
|
processedDataUrl: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
processImageEntry(img.id, img.file, device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImage(id: string): ImageEntry | undefined {
|
||||||
|
return images.find((img) => img.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPipelineOverride(id: string, config: PipelineConfig | null): void {
|
||||||
|
updateImage(id, { pipelineOverride: config });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reprocessImage(id: string, device: Device): Promise<void> {
|
||||||
|
const img = getImage(id);
|
||||||
|
if (!img || !img.file) return;
|
||||||
|
|
||||||
|
updateImage(id, {
|
||||||
|
status: 'processing',
|
||||||
|
processedBlob: null,
|
||||||
|
processedDataUrl: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
processImageEntry(id, img.file, device);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get images() {
|
||||||
|
return images;
|
||||||
|
},
|
||||||
|
get pendingCount() {
|
||||||
|
return pendingCount;
|
||||||
|
},
|
||||||
|
get processingCount() {
|
||||||
|
return processingCount;
|
||||||
|
},
|
||||||
|
get completeCount() {
|
||||||
|
return completeCount;
|
||||||
|
},
|
||||||
|
get errorCount() {
|
||||||
|
return errorCount;
|
||||||
|
},
|
||||||
|
get hasImages() {
|
||||||
|
return hasImages;
|
||||||
|
},
|
||||||
|
get completedImages() {
|
||||||
|
return completedImages;
|
||||||
|
},
|
||||||
|
addImages,
|
||||||
|
updateImage,
|
||||||
|
removeImage,
|
||||||
|
clearAll,
|
||||||
|
reprocessAll,
|
||||||
|
getImage,
|
||||||
|
setPipelineOverride,
|
||||||
|
reprocessImage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imagesStore = createImagesStore();
|
||||||
Reference in New Issue
Block a user