Files
Starlight_Lancher/apps/app-frontend/src/composables/use-image-thumbnail.ts
T
AxTps b5406c2da0
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
feat: 移植截图中心(浏览/分组/编辑/导出)
2026-10-01 22:45:02 +08:00

154 lines
3.7 KiB
TypeScript

import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}