mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-08-30 04:26:46 +08:00
✨ Feature(custom): support jxl image preview in gallery
ISSUES CLOSED: #531
This commit is contained in:
Vendored
+1
-2
@@ -6,7 +6,6 @@
|
|||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"EditorConfig.EditorConfig",
|
"EditorConfig.EditorConfig",
|
||||||
"lokalise.i18n-ally",
|
"lokalise.i18n-ally",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss"
|
||||||
"vitest.explorer"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"got": "^14.6.6",
|
"got": "^14.6.6",
|
||||||
"hpagent": "^1.2.0",
|
"hpagent": "^1.2.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
|
"jxl-oxide-wasm": "0.12.6",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { clipboard } from 'electron'
|
|||||||
|
|
||||||
import { RPCRouter } from '~/events/rpc/router'
|
import { RPCRouter } from '~/events/rpc/router'
|
||||||
import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum'
|
import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum'
|
||||||
|
import { convertJxlSourceToPngDataUrl } from '~/utils/jxlPreview'
|
||||||
import pasteTemplate from '~/utils/pasteTemplate'
|
import pasteTemplate from '~/utils/pasteTemplate'
|
||||||
import { runScriptInStage } from '~/utils/runScript'
|
import { runScriptInStage } from '~/utils/runScript'
|
||||||
interface IFilter {
|
interface IFilter {
|
||||||
@@ -49,6 +50,17 @@ const galleryRoutes = [
|
|||||||
await runScriptInStage('onGalleryRemove', picgo, { galleryItem: args[0] })
|
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,
|
action: IRPCActionType.GALLERY_GET_DB,
|
||||||
handler: async (_: IIPCEvent, args: [filter: IFilter]) => {
|
handler: async (_: IIPCEvent, args: [filter: IFilter]) => {
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export const IRPCActionType = {
|
|||||||
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
||||||
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
||||||
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
||||||
|
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
|
||||||
|
|
||||||
// plugin rpc
|
// plugin rpc
|
||||||
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
||||||
|
|||||||
@@ -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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import { useVirtualGrid } from '@/hooks/useVirtualGrid'
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ const {
|
|||||||
viewMode?: 'list' | 'grid'
|
viewMode?: 'list' | 'grid'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<(e: 'visibleIndexesChange', indexes: number[]) => void>()
|
||||||
|
|
||||||
const containerRef = useTemplateRef('containerRef')
|
const containerRef = useTemplateRef('containerRef')
|
||||||
const containerHeight = ref(0)
|
const containerHeight = ref(0)
|
||||||
const containerWidth = ref<number>(0)
|
const containerWidth = ref<number>(0)
|
||||||
@@ -99,6 +101,14 @@ const viewportStyle = computed(() => {
|
|||||||
|
|
||||||
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${itemHeight}px` }))
|
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${itemHeight}px` }))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
visibleIndexes,
|
||||||
|
indexes => {
|
||||||
|
emit('visibleIndexesChange', indexes)
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const c = containerRef.value
|
const c = containerRef.value
|
||||||
if (!c) return
|
if (!c) return
|
||||||
|
|||||||
+251
-14
@@ -209,6 +209,7 @@
|
|||||||
:item-height="300"
|
:item-height="300"
|
||||||
:grid-breakpoints="effectiveGridBreakpoints"
|
:grid-breakpoints="effectiveGridBreakpoints"
|
||||||
key-field="key"
|
key-field="key"
|
||||||
|
@visible-indexes-change="handleVisibleIndexesChange"
|
||||||
>
|
>
|
||||||
<template #default="{ item, index }">
|
<template #default="{ item, index }">
|
||||||
<div
|
<div
|
||||||
@@ -221,17 +222,11 @@
|
|||||||
@click.stop="zoomImage(index)"
|
@click.stop="zoomImage(index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="
|
:src="displayImageSources[item.key || ''] || item.src"
|
||||||
imageErrorStates[item.key || '']
|
|
||||||
? './errorLoading.png'
|
|
||||||
: isAlwaysForceReload
|
|
||||||
? addCacheBustParam(item.src)
|
|
||||||
: item.src
|
|
||||||
"
|
|
||||||
class="h-full w-full object-contain transition-all duration-fast ease-apple"
|
class="h-full w-full object-contain transition-all duration-fast ease-apple"
|
||||||
:class="{ loading: !imageLoadStates[item.key || ''] }"
|
:class="{ loading: !imageLoadStates[item.key || ''] }"
|
||||||
@load="onImageLoad(item.key || '')"
|
@load="onImageLoad(item)"
|
||||||
@error="onImageError(item.key || '')"
|
@error="onImageError(item)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!imageLoadStates[item.key || '']"
|
v-if="!imageLoadStates[item.key || '']"
|
||||||
@@ -293,7 +288,7 @@
|
|||||||
<!-- Custom Image Preview Modal -->
|
<!-- Custom Image Preview Modal -->
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
v-model:gallery-slider-control="gallerySliderControl"
|
v-model:gallery-slider-control="gallerySliderControl"
|
||||||
:filter-list="filterList"
|
:filter-list="previewFilterList"
|
||||||
:is-always-force-reload="isAlwaysForceReload"
|
:is-always-force-reload="isAlwaysForceReload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -436,6 +431,7 @@ import { configPaths } from '@/utils/configPaths'
|
|||||||
import { getConfig, saveConfig } from '@/utils/dataSender'
|
import { getConfig, saveConfig } from '@/utils/dataSender'
|
||||||
import $$db from '@/utils/db'
|
import $$db from '@/utils/db'
|
||||||
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
|
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
|
||||||
|
import { getGalleryPreviewSource, getJxlPreviewSource } from '@/utils/galleryPreview'
|
||||||
import { picBedsCanbeDeleted } from '@/utils/static'
|
import { picBedsCanbeDeleted } from '@/utils/static'
|
||||||
|
|
||||||
type IResult<T> = T & {
|
type IResult<T> = T & {
|
||||||
@@ -493,6 +489,16 @@ const currentSortField = ref<'name' | 'time' | 'ext' | 'check'>('name')
|
|||||||
const userGridColumns = useStorage<number>('galleryGridColumns', 4)
|
const userGridColumns = useStorage<number>('galleryGridColumns', 4)
|
||||||
const imageLoadStates = reactive<Record<string, boolean>>({})
|
const imageLoadStates = reactive<Record<string, boolean>>({})
|
||||||
const imageErrorStates = 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 pasteStyleList = ['markdown', 'HTML', 'URL', 'UBB', 'Custom']
|
||||||
const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')]
|
const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')]
|
||||||
@@ -556,6 +562,17 @@ const filterList = computed(() => {
|
|||||||
return getGallery()
|
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 matchedUrls = computed(() => {
|
||||||
const matches = filterList.value.filter((item: any) => {
|
const matches = filterList.value.filter((item: any) => {
|
||||||
return customStrMatch(item.imgUrl, batchRenameMatch.value)
|
return customStrMatch(item.imgUrl, batchRenameMatch.value)
|
||||||
@@ -597,8 +614,28 @@ watch(useShortUrl, newVal => {
|
|||||||
saveConfig(configPaths.settings.useShortUrl, newVal === t('pages.gallery.shortUrl'))
|
saveConfig(configPaths.settings.useShortUrl, newVal === t('pages.gallery.shortUrl'))
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(filterList, () => {
|
watch(filterList, items => {
|
||||||
clearChoosedList()
|
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, _ => {
|
watch(userGridColumns, _ => {
|
||||||
@@ -629,13 +666,23 @@ watch(searchTextURL, newVal => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onImageLoad(id: string) {
|
function onImageLoad(item: IGalleryItem) {
|
||||||
|
const id = item.key || ''
|
||||||
imageLoadStates[id] = true
|
imageLoadStates[id] = true
|
||||||
|
if (getJxlPreviewSource(item)) {
|
||||||
|
updateDisplayImageSource(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageError(id: string) {
|
function onImageError(item: IGalleryItem) {
|
||||||
|
const id = item.key || ''
|
||||||
imageLoadStates[id] = false
|
imageLoadStates[id] = false
|
||||||
|
if (ensureJxlPreview(item)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
imageErrorStates[id] = true
|
imageErrorStates[id] = true
|
||||||
|
updateDisplayImageSource(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleViewMode() {
|
function toggleViewMode() {
|
||||||
@@ -689,7 +736,7 @@ const addCacheBustParam = (url: string | undefined) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const separator = url.includes('?') ? '&' : '?'
|
const separator = url.includes('?') ? '&' : '?'
|
||||||
return `${url}${separator}cbplist=${new Date().getTime()}`
|
return `${url}${separator}cbplist=${cacheBustToken.value}`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -699,6 +746,186 @@ function formatFileName(name: string) {
|
|||||||
return window.node.path.basename(name)
|
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[] {
|
function getGallery(): IGalleryItem[] {
|
||||||
if (
|
if (
|
||||||
debouncedSearchText.value ||
|
debouncedSearchText.value ||
|
||||||
@@ -760,8 +987,17 @@ async function updateGallery() {
|
|||||||
Object.keys(imageErrorStates).forEach(k => {
|
Object.keys(imageErrorStates).forEach(k => {
|
||||||
if (!newIds.has(k)) delete imageErrorStates[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
|
images.value = newList
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
pruneJxlPreviewState()
|
||||||
|
syncVisibleDisplayImageSources()
|
||||||
if (virtualScrollerRef.value) {
|
if (virtualScrollerRef.value) {
|
||||||
virtualScrollerRef.value.refresh()
|
virtualScrollerRef.value.refresh()
|
||||||
}
|
}
|
||||||
@@ -806,6 +1042,7 @@ function clearChoosedList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function zoomImage(index: number) {
|
function zoomImage(index: number) {
|
||||||
|
ensureJxlPreview(filterList.value[index])
|
||||||
gallerySliderControl.value.index = index
|
gallerySliderControl.value.index = index
|
||||||
gallerySliderControl.value.visible = true
|
gallerySliderControl.value.visible = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const IRPCActionType = {
|
|||||||
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
|
||||||
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
|
||||||
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
|
||||||
|
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
|
||||||
|
|
||||||
// plugin rpc
|
// plugin rpc
|
||||||
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
readable-stream "~2.3.6"
|
||||||
setimmediate "^1.0.5"
|
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:
|
keycode@2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
|
resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
|
||||||
|
|||||||
Reference in New Issue
Block a user