From 26d9c4906df0cc001fdd0d2f5c9d2d90dfe7850a Mon Sep 17 00:00:00 2001 From: Kuingsmile <96409857+Kuingsmile@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:32:18 -0700 Subject: [PATCH] :sparkles: Feature(custom): support jxl image preview in gallery ISSUES CLOSED: #531 --- .vscode/extensions.json | 3 +- package.json | 1 + src/main/events/rpc/routes/gallery/index.ts | 12 + src/main/utils/enum.ts | 1 + src/main/utils/jxlPreview.ts | 74 ++++++ src/renderer/components/VirtualScroller.vue | 12 +- src/renderer/pages/Gallery.vue | 265 ++++++++++++++++++-- src/renderer/utils/enum.ts | 1 + src/renderer/utils/galleryPreview.ts | 79 ++++++ tests/gallery-preview.test.ts | 71 ++++++ yarn.lock | 5 + 11 files changed, 507 insertions(+), 17 deletions(-) create mode 100644 src/main/utils/jxlPreview.ts create mode 100644 src/renderer/utils/galleryPreview.ts create mode 100644 tests/gallery-preview.test.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e72e3c91..d9917f59 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,6 @@ "esbenp.prettier-vscode", "EditorConfig.EditorConfig", "lokalise.i18n-ally", - "bradlc.vscode-tailwindcss", - "vitest.explorer" + "bradlc.vscode-tailwindcss" ] } diff --git a/package.json b/package.json index 63998d31..0ca4fd35 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/events/rpc/routes/gallery/index.ts b/src/main/events/rpc/routes/gallery/index.ts index 1b3fb319..21992639 100644 --- a/src/main/events/rpc/routes/gallery/index.ts +++ b/src/main/events/rpc/routes/gallery/index.ts @@ -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]) => { diff --git a/src/main/utils/enum.ts b/src/main/utils/enum.ts index 771bcd26..0c98a446 100644 --- a/src/main/utils/enum.ts +++ b/src/main/utils/enum.ts @@ -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', diff --git a/src/main/utils/jxlPreview.ts b/src/main/utils/jxlPreview.ts new file mode 100644 index 00000000..8fa529d9 --- /dev/null +++ b/src/main/utils/jxlPreview.ts @@ -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 { + 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 { + 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() + } +} diff --git a/src/renderer/components/VirtualScroller.vue b/src/renderer/components/VirtualScroller.vue index 6d95ed33..922e42c9 100644 --- a/src/renderer/components/VirtualScroller.vue +++ b/src/renderer/components/VirtualScroller.vue @@ -24,7 +24,7 @@