Cache compressed image previews

This commit is contained in:
Codex
2026-05-13 05:25:36 +00:00
parent 813ffbfa8b
commit a2b2fb82ba
4 changed files with 277 additions and 23 deletions

View File

@@ -0,0 +1,249 @@
'use client';
import { useEffect, useMemo, useState, type CSSProperties, type ReactEventHandler } from 'react';
type CachedPreviewImageProps = {
src: string;
alt: string;
className?: string;
style?: CSSProperties;
badgeClassName?: string;
onDoubleClick?: () => void;
onClick?: () => void;
onLoad?: ReactEventHandler<HTMLImageElement>;
};
type CachedPreview = {
blob: Blob;
width: number;
height: number;
updatedAt: number;
};
const DB_NAME = 'miaojing-preview-cache';
const STORE_NAME = 'image-previews';
const DB_VERSION = 1;
const MAX_PREVIEW_EDGE = 720;
const PREVIEW_QUALITY = 0.72;
const MAX_CACHE_ITEMS = 180;
let dbPromise: Promise<IDBDatabase> | null = null;
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
function getAspectLabel(width: number, height: number) {
if (!width || !height) return '';
const divisor = gcd(width, height);
const ratioWidth = Math.round(width / divisor);
const ratioHeight = Math.round(height / divisor);
if (ratioWidth > 60 || ratioHeight > 60) {
const decimal = width / height;
if (decimal >= 0.98 && decimal <= 1.02) return '1:1';
if (decimal > 1) return `${decimal.toFixed(2)}:1`;
return `1:${(height / width).toFixed(2)}`;
}
return `${ratioWidth}:${ratioHeight}`;
}
function openPreviewDb(): Promise<IDBDatabase> {
if (typeof indexedDB === 'undefined') return Promise.reject(new Error('IndexedDB unavailable'));
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME);
store.createIndex('updatedAt', 'updatedAt');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Open preview cache failed'));
});
}
return dbPromise;
}
async function getCachedPreview(key: string): Promise<CachedPreview | null> {
const db = await openPreviewDb();
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(key);
request.onsuccess = () => resolve((request.result as CachedPreview | undefined) || null);
request.onerror = () => resolve(null);
});
}
async function pruneCache(db: IDBDatabase) {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const keysRequest = store.getAllKeys();
const valuesRequest = store.getAll();
await new Promise<void>((resolve) => {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
tx.onabort = () => resolve();
});
const keys = keysRequest.result || [];
const values = (valuesRequest.result || []) as CachedPreview[];
if (keys.length <= MAX_CACHE_ITEMS) return;
const entries = keys.map((key, index) => ({ key, updatedAt: values[index]?.updatedAt || 0 }))
.sort((a, b) => a.updatedAt - b.updatedAt)
.slice(0, keys.length - MAX_CACHE_ITEMS);
const deleteTx = db.transaction(STORE_NAME, 'readwrite');
const deleteStore = deleteTx.objectStore(STORE_NAME);
entries.forEach(entry => deleteStore.delete(entry.key));
}
async function setCachedPreview(key: string, value: CachedPreview) {
const db = await openPreviewDb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
tx.onabort = () => resolve();
});
void pruneCache(db).catch(() => undefined);
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('图片预览加载失败'));
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise(resolve => canvas.toBlob(resolve, 'image/webp', PREVIEW_QUALITY));
}
async function createPreview(src: string): Promise<CachedPreview> {
const image = await loadImage(src);
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error('无法读取图片尺寸');
const scale = Math.min(1, MAX_PREVIEW_EDGE / Math.max(width, height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('浏览器不支持预览图生成');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const blob = await canvasToBlob(canvas);
if (!blob) throw new Error('预览图生成失败');
return { blob, width, height, updatedAt: Date.now() };
}
export function CachedPreviewImage({
src,
alt,
className = '',
style,
badgeClassName = 'absolute right-2 top-2 z-10',
onDoubleClick,
onClick,
onLoad,
}: CachedPreviewImageProps) {
const [previewUrl, setPreviewUrl] = useState('');
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
if (!src) {
setPreviewUrl('');
setSize(null);
return;
}
let cancelled = false;
let objectUrl = '';
setSize(null);
const usePreview = (preview: CachedPreview) => {
if (cancelled) return;
setSize({ width: preview.width, height: preview.height });
const blob = preview.blob;
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
};
getCachedPreview(src)
.then(cached => {
if (cancelled) return;
if (cached?.blob) {
usePreview(cached);
return;
}
return createPreview(src).then(created => {
if (cancelled) return;
usePreview(created);
void setCachedPreview(src, created).catch(() => undefined);
});
})
.catch(() => {
if (!cancelled) setPreviewUrl('');
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [src]);
const displaySrc = useMemo(() => previewUrl || src, [previewUrl, src]);
const metadataLabel = useMemo(() => {
if (!size) return '';
return `${getAspectLabel(size.width, size.height)} · ${size.width}×${size.height}`;
}, [size]);
const handleLoad: ReactEventHandler<HTMLImageElement> = (event) => {
if (!size && event.currentTarget.naturalWidth > 0 && event.currentTarget.naturalHeight > 0) {
setSize({
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
});
}
onLoad?.(event);
};
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={displaySrc}
alt={alt}
className={className}
style={style}
onClick={onClick}
onDoubleClick={onDoubleClick}
onLoad={handleLoad}
loading="lazy"
decoding="async"
/>
{metadataLabel && (
<div
className={`rounded-full border border-white/24 bg-black/48 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_10px_32px_rgba(0,0,0,0.35)] backdrop-blur-md light:border-amber-900/18 light:bg-white/60 light:text-foreground light:shadow-[0_10px_32px_rgba(83,61,27,0.14)] ${badgeClassName}`}
>
{metadataLabel}
</div>
)}
</>
);
}

View File

@@ -47,6 +47,7 @@ import { ImageCountCombobox } from '@/components/create/image-count-combobox';
import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
@@ -752,15 +753,14 @@ export function ImageToImagePanel() {
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" /></div>
<div className="grid grid-cols-2 gap-3">
{results.map((url, i) => (
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onDoubleClick={() => setLightboxSrc(url)}
/>
{results.map((url, i) => (
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
<CachedPreviewImage
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onDoubleClick={() => setLightboxSrc(url)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button size="sm" variant="secondary" className="gap-1" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" /></Button>
@@ -793,10 +793,14 @@ export function ImageToImagePanel() {
>
{isPlaceholder(record.url) ? (
<div className="w-full aspect-square flex items-center justify-center"><ImageIcon className="h-6 w-6 text-muted-foreground/30" /></div>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={record.url} alt={record.prompt?.slice(0, 20) || '历史记录'} className="w-full aspect-square object-cover" />
)}
) : (
<CachedPreviewImage
src={record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="w-full aspect-square object-cover"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end p-1.5 opacity-0 group-hover:opacity-100">
<p className="text-xs text-white line-clamp-2">{record.prompt}</p>
</div>

View File

@@ -45,6 +45,7 @@ import { ImageCountCombobox } from '@/components/create/image-count-combobox';
import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
@@ -623,8 +624,7 @@ export function TextToImagePanel() {
<div className="grid grid-cols-2 gap-3">
{results.map((url, i) => (
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<CachedPreviewImage
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
@@ -664,8 +664,12 @@ export function TextToImagePanel() {
{isPlaceholder(record.url) ? (
<div className="w-full aspect-square flex items-center justify-center"><ImageIcon className="h-6 w-6 text-muted-foreground/30" /></div>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={record.url} alt={record.prompt?.slice(0, 20) || '历史记录'} className="w-full aspect-square object-cover" />
<CachedPreviewImage
src={record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="w-full aspect-square object-cover"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end p-1.5 opacity-0 group-hover:opacity-100">
<p className="text-xs text-white line-clamp-2">{record.prompt}</p>

View File

@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCircle2, Maximize2 } from 'lucide-react';
import { toast } from 'sonner';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
interface CreationDetailDialogProps {
record: CreationRecord | null;
@@ -447,11 +447,11 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
<p className="text-sm"></p>
</div>
) : record.type === 'image' ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
<CachedPreviewImage
src={record.url}
alt={record.prompt}
className="h-full w-full object-cover cursor-zoom-in"
badgeClassName="absolute right-3 top-3 z-20"
style={previewImageStyle}
onLoad={(event) => {
const img = event.currentTarget;
@@ -477,9 +477,6 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
/>
)}
{/* Fullscreen button */}
{!isPlaceholderUrl && record.type === 'image' && (
<ImageMetadataBadge src={record.url} className="absolute right-3 top-3 z-20" />
)}
{!isPlaceholderUrl && record.type === 'image' && (
<button
onClick={() => setFullscreenSrc(record.url)}