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
+19 -15
View File
@@ -17,6 +17,8 @@ import { MediaRenderer } from './MediaRenderer';
interface PostModalProps {
post: Post;
nextPost?: Post;
prevPost?: Post;
onClose: () => void;
onNextPost?: () => void;
onPrevPost?: () => void;
@@ -26,7 +28,7 @@ interface PostModalProps {
}
export const PostModal: React.FC<PostModalProps> = ({
post, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [direction, setDirection] = useState(0);
@@ -34,32 +36,34 @@ export const PostModal: React.FC<PostModalProps> = ({
// Preloading Logic
useEffect(() => {
const controller = new AbortController();
const preloadMedia = async (index: number) => {
if (index < 0 || index >= post.media.length) return;
const media = post.media[index];
if (!media.url) return;
const preloadMedia = async (url: string, type: 'image' | 'video') => {
if (!url) return;
try {
if (media.type === 'image') {
if (type === 'image') {
const img = new Image();
img.src = media.url;
img.src = url;
} else {
const video = document.createElement('video');
video.src = media.url;
video.src = url;
video.preload = 'auto';
}
} catch (e) {}
};
// 1. Immediate preload of first two slides
preloadMedia(0);
preloadMedia(1);
// 1. Current post: Immediate preload of first two slides
if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type);
if (post.media[1]) preloadMedia(post.media[1].url, post.media[1].type);
// 2. Delayed preload of the rest to stay out of the way of initial render
// 2. Next/Prev posts: Preload their first slides
if (nextPost?.media[0]) preloadMedia(nextPost.media[0].url, nextPost.media[0].type);
if (prevPost?.media[0]) preloadMedia(prevPost.media[0].url, prevPost.media[0].type);
// 3. Current post: Delayed preload of the rest
const timeout = setTimeout(() => {
for (let i = 2; i < post.media.length; i++) {
if (controller.signal.aborted) break;
preloadMedia(i);
preloadMedia(post.media[i].url, post.media[i].type);
}
}, 1000);
@@ -67,7 +71,7 @@ export const PostModal: React.FC<PostModalProps> = ({
controller.abort();
clearTimeout(timeout);
};
}, [post.id, post.media]);
}, [post.id, post.media, nextPost?.id, prevPost?.id]);
useEffect(() => setCurrentIndex(0), [post.id]);
useEffect(() => {
+115
View File
@@ -0,0 +1,115 @@
import React, { useState, useEffect, useRef } from 'react';
import { Play, Image as ImageIcon } from 'lucide-react';
import { cn } from '../lib/utils';
import { Post } from '../types';
interface PostThumbnailProps {
post: Post;
className?: string;
thumbnailUrl?: string; // High-res thumbnail from queue
onRequestThumbnail: (id: string, url: string) => void;
}
const videoThumbnailCache = new Map<string, string>();
export const PostThumbnail = ({ post, className, thumbnailUrl, onRequestThumbnail }: PostThumbnailProps) => {
const [videoThumbnail, setVideoThumbnail] = useState<string | null>(videoThumbnailCache.get(post.media[0].url) || null);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const mainMedia = post.media[0];
const isVideo = mainMedia.type === 'video';
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
}, { rootMargin: '400px' }); // Larger margin for smoother scrolling
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isInView) return;
if (isVideo) {
if (videoThumbnail) return;
const video = document.createElement('video');
video.src = `${mainMedia.url}#t=0.1`;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
const captureFrame = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx && video.videoWidth > 0) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
videoThumbnailCache.set(mainMedia.url, dataUrl);
setVideoThumbnail(dataUrl);
}
} catch (err) {} finally { cleanup(); }
};
const handleLoadedMetadata = () => video.currentTime = 0.1;
const handleSeeked = () => captureFrame();
const cleanup = () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('seeked', handleSeeked);
video.removeAttribute('src');
video.load();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('error', cleanup);
const timeout = setTimeout(() => cleanup(), 5000);
return () => { clearTimeout(timeout); cleanup(); };
} else {
// Request high-res image thumbnailing only if size > 1MiB
const ONE_MIB = 1024 * 1024;
if (mainMedia.size && mainMedia.size > ONE_MIB) {
onRequestThumbnail(post.id, mainMedia.url);
}
}
}, [isInView, isVideo, mainMedia.url, mainMedia.size, post.id, onRequestThumbnail, videoThumbnail]);
// Use high-res thumbnail if available, then video thumb, then original (if small enough), then placeholder
const displayUrl = thumbnailUrl || videoThumbnail || (isVideo ? null : post.thumbnail);
if (!displayUrl && isVideo) {
return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
<Play size={20} className="text-gray-300" fill="currentColor" />
</div>
);
}
if (!displayUrl) {
return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-50 flex items-center justify-center text-black", className)}>
<ImageIcon size={20} className="text-gray-200" />
</div>
);
}
return (
<div ref={containerRef} className="w-full h-full">
<img
src={displayUrl}
alt=""
className={cn("w-full h-full object-cover transition-all duration-700", className, !thumbnailUrl && !videoThumbnail ? "blur-sm scale-105" : "blur-0 scale-100")}
referrerPolicy="no-referrer"
loading="lazy"
/>
</div>
);
};
-59
View File
@@ -1,59 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Play } from 'lucide-react';
import { cn } from '../lib/utils';
const thumbnailCache = new Map<string, string>();
export const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => {
const [thumbnail, setThumbnail] = useState<string | null>(thumbnailCache.get(url) || null);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (thumbnail || !containerRef.current) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); }
}, { rootMargin: '200px' });
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [thumbnail]);
useEffect(() => {
if (thumbnail || !isInView) return;
const video = document.createElement('video');
video.src = `${url}#t=0.1`; video.preload = 'metadata'; video.muted = true; video.playsInline = true;
const captureFrame = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx && video.videoWidth > 0) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
thumbnailCache.set(url, dataUrl); setThumbnail(dataUrl);
}
} catch (err) {} finally { cleanup(); }
};
const handleLoadedMetadata = () => video.currentTime = 0.1;
const handleSeeked = () => captureFrame();
const cleanup = () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('seeked', handleSeeked);
video.removeAttribute('src');
video.load();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('error', cleanup);
const timeout = setTimeout(() => { if (!thumbnailCache.has(url)) cleanup(); }, 5000);
return () => { clearTimeout(timeout); cleanup(); };
}, [url, thumbnail, isInView]);
if (!thumbnail) return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
<Play size={20} className="text-gray-300" fill="currentColor" />
</div>
);
return <img src={thumbnail} alt="" className={cn("w-full h-full object-cover transition-transform duration-500 group-hover:scale-110", className)} referrerPolicy="no-referrer" />;
};