mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-06-20 23:24:11 +08:00
✨ Feature(custom): support jxl image preview in gallery
ISSUES CLOSED: #531
This commit is contained in:
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -6,7 +6,6 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"EditorConfig.EditorConfig",
|
||||
"lokalise.i18n-ally",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer"
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"got": "^14.6.6",
|
||||
"hpagent": "^1.2.0",
|
||||
"i18next": "^26.3.1",
|
||||
"jxl-oxide-wasm": "0.12.6",
|
||||
"lodash-es": "^4.18.1",
|
||||
"marked": "^18.0.5",
|
||||
"mime": "^4.1.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { clipboard } from 'electron'
|
||||
|
||||
import { RPCRouter } from '~/events/rpc/router'
|
||||
import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum'
|
||||
import { convertJxlSourceToPngDataUrl } from '~/utils/jxlPreview'
|
||||
import pasteTemplate from '~/utils/pasteTemplate'
|
||||
import { runScriptInStage } from '~/utils/runScript'
|
||||
interface IFilter {
|
||||
@@ -49,6 +50,17 @@ const galleryRoutes = [
|
||||
await runScriptInStage('onGalleryRemove', picgo, { galleryItem: args[0] })
|
||||
},
|
||||
},
|
||||
{
|
||||
action: IRPCActionType.GALLERY_GET_JXL_PREVIEW,
|
||||
handler: async (_: IIPCEvent, args: [source: string, isKnownJxl?: boolean]) => {
|
||||
try {
|
||||
return await convertJxlSourceToPngDataUrl(args[0], args[1])
|
||||
} catch (_e) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
type: IRPCType.INVOKE,
|
||||
},
|
||||
{
|
||||
action: IRPCActionType.GALLERY_GET_DB,
|
||||
handler: async (_: IIPCEvent, args: [filter: IFilter]) => {
|
||||
|
||||
@@ -206,6 +206,7 @@ export const IRPCActionType = {
|
||||
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
||||
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
||||
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
||||
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
|
||||
|
||||
// plugin rpc
|
||||
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
||||
|
||||
74
src/main/utils/jxlPreview.ts
Normal file
74
src/main/utils/jxlPreview.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { initSync, JxlImage } from 'jxl-oxide-wasm'
|
||||
|
||||
let isJxlDecoderInitialized = false
|
||||
|
||||
function isHttpSource(source: string): boolean {
|
||||
return /^https?:\/\//i.test(source)
|
||||
}
|
||||
|
||||
function isInlineSource(source: string): boolean {
|
||||
return /^(data:|blob:)/i.test(source)
|
||||
}
|
||||
|
||||
function isJxlSource(source: string): boolean {
|
||||
const sourcePath = isHttpSource(source) ? new URL(source).pathname : source
|
||||
return path.extname(sourcePath.split(/[?#]/, 1)[0]).toLowerCase() === '.jxl'
|
||||
}
|
||||
|
||||
function normalizeLocalFilePath(source: string): string {
|
||||
if (source.startsWith('file://')) {
|
||||
return fileURLToPath(source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
function initJxlDecoder() {
|
||||
if (isJxlDecoderInitialized) return
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJsonPath = require.resolve('jxl-oxide-wasm/package.json')
|
||||
const wasmPath = path.join(path.dirname(packageJsonPath), 'jxl_oxide_wasm_bg.wasm')
|
||||
const wasmBytes = readFileSync(wasmPath)
|
||||
initSync({ module: wasmBytes })
|
||||
isJxlDecoderInitialized = true
|
||||
}
|
||||
|
||||
async function readJxlSource(source: string): Promise<Buffer> {
|
||||
if (isHttpSource(source)) {
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed with status ${response.status}`)
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
return await readFile(normalizeLocalFilePath(source))
|
||||
}
|
||||
|
||||
export async function convertJxlSourceToPngDataUrl(source: string, isKnownJxl = false): Promise<string | undefined> {
|
||||
if (!source || isInlineSource(source) || (!isKnownJxl && !isJxlSource(source))) return undefined
|
||||
|
||||
const fileBytes = await readJxlSource(source)
|
||||
initJxlDecoder()
|
||||
|
||||
const image = new JxlImage()
|
||||
try {
|
||||
image.feedBytes(fileBytes)
|
||||
image.forceSrgb = true
|
||||
|
||||
if (!image.tryInit() || !image.loaded) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pngBytes = image.render().encodeToPng()
|
||||
return `data:image/png;base64,${Buffer.from(pngBytes).toString('base64')}`
|
||||
} finally {
|
||||
image.free()
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useVirtualGrid } from '@/hooks/useVirtualGrid'
|
||||
|
||||
@@ -51,6 +51,8 @@ const {
|
||||
viewMode?: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<(e: 'visibleIndexesChange', indexes: number[]) => void>()
|
||||
|
||||
const containerRef = useTemplateRef('containerRef')
|
||||
const containerHeight = ref(0)
|
||||
const containerWidth = ref<number>(0)
|
||||
@@ -99,6 +101,14 @@ const viewportStyle = computed(() => {
|
||||
|
||||
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${itemHeight}px` }))
|
||||
|
||||
watch(
|
||||
visibleIndexes,
|
||||
indexes => {
|
||||
emit('visibleIndexesChange', indexes)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
function handleScroll() {
|
||||
const c = containerRef.value
|
||||
if (!c) return
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
:item-height="300"
|
||||
:grid-breakpoints="effectiveGridBreakpoints"
|
||||
key-field="key"
|
||||
@visible-indexes-change="handleVisibleIndexesChange"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div
|
||||
@@ -221,17 +222,11 @@
|
||||
@click.stop="zoomImage(index)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
imageErrorStates[item.key || '']
|
||||
? './errorLoading.png'
|
||||
: isAlwaysForceReload
|
||||
? addCacheBustParam(item.src)
|
||||
: item.src
|
||||
"
|
||||
:src="displayImageSources[item.key || ''] || item.src"
|
||||
class="h-full w-full object-contain transition-all duration-fast ease-apple"
|
||||
:class="{ loading: !imageLoadStates[item.key || ''] }"
|
||||
@load="onImageLoad(item.key || '')"
|
||||
@error="onImageError(item.key || '')"
|
||||
@load="onImageLoad(item)"
|
||||
@error="onImageError(item)"
|
||||
/>
|
||||
<div
|
||||
v-if="!imageLoadStates[item.key || '']"
|
||||
@@ -293,7 +288,7 @@
|
||||
<!-- Custom Image Preview Modal -->
|
||||
<ImagePreview
|
||||
v-model:gallery-slider-control="gallerySliderControl"
|
||||
:filter-list="filterList"
|
||||
:filter-list="previewFilterList"
|
||||
:is-always-force-reload="isAlwaysForceReload"
|
||||
/>
|
||||
|
||||
@@ -436,6 +431,7 @@ import { configPaths } from '@/utils/configPaths'
|
||||
import { getConfig, saveConfig } from '@/utils/dataSender'
|
||||
import $$db from '@/utils/db'
|
||||
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
|
||||
import { getGalleryPreviewSource, getJxlPreviewSource } from '@/utils/galleryPreview'
|
||||
import { picBedsCanbeDeleted } from '@/utils/static'
|
||||
|
||||
type IResult<T> = T & {
|
||||
@@ -493,6 +489,16 @@ const currentSortField = ref<'name' | 'time' | 'ext' | 'check'>('name')
|
||||
const userGridColumns = useStorage<number>('galleryGridColumns', 4)
|
||||
const imageLoadStates = reactive<Record<string, boolean>>({})
|
||||
const imageErrorStates = reactive<Record<string, boolean>>({})
|
||||
const displayImageSources = reactive<Record<string, string>>({})
|
||||
const jxlPreviewCache = reactive<Record<string, string>>({})
|
||||
const jxlPreviewLoading = reactive<Record<string, boolean>>({})
|
||||
const jxlPreviewErrors = reactive<Record<string, boolean>>({})
|
||||
const jxlPreviewCacheOrder: string[] = []
|
||||
const cacheBustToken = ref(Date.now())
|
||||
const visibleGalleryIndexes = ref<number[]>([])
|
||||
let jxlPreviewGeneration = 0
|
||||
|
||||
const JXL_PREVIEW_CACHE_LIMIT = 64
|
||||
|
||||
const pasteStyleList = ['markdown', 'HTML', 'URL', 'UBB', 'Custom']
|
||||
const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')]
|
||||
@@ -556,6 +562,17 @@ const filterList = computed(() => {
|
||||
return getGallery()
|
||||
})
|
||||
|
||||
const previewFilterList = computed(() => {
|
||||
if (!gallerySliderControl.value.visible) {
|
||||
return filterList.value
|
||||
}
|
||||
|
||||
const currentIndex = gallerySliderControl.value.index
|
||||
return filterList.value.map((item, index) =>
|
||||
index === currentIndex ? { ...item, src: buildDisplayImageSrc(item) } : item,
|
||||
)
|
||||
})
|
||||
|
||||
const matchedUrls = computed(() => {
|
||||
const matches = filterList.value.filter((item: any) => {
|
||||
return customStrMatch(item.imgUrl, batchRenameMatch.value)
|
||||
@@ -597,8 +614,28 @@ watch(useShortUrl, newVal => {
|
||||
saveConfig(configPaths.settings.useShortUrl, newVal === t('pages.gallery.shortUrl'))
|
||||
})
|
||||
|
||||
watch(filterList, () => {
|
||||
watch(filterList, items => {
|
||||
clearChoosedList()
|
||||
pruneDisplayImageSources(items)
|
||||
pruneJxlPreviewState(items)
|
||||
nextTick(() => {
|
||||
syncVisibleDisplayImageSources()
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [gallerySliderControl.value.visible, gallerySliderControl.value.index] as const,
|
||||
([visible, index]) => {
|
||||
if (visible) {
|
||||
ensureJxlPreview(filterList.value[index])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(isAlwaysForceReload, () => {
|
||||
cacheBustToken.value = Date.now()
|
||||
invalidateJxlPreviewCache()
|
||||
syncVisibleDisplayImageSources()
|
||||
})
|
||||
|
||||
watch(userGridColumns, _ => {
|
||||
@@ -629,13 +666,23 @@ watch(searchTextURL, newVal => {
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function onImageLoad(id: string) {
|
||||
function onImageLoad(item: IGalleryItem) {
|
||||
const id = item.key || ''
|
||||
imageLoadStates[id] = true
|
||||
if (getJxlPreviewSource(item)) {
|
||||
updateDisplayImageSource(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onImageError(id: string) {
|
||||
function onImageError(item: IGalleryItem) {
|
||||
const id = item.key || ''
|
||||
imageLoadStates[id] = false
|
||||
if (ensureJxlPreview(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
imageErrorStates[id] = true
|
||||
updateDisplayImageSource(item)
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
@@ -689,7 +736,7 @@ const addCacheBustParam = (url: string | undefined) => {
|
||||
}
|
||||
try {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}cbplist=${new Date().getTime()}`
|
||||
return `${url}${separator}cbplist=${cacheBustToken.value}`
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
@@ -699,6 +746,186 @@ function formatFileName(name: string) {
|
||||
return window.node.path.basename(name)
|
||||
}
|
||||
|
||||
function getPreviewSource(item: ImgInfo) {
|
||||
const itemKey = item.key || ''
|
||||
const previewPath = getJxlPreviewSource(item)
|
||||
if (previewPath && jxlPreviewCache[previewPath]) {
|
||||
touchJxlPreviewCache(previewPath)
|
||||
}
|
||||
|
||||
return getGalleryPreviewSource(
|
||||
item,
|
||||
jxlPreviewCache,
|
||||
jxlPreviewLoading,
|
||||
jxlPreviewErrors,
|
||||
itemKey ? imageLoadStates[itemKey] : false,
|
||||
)
|
||||
}
|
||||
|
||||
function buildDisplayImageSrc(item: IGalleryItem) {
|
||||
if (imageErrorStates[item.key || '']) return './errorLoading.png'
|
||||
const src = getJxlPreviewSource(item) ? getPreviewSource(item) : item.src || item.galleryPath || item.imgUrl || ''
|
||||
return isAlwaysForceReload.value ? addCacheBustParam(src) : src
|
||||
}
|
||||
|
||||
function updateDisplayImageSource(item?: IGalleryItem) {
|
||||
if (!item?.key) return
|
||||
if (!filterList.value.some(currentItem => currentItem.key === item.key)) return
|
||||
displayImageSources[item.key] = buildDisplayImageSrc(item)
|
||||
}
|
||||
|
||||
function pruneDisplayImageSources(items: IGalleryItem[] = filterList.value, indexes?: number[]) {
|
||||
const keys = new Set<string>()
|
||||
const sourceItems = indexes ? indexes.map(index => items[index]).filter(Boolean) : items
|
||||
sourceItems.forEach(item => {
|
||||
if (!item.key) return
|
||||
keys.add(item.key)
|
||||
})
|
||||
Object.keys(displayImageSources).forEach(key => {
|
||||
if (!keys.has(key)) {
|
||||
delete displayImageSources[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function syncVisibleDisplayImageSources(indexes: number[] = visibleGalleryIndexes.value) {
|
||||
const visibleItems = filterList.value
|
||||
pruneDisplayImageSources(visibleItems, indexes)
|
||||
indexes.forEach(index => {
|
||||
updateDisplayImageSource(visibleItems[index])
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveJxlPreviewSources(items: ImgInfo[] = filterList.value) {
|
||||
const sources = new Set<string>()
|
||||
items.forEach(item => {
|
||||
const previewPath = getJxlPreviewSource(item)
|
||||
if (previewPath) {
|
||||
sources.add(previewPath)
|
||||
}
|
||||
})
|
||||
return sources
|
||||
}
|
||||
|
||||
function isJxlPreviewSourceActive(previewPath: string) {
|
||||
return getActiveJxlPreviewSources().has(previewPath)
|
||||
}
|
||||
|
||||
function touchJxlPreviewCache(previewPath: string) {
|
||||
const index = jxlPreviewCacheOrder.indexOf(previewPath)
|
||||
if (index >= 0) {
|
||||
jxlPreviewCacheOrder.splice(index, 1)
|
||||
}
|
||||
jxlPreviewCacheOrder.push(previewPath)
|
||||
}
|
||||
|
||||
function deleteJxlPreviewCacheEntry(previewPath: string) {
|
||||
delete jxlPreviewCache[previewPath]
|
||||
const index = jxlPreviewCacheOrder.indexOf(previewPath)
|
||||
if (index >= 0) {
|
||||
jxlPreviewCacheOrder.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function cacheJxlPreview(previewPath: string, previewSrc: string) {
|
||||
jxlPreviewCache[previewPath] = previewSrc
|
||||
touchJxlPreviewCache(previewPath)
|
||||
|
||||
while (jxlPreviewCacheOrder.length > JXL_PREVIEW_CACHE_LIMIT) {
|
||||
const stalePreviewPath = jxlPreviewCacheOrder.shift()
|
||||
if (stalePreviewPath) {
|
||||
delete jxlPreviewCache[stalePreviewPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateJxlPreviewCache() {
|
||||
jxlPreviewGeneration += 1
|
||||
jxlPreviewCacheOrder.length = 0
|
||||
Object.keys(jxlPreviewCache).forEach(key => {
|
||||
delete jxlPreviewCache[key]
|
||||
})
|
||||
Object.keys(jxlPreviewLoading).forEach(key => {
|
||||
delete jxlPreviewLoading[key]
|
||||
})
|
||||
Object.keys(jxlPreviewErrors).forEach(key => {
|
||||
delete jxlPreviewErrors[key]
|
||||
})
|
||||
}
|
||||
|
||||
function pruneJxlPreviewState(items: ImgInfo[] = filterList.value) {
|
||||
const activeSources = getActiveJxlPreviewSources(items)
|
||||
Object.keys(jxlPreviewCache).forEach(previewPath => {
|
||||
if (!activeSources.has(previewPath)) {
|
||||
deleteJxlPreviewCacheEntry(previewPath)
|
||||
}
|
||||
})
|
||||
Object.keys(jxlPreviewLoading).forEach(previewPath => {
|
||||
if (!activeSources.has(previewPath)) {
|
||||
delete jxlPreviewLoading[previewPath]
|
||||
}
|
||||
})
|
||||
Object.keys(jxlPreviewErrors).forEach(previewPath => {
|
||||
if (!activeSources.has(previewPath)) {
|
||||
delete jxlPreviewErrors[previewPath]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleVisibleIndexesChange(indexes: number[]) {
|
||||
visibleGalleryIndexes.value = indexes
|
||||
syncVisibleDisplayImageSources(indexes)
|
||||
}
|
||||
|
||||
function ensureJxlPreview(item?: IGalleryItem): boolean {
|
||||
const previewPath = getJxlPreviewSource(item)
|
||||
if (!previewPath || jxlPreviewErrors[previewPath]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (jxlPreviewCache[previewPath]) {
|
||||
updateDisplayImageSource(item)
|
||||
return true
|
||||
}
|
||||
|
||||
if (jxlPreviewLoading[previewPath]) {
|
||||
return true
|
||||
}
|
||||
|
||||
jxlPreviewLoading[previewPath] = true
|
||||
const previewGeneration = jxlPreviewGeneration
|
||||
const previewRequestSource = isAlwaysForceReload.value ? addCacheBustParam(previewPath) : previewPath
|
||||
updateDisplayImageSource(item)
|
||||
window.electron
|
||||
.triggerRPC<string | undefined>(IRPCActionType.GALLERY_GET_JXL_PREVIEW, previewRequestSource, true)
|
||||
.then(previewSrc => {
|
||||
if (previewGeneration !== jxlPreviewGeneration || !isJxlPreviewSourceActive(previewPath)) {
|
||||
return
|
||||
}
|
||||
if (previewSrc) {
|
||||
cacheJxlPreview(previewPath, previewSrc)
|
||||
delete jxlPreviewErrors[previewPath]
|
||||
} else {
|
||||
jxlPreviewErrors[previewPath] = true
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (previewGeneration !== jxlPreviewGeneration || !isJxlPreviewSourceActive(previewPath)) {
|
||||
return
|
||||
}
|
||||
jxlPreviewErrors[previewPath] = true
|
||||
})
|
||||
.finally(() => {
|
||||
if (previewGeneration !== jxlPreviewGeneration) {
|
||||
return
|
||||
}
|
||||
delete jxlPreviewLoading[previewPath]
|
||||
updateDisplayImageSource(item)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getGallery(): IGalleryItem[] {
|
||||
if (
|
||||
debouncedSearchText.value ||
|
||||
@@ -760,8 +987,17 @@ async function updateGallery() {
|
||||
Object.keys(imageErrorStates).forEach(k => {
|
||||
if (!newIds.has(k)) delete imageErrorStates[k]
|
||||
})
|
||||
Object.keys(displayImageSources).forEach(k => {
|
||||
if (!newIds.has(k)) delete displayImageSources[k]
|
||||
})
|
||||
if (isAlwaysForceReload.value) {
|
||||
cacheBustToken.value = Date.now()
|
||||
invalidateJxlPreviewCache()
|
||||
}
|
||||
images.value = newList
|
||||
nextTick(() => {
|
||||
pruneJxlPreviewState()
|
||||
syncVisibleDisplayImageSources()
|
||||
if (virtualScrollerRef.value) {
|
||||
virtualScrollerRef.value.refresh()
|
||||
}
|
||||
@@ -806,6 +1042,7 @@ function clearChoosedList() {
|
||||
}
|
||||
|
||||
function zoomImage(index: number) {
|
||||
ensureJxlPreview(filterList.value[index])
|
||||
gallerySliderControl.value.index = index
|
||||
gallerySliderControl.value.visible = true
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ export const IRPCActionType = {
|
||||
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
||||
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
||||
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
||||
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
|
||||
|
||||
// plugin rpc
|
||||
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
||||
|
||||
79
src/renderer/utils/galleryPreview.ts
Normal file
79
src/renderer/utils/galleryPreview.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
type GalleryPreviewItem = Pick<ImgInfo, 'extname' | 'fileName' | 'imgUrl'> & {
|
||||
galleryPath?: string
|
||||
}
|
||||
|
||||
export const JXL_LOADING_PREVIEW_SRC = './loading.jpg'
|
||||
export const JXL_ERROR_PREVIEW_SRC = './errorLoading.png'
|
||||
|
||||
function stripUrlSuffix(value: string): string {
|
||||
return value.split(/[?#]/, 1)[0]
|
||||
}
|
||||
|
||||
function getSourceExtension(value?: string): string {
|
||||
if (!value) return ''
|
||||
const match = stripUrlSuffix(value).match(/\.([a-z0-9]+)$/i)
|
||||
return match ? `.${match[1].toLowerCase()}` : ''
|
||||
}
|
||||
|
||||
function hasJxlExtension(value?: string): boolean {
|
||||
return getSourceExtension(value) === '.jxl'
|
||||
}
|
||||
|
||||
function hasNonJxlExtension(value?: string): boolean {
|
||||
const extension = getSourceExtension(value)
|
||||
return extension !== '' && extension !== '.jxl'
|
||||
}
|
||||
|
||||
function normalizeExtname(extname?: string): string {
|
||||
if (!extname) return ''
|
||||
const normalized = extname.toLowerCase()
|
||||
return normalized.startsWith('.') ? normalized : `.${normalized}`
|
||||
}
|
||||
|
||||
function isInlineImageSource(value: string): boolean {
|
||||
return /^(data:|blob:)/i.test(value)
|
||||
}
|
||||
|
||||
export function getJxlPreviewSource(item?: GalleryPreviewItem): string {
|
||||
const hasJxlMetadata = hasJxlExtension(item?.fileName) || normalizeExtname(item?.extname) === '.jxl'
|
||||
const previewSources = [item?.galleryPath, item?.imgUrl].filter(Boolean) as string[]
|
||||
const usableSources = previewSources.filter(source => !isInlineImageSource(source))
|
||||
const explicitJxlSource = usableSources.find(source => hasJxlExtension(source))
|
||||
|
||||
if (explicitJxlSource) return explicitJxlSource
|
||||
if (!hasJxlMetadata) return ''
|
||||
|
||||
return (
|
||||
[item?.imgUrl, item?.galleryPath].find(
|
||||
source => source && !isInlineImageSource(source) && !hasNonJxlExtension(source),
|
||||
) || ''
|
||||
)
|
||||
}
|
||||
|
||||
function getNativeFallbackSource(item: GalleryPreviewItem, jxlPreviewSource: string): string {
|
||||
const fallbackSrc = item.galleryPath || item.imgUrl || ''
|
||||
if (!fallbackSrc || fallbackSrc === jxlPreviewSource || hasJxlExtension(fallbackSrc)) return ''
|
||||
return fallbackSrc
|
||||
}
|
||||
|
||||
export function getGalleryPreviewSource(
|
||||
item: GalleryPreviewItem,
|
||||
jxlPreviews: Record<string, string>,
|
||||
jxlPreviewLoading: Record<string, boolean> = {},
|
||||
jxlPreviewErrors: Record<string, boolean> = {},
|
||||
preferNativeFallbackOnError = false,
|
||||
): string {
|
||||
const fallbackSrc = item.galleryPath || item.imgUrl || ''
|
||||
const jxlPreviewSource = getJxlPreviewSource(item)
|
||||
if (!jxlPreviewSource) return fallbackSrc
|
||||
const nativeFallbackSrc = getNativeFallbackSource(item, jxlPreviewSource)
|
||||
|
||||
if (jxlPreviews[jxlPreviewSource]) return jxlPreviews[jxlPreviewSource]
|
||||
if (jxlPreviewErrors[jxlPreviewSource]) {
|
||||
if (preferNativeFallbackOnError) return fallbackSrc
|
||||
return nativeFallbackSrc || JXL_ERROR_PREVIEW_SRC
|
||||
}
|
||||
if (jxlPreviewLoading[jxlPreviewSource]) return nativeFallbackSrc || JXL_LOADING_PREVIEW_SRC
|
||||
|
||||
return fallbackSrc
|
||||
}
|
||||
71
tests/gallery-preview.test.ts
Normal file
71
tests/gallery-preview.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getGalleryPreviewSource,
|
||||
getJxlPreviewSource,
|
||||
JXL_ERROR_PREVIEW_SRC,
|
||||
JXL_LOADING_PREVIEW_SRC,
|
||||
} from '../src/renderer/utils/galleryPreview'
|
||||
|
||||
describe('gallery JXL preview helpers', () => {
|
||||
it('detects local JPEG XL files from galleryPath, fileName, or extname', () => {
|
||||
expect(getJxlPreviewSource({ galleryPath: 'C:\\images\\photo.jxl' })).toBe('C:\\images\\photo.jxl')
|
||||
expect(getJxlPreviewSource({ galleryPath: '/tmp/photo', fileName: 'photo.JXL' })).toBe('/tmp/photo')
|
||||
expect(getJxlPreviewSource({ galleryPath: '/tmp/photo', extname: 'jxl' })).toBe('/tmp/photo')
|
||||
})
|
||||
|
||||
it('detects remote JPEG XL URLs but ignores inline image sources', () => {
|
||||
expect(getJxlPreviewSource({ imgUrl: 'https://example.com/photo.jxl' })).toBe('https://example.com/photo.jxl')
|
||||
expect(getJxlPreviewSource({ galleryPath: 'data:image/jxl;base64,abc', extname: '.jxl' })).toBe('')
|
||||
})
|
||||
|
||||
it('does not decode a non-JXL galleryPath just because metadata says JXL', () => {
|
||||
expect(
|
||||
getJxlPreviewSource({
|
||||
galleryPath: '/tmp/original.png',
|
||||
imgUrl: 'https://example.com/uploaded',
|
||||
extname: '.jxl',
|
||||
}),
|
||||
).toBe('https://example.com/uploaded')
|
||||
|
||||
expect(
|
||||
getJxlPreviewSource({
|
||||
galleryPath: '/tmp/original.png',
|
||||
extname: '.jxl',
|
||||
}),
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('uses cached, loading, and error preview sources for local JXL images', () => {
|
||||
const item = { galleryPath: '/tmp/photo.jxl', imgUrl: 'https://example.com/photo.jxl' }
|
||||
|
||||
expect(getGalleryPreviewSource(item, { '/tmp/photo.jxl': 'data:image/png;base64,abc' })).toBe(
|
||||
'data:image/png;base64,abc',
|
||||
)
|
||||
expect(getGalleryPreviewSource(item, {}, { '/tmp/photo.jxl': true })).toBe(JXL_LOADING_PREVIEW_SRC)
|
||||
expect(getGalleryPreviewSource(item, {}, {}, { '/tmp/photo.jxl': true })).toBe(JXL_ERROR_PREVIEW_SRC)
|
||||
})
|
||||
|
||||
it('can keep the original JXL source after decoder failure when native rendering already worked', () => {
|
||||
const item = { galleryPath: '/tmp/photo.jxl', imgUrl: 'https://example.com/photo.jxl' }
|
||||
|
||||
expect(getGalleryPreviewSource(item, {}, {}, { '/tmp/photo.jxl': true }, true)).toBe('/tmp/photo.jxl')
|
||||
})
|
||||
|
||||
it('keeps a renderable gallery fallback while a separate JXL source is loading or failed', () => {
|
||||
const item = {
|
||||
galleryPath: '/tmp/original.png',
|
||||
imgUrl: 'https://example.com/photo.jxl',
|
||||
extname: '.jxl',
|
||||
}
|
||||
|
||||
expect(getGalleryPreviewSource(item, {}, { 'https://example.com/photo.jxl': true })).toBe('/tmp/original.png')
|
||||
expect(getGalleryPreviewSource(item, {}, {}, { 'https://example.com/photo.jxl': true })).toBe('/tmp/original.png')
|
||||
})
|
||||
|
||||
it('falls back to the original source for non-JXL images', () => {
|
||||
expect(
|
||||
getGalleryPreviewSource({ galleryPath: '/tmp/photo.png', imgUrl: 'https://example.com/photo.png' }, {}),
|
||||
).toBe('/tmp/photo.png')
|
||||
})
|
||||
})
|
||||
@@ -9107,6 +9107,11 @@ jszip@^3.1.0:
|
||||
readable-stream "~2.3.6"
|
||||
setimmediate "^1.0.5"
|
||||
|
||||
jxl-oxide-wasm@0.12.6:
|
||||
version "0.12.6"
|
||||
resolved "https://registry.yarnpkg.com/jxl-oxide-wasm/-/jxl-oxide-wasm-0.12.6.tgz#a5096bb51fb7c2a7dfac69576c7dae3a94c488ca"
|
||||
integrity sha512-nZlPNSJidFAjkTqNcxxqdac/DxY0oNqr/Q98xOb3UutQyvopHjyubnW7KCjvwFuAr3Lew6xRe+7/zZ8tkbFxug==
|
||||
|
||||
keycode@2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
|
||||
|
||||
Reference in New Issue
Block a user