Cache compressed image previews
This commit is contained in:
249
src/components/create/cached-preview-image.tsx
Normal file
249
src/components/create/cached-preview-image.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user