perf: implement background thumbnail generation and inter-post preloading

Key changes:
- Added Web Worker for background image thumbnailing with a 1MiB threshold to optimize CPU/memory usage.
- Implemented a serial task queue for memory-safe high-res image processing, preventing OOM crashes.
- Added inter-post preloading in the modal for seamless 'Previous/Next' navigation.
- Refined scanning UI with double-buffering and a dark background to completely eliminate white flashes.
- Renamed project to 'instaarchive-viewer' in package.json.
- Fixed 'Open image in new tab' by denylisting /archives and /api in PWA config.
This commit is contained in:
ergosteur
2026-03-07 21:42:56 -05:00
parent 3c50be286e
commit 8ec2c07b3f
10 changed files with 338 additions and 87 deletions
+5 -5
View File
@@ -195,8 +195,8 @@ export const useArchiveScanner = (
const type = isVideo(matchedFile.name) ? 'video' : 'image';
const url = matchedFile.url || URL.createObjectURL(new Blob([await matchedFile.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
const existingMedia = post.media!.find(media => media.index === mIdx + 1);
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, url, type, index: mIdx + 1 } : media); }
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1 });
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, url, type, index: mIdx + 1, size: matchedFile!.size } : media); }
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1, size: matchedFile.size });
}
}
if (post.media!.length > 0) postsMap.set(postId, post);
@@ -251,8 +251,8 @@ export const useArchiveScanner = (
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
if (type === 'image') throttledSetScanningImage(url);
const existingMedia = post.media!.find(m => m.index === index);
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, url, type, index } : m); }
else post.media!.push({ name: file.name, url, type, index });
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, url, type, index, size: file.size } : m); }
else post.media!.push({ name: file.name, url, type, index, size: file.size });
}
}
await new Promise(resolve => setTimeout(resolve, 0));
@@ -297,7 +297,7 @@ export const useArchiveScanner = (
const type = isVideo(file.name) ? 'video' : 'image';
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
if (type === 'image') throttledSetScanningImage(url);
post.media.push({ name: file.name, url, type, index: idx + 1 });
post.media.push({ name: file.name, url, type, index: idx + 1, size: file.size });
}
post.thumbnail = post.media[0].url;
postsMap.set(postId, post);
+106
View File
@@ -0,0 +1,106 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import * as idb from 'idb-keyval';
interface ThumbnailRequest {
id: string;
url: string;
blob?: Blob;
}
const THUMBNAIL_WIDTH = 400;
export const useThumbnailQueue = () => {
const [cacheHits, setCacheHits] = useState<Map<string, string>>(new Map());
const queueRef = useRef<ThumbnailRequest[]>([]);
const isProcessingRef = useRef(false);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// Initialize worker with relative URL (vite will handle this)
workerRef.current = new Worker(new URL('../lib/thumbnail-worker.ts', import.meta.url), {
type: 'module'
});
workerRef.current.onmessage = async (e) => {
const { id, blob, error } = e.data;
if (!error && blob) {
const url = URL.createObjectURL(blob);
setCacheHits(prev => new Map(prev).set(id, url));
// Persist to IndexedDB (as dataURL for simple retrieval or keep as Blob)
try {
await idb.set(`thumb_${id}`, blob);
} catch (err) {}
}
// Process next in queue
isProcessingRef.current = false;
processNext();
};
return () => {
workerRef.current?.terminate();
};
}, []);
const processNext = useCallback(async () => {
if (isProcessingRef.current || queueRef.current.length === 0 || !workerRef.current) return;
isProcessingRef.current = true;
const request = queueRef.current.shift()!;
try {
// 1. Double check cache before expensive work
const cached = await idb.get(`thumb_${request.id}`);
if (cached instanceof Blob) {
const url = URL.createObjectURL(cached);
setCacheHits(prev => new Map(prev).set(request.id, url));
isProcessingRef.current = false;
processNext();
return;
}
// 2. Fetch original if no blob provided
let blob = request.blob;
if (!blob) {
const res = await fetch(request.url);
blob = await res.blob();
}
// 3. Send to worker
workerRef.current.postMessage({
id: request.id,
blob,
width: THUMBNAIL_WIDTH
});
} catch (err) {
console.error('[ThumbnailQueue] Failed to process:', request.id, err);
isProcessingRef.current = false;
processNext();
}
}, []);
const requestThumbnail = useCallback(async (id: string, url: string, blob?: Blob) => {
// 1. Sync check state
if (cacheHits.has(id)) return;
// 2. Async check idb
const cached = await idb.get(`thumb_${id}`);
if (cached instanceof Blob) {
setCacheHits(prev => new Map(prev).set(id, URL.createObjectURL(cached)));
return;
}
// 3. Add to queue if not already there
if (!queueRef.current.some(r => r.id === id)) {
queueRef.current.push({ id, url, blob });
processNext();
}
}, [cacheHits, processNext]);
return {
cacheHits,
requestThumbnail
};
};