feat(image viewer): add LRU cache for heic converted images (#2601)

This commit is contained in:
Aaron Liu 2025-07-05 10:22:55 +08:00
parent 38f5114426
commit 70931462f2
2 changed files with 89 additions and 18 deletions

View File

@ -6,6 +6,7 @@ import { getFileEntityUrl, getFileInfo } from "../../../../api/api.ts";
import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { getFileLinkedUri } from "../../../../util";
import { LRUCache } from "../../../../util/lru.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import useMountedRef from "./hooks/useMountedRef";
import "./Photo.less";
@ -28,6 +29,9 @@ export interface IPhotoProps extends React.HTMLAttributes<HTMLElement> {
brokenElement?: JSX.Element | ((photoProps: BrokenElementParams) => JSX.Element);
}
// Global LRU cache for HEIC conversions (capacity: 50 images)
const heicConversionCache = new LRUCache<string, string>(50);
export default function Photo({
file,
version,
@ -50,8 +54,14 @@ export default function Photo({
return extension === "heic" || extension === "heif";
};
// Helper function to convert HEIC to PNG
const convertHeicToPng = async (imageUrl: string): Promise<string> => {
// Helper function to convert HEIC to JPG with caching
const convertHeicToJpg = async (imageUrl: string, cacheKey: string): Promise<string> => {
// Check cache first
const cachedUrl = heicConversionCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
try {
// Fetch the image as blob
const response = await fetch(imageUrl);
@ -64,21 +74,27 @@ export default function Photo({
const isHeicBlob = await isHeic(file);
if (isHeicBlob) {
// Convert HEIC to PNG
const pngBlob = await heicTo({
// Convert HEIC to JPG
const jpgBlob = await heicTo({
blob: blob,
type: "image/png",
quality: 0.9,
type: "image/jpeg",
quality: 1,
});
// Create object URL for the converted image
return URL.createObjectURL(pngBlob);
const convertedUrl = URL.createObjectURL(jpgBlob);
// Cache the converted URL
heicConversionCache.set(cacheKey, convertedUrl);
return convertedUrl;
} else {
// If not HEIC, return original URL
// If not HEIC, cache and return original URL
heicConversionCache.set(cacheKey, imageUrl);
return imageUrl;
}
} catch (error) {
console.error("Error converting HEIC to PNG:", error);
console.error("Error converting HEIC to JPG:", error);
throw error;
}
};
@ -92,11 +108,12 @@ export default function Photo({
)
.then(async (res) => {
const originalUrl = res.urls[0].url;
const cacheKey = `${file.id}-${version || "default"}`;
// Check if the file is HEIC/HEIF and convert if needed
if (isHeicFile(file.name)) {
try {
const convertedUrl = await convertHeicToPng(originalUrl);
const convertedUrl = await convertHeicToJpg(originalUrl, cacheKey);
setImageSrc(convertedUrl);
if (file.metadata?.[Metadata.live_photo]) {
loadLivePhoto(file, convertedUrl);
@ -189,21 +206,23 @@ export default function Photo({
useEffect(() => {
return () => {
if (imageSrc && imageSrc.startsWith("blob:")) {
URL.revokeObjectURL(imageSrc);
// Don't revoke cached URLs, let the cache handle cleanup
// URL.revokeObjectURL(imageSrc);
}
};
}, [imageSrc]);
const {
onMouseDown,
onTouchStart,
style: { width, height, ...restStyle },
...rest
} = restProps;
const { onMouseDown, onTouchStart, style, ...rest } = restProps;
// Extract width and height from style if available
const { width, height, ...restStyle } = style || {};
useEffect(() => {
if (playerRef.current) {
playerRef.current.updateSize(width, height);
// Convert width and height to numbers, defaulting to 0 if not valid
const numWidth = typeof width === "number" ? width : 0;
const numHeight = typeof height === "number" ? height : 0;
playerRef.current.updateSize(numWidth, numHeight);
}
}, [width, height]);

52
src/util/lru.ts Normal file
View File

@ -0,0 +1,52 @@
// LRU Cache implementation for HEIC image conversion
export class LRUCache<K, V> {
private capacity: number;
private cache: Map<K, V>;
constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map();
}
get(key: K): V | undefined {
if (this.cache.has(key)) {
// Move to end (most recently used)
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
return undefined;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
// Update existing key
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
const firstValue = this.cache.get(firstKey);
// Clean up blob URL if it exists
if (typeof firstValue === "string" && firstValue.startsWith("blob:")) {
URL.revokeObjectURL(firstValue);
}
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
clear(): void {
// Clean up all blob URLs
for (const value of this.cache.values()) {
if (typeof value === "string" && value.startsWith("blob:")) {
URL.revokeObjectURL(value);
}
}
this.cache.clear();
}
}