From 0b7e1b58cc28fb834c4b820b996b00e8ec5e99c9 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 11:15:45 +0100 Subject: [PATCH 01/11] Reduce thumbnail pressure for large folders --- src-tauri/src/file_management.rs | 14 ++++++----- src/App.tsx | 40 +++++++++++++++++++++++++++++--- src/hooks/useThumbnails.tsx | 11 +-------- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 3489519bf..f41b3c88c 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -41,7 +41,7 @@ use crate::mask_generation::MaskDefinition; use crate::preset_converter; use crate::tagging::COLOR_TAG_PREFIX; -const THUMBNAIL_WIDTH: u32 = 640; +const THUMBNAIL_WIDTH: u32 = 384; fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle @@ -1282,7 +1282,7 @@ pub fn generate_thumbnails_progressive( .store(false, Ordering::SeqCst); let cancellation_token = state.thumbnail_cancellation_token.clone(); - const MAX_THUMBNAIL_THREADS: usize = 6; + const MAX_THUMBNAIL_THREADS: usize = 2; let num_threads = (num_cpus::get_physical().saturating_sub(1)).clamp(1, MAX_THUMBNAIL_THREADS); let pool = ThreadPoolBuilder::new() @@ -1334,10 +1334,12 @@ pub fn generate_thumbnails_progressive( if cancellation_token.load(Ordering::Relaxed) { return Err(()); } - let _ = app_handle_clone.emit( - "thumbnail-progress", - serde_json::json!({ "completed": completed, "total": total_count }), - ); + if completed == total_count || completed % 8 == 0 { + let _ = app_handle_clone.emit( + "thumbnail-progress", + serde_json::json!({ "completed": completed, "total": total_count }), + ); + } Ok(()) }); diff --git a/src/App.tsx b/src/App.tsx index 8a355448b..f148925dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -417,6 +417,9 @@ function App() { const { showContextMenu } = useContextMenu(); const [thumbnails, setThumbnails] = useState>({}); const { loading: isThumbnailsLoading } = useThumbnails(imageList, setThumbnails); + const pendingThumbnailUpdatesRef = useRef>({}); + const pendingRatingUpdatesRef = useRef>({}); + const thumbnailFlushFrameRef = useRef(null); const transformWrapperRef = useRef(null); const isProgrammaticZoom = useRef(false); const currentResRef = useRef(1280); @@ -432,6 +435,20 @@ function App() { const previewJobIdRef = useRef(0); const latestRenderedJobIdRef = useRef(0); + const flushThumbnailUpdates = useCallback(() => { + thumbnailFlushFrameRef.current = null; + const thumbnailUpdates = pendingThumbnailUpdatesRef.current; + const ratingUpdates = pendingRatingUpdatesRef.current; + pendingThumbnailUpdatesRef.current = {}; + pendingRatingUpdatesRef.current = {}; + if (Object.keys(thumbnailUpdates).length > 0) { + setThumbnails((prev) => ({ ...prev, ...thumbnailUpdates })); + } + if (Object.keys(ratingUpdates).length > 0) { + setImageRatings((prev) => ({ ...prev, ...ratingUpdates })); + } + }, []); + useEffect(() => { if (currentFolderPath) { preloadedDataRef.current = { @@ -1887,6 +1904,12 @@ function App() { setIsViewLoading(true); setSearchCriteria({ tags: [], text: '', mode: 'OR' }); setLibraryScrollTop(0); + pendingThumbnailUpdatesRef.current = {}; + pendingRatingUpdatesRef.current = {}; + if (thumbnailFlushFrameRef.current !== null) { + cancelAnimationFrame(thumbnailFlushFrameRef.current); + thumbnailFlushFrameRef.current = null; + } setThumbnails({}); imageCacheRef.current.clear(); try { @@ -2988,10 +3011,13 @@ function App() { if (isEffectActive) { const { path, data, rating } = event.payload; if (data) { - setThumbnails((prev) => ({ ...prev, [path]: data })); + pendingThumbnailUpdatesRef.current[path] = data; } if (rating !== undefined) { - setImageRatings((prev) => ({ ...prev, [path]: rating })); + pendingRatingUpdatesRef.current[path] = rating; + } + if ((data || rating !== undefined) && thumbnailFlushFrameRef.current === null) { + thumbnailFlushFrameRef.current = requestAnimationFrame(flushThumbnailUpdates); } } }), @@ -3130,7 +3156,15 @@ function App() { isEffectActive = false; listeners.forEach((p) => p.then((unlisten) => unlisten())); }; - }, [refreshAllFolderTrees, handleSelectSubfolder]); + }, [flushThumbnailUpdates, refreshAllFolderTrees, handleSelectSubfolder]); + + useEffect(() => { + return () => { + if (thumbnailFlushFrameRef.current !== null) { + cancelAnimationFrame(thumbnailFlushFrameRef.current); + } + }; + }, []); useEffect(() => { if ([Status.Success, Status.Error, Status.Cancelled].includes(exportState.status)) { diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index 37674b6d4..34de6130d 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -45,17 +45,11 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { }); let unlistenComplete: any; - let unlistenProgress: any; const setupListenersAndInvoke = async () => { setLoading(true); setProgress({ completed: 0, total: imagePaths.length }); - unlistenProgress = await listen('thumbnail-progress', (event: any) => { - const { completed, total } = event.payload; - setProgress({ completed, total }); - }); - unlistenComplete = await listen('thumbnail-generation-complete', () => { setLoading(false); }); @@ -74,11 +68,8 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { if (unlistenComplete) { unlistenComplete(); } - if (unlistenProgress) { - unlistenProgress(); - } }; }, [imageList, setThumbnails]); return { loading, progress }; -} \ No newline at end of file +} From f017fb92bd427de1f2391c283eb53023c2bd7dec Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 11:31:07 +0100 Subject: [PATCH 02/11] Request thumbnails only for visible library rows --- src/App.tsx | 5 +++- src/components/panel/MainLibrary.tsx | 27 +++++++++++++++++ src/hooks/useThumbnails.tsx | 45 ++++++++++++++++++++-------- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f148925dd..e22254388 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -416,7 +416,8 @@ function App() { const [libraryScrollTop, setLibraryScrollTop] = useState(0); const { showContextMenu } = useContextMenu(); const [thumbnails, setThumbnails] = useState>({}); - const { loading: isThumbnailsLoading } = useThumbnails(imageList, setThumbnails); + const [visibleThumbnailPaths, setVisibleThumbnailPaths] = useState>([]); + const { loading: isThumbnailsLoading } = useThumbnails(imageList, thumbnails, setThumbnails, visibleThumbnailPaths); const pendingThumbnailUpdatesRef = useRef>({}); const pendingRatingUpdatesRef = useRef>({}); const thumbnailFlushFrameRef = useRef(null); @@ -1904,6 +1905,7 @@ function App() { setIsViewLoading(true); setSearchCriteria({ tags: [], text: '', mode: 'OR' }); setLibraryScrollTop(0); + setVisibleThumbnailPaths([]); pendingThumbnailUpdatesRef.current = {}; pendingRatingUpdatesRef.current = {}; if (thumbnailFlushFrameRef.current !== null) { @@ -4801,6 +4803,7 @@ function App() { thumbnailAspectRatio={thumbnailAspectRatio} thumbnails={thumbnails} thumbnailSize={thumbnailSize} + onVisibleThumbnailPathsChange={setVisibleThumbnailPaths} onNavigateToCommunity={() => setActiveView('community')} /> )} diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index a6bbaef3e..c96066f3b 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -109,6 +109,7 @@ interface MainLibraryProps { thumbnailAspectRatio: ThumbnailAspectRatio; thumbnails: Record; thumbnailSize: ThumbnailSize; + onVisibleThumbnailPathsChange(paths: Array): void; onNavigateToCommunity(): void; } @@ -1206,6 +1207,7 @@ export default function MainLibrary({ thumbnailAspectRatio, thumbnails, thumbnailSize, + onVisibleThumbnailPathsChange, onNavigateToCommunity, }: MainLibraryProps) { const [showSettings, setShowSettings] = useState(false); @@ -1217,6 +1219,7 @@ export default function MainLibrary({ const [latestVersion, setLatestVersion] = useState(''); const [isLoaderVisible, setIsLoaderVisible] = useState(false); const loadedThumbnailsRef = useRef(new Set()); + const visibleThumbnailPathsKeyRef = useRef(''); const prevScrollState = useRef({ path: null as string | null, @@ -1345,6 +1348,13 @@ export default function MainLibrary({ } }, [appSettings?.enableExifReading, sortCriteria.key, setSortCriteria]); + useEffect(() => { + if (imageList.length === 0 && visibleThumbnailPathsKeyRef.current !== '') { + visibleThumbnailPathsKeyRef.current = ''; + onVisibleThumbnailPathsChange([]); + } + }, [imageList.length, onVisibleThumbnailPathsChange]); + useEffect(() => { let showTimer: number | undefined; let hideTimer: number | undefined; @@ -1735,7 +1745,24 @@ export default function MainLibrary({ listRef={setListHandle} rowCount={rows.length} rowHeight={getItemSize} + overscanCount={4} onScroll={(e: React.UIEvent) => setLibraryScrollTop(e.currentTarget.scrollTop)} + onRowsRendered={(_: { startIndex: number; stopIndex: number }, allRows: { startIndex: number; stopIndex: number }) => { + const nextPaths: string[] = []; + + for (let i = allRows.startIndex; i <= allRows.stopIndex; i++) { + const row = rows[i]; + if (row?.type === 'images') { + nextPaths.push(...row.images.map((imageFile: ImageFile) => imageFile.path)); + } + } + + const nextKey = nextPaths.join('|'); + if (nextKey !== visibleThumbnailPathsKeyRef.current) { + visibleThumbnailPathsKeyRef.current = nextKey; + onVisibleThumbnailPathsChange(nextPaths); + } + }} className="custom-scrollbar" rowComponent={Row} rowProps={{ diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index 34de6130d..c505df7b1 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -3,22 +3,24 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { ImageFile, Invokes, Progress } from '../components/ui/AppProperties'; -export function useThumbnails(imageList: Array, setThumbnails: any) { +export function useThumbnails( + imageList: Array, + thumbnails: Record, + setThumbnails: any, + requestedPaths: Array = [], +) { const [loading, setLoading] = useState(false); const [progress, setProgress] = useState({ completed: 0, total: 0 }); const processedImageListKey = useRef(null); + const thumbnailsRef = useRef>(thumbnails); useEffect(() => { - const newKey = - imageList && imageList.length > 0 ? JSON.stringify(imageList.map((img: ImageFile) => img.path).sort()) : ''; - - if (newKey === processedImageListKey.current) { - return; - } - - processedImageListKey.current = newKey; + thumbnailsRef.current = thumbnails; + }, [thumbnails]); + useEffect(() => { if (!imageList || imageList.length === 0) { + processedImageListKey.current = null; setThumbnails({}); setLoading(false); setProgress({ completed: 0, total: 0 }); @@ -26,6 +28,8 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { } const imagePaths = imageList.map((img: ImageFile) => img.path); + const effectiveRequestedPaths = + requestedPaths.length > 0 ? requestedPaths.filter((path) => imagePaths.includes(path)) : imagePaths.slice(0, 48); setThumbnails((prevThumbnails: Record) => { const newPathSet = new Set(imagePaths); @@ -44,18 +48,35 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { : prevThumbnails; }); + invoke('cancel_thumbnail_generation').catch(() => {}); + + const pathsToRequest = effectiveRequestedPaths.filter((path) => !thumbnailsRef.current[path]); + const newKey = JSON.stringify(pathsToRequest); + + if (newKey === processedImageListKey.current) { + return; + } + + processedImageListKey.current = newKey; + + if (pathsToRequest.length === 0) { + setLoading(false); + setProgress({ completed: 0, total: 0 }); + return; + } + let unlistenComplete: any; const setupListenersAndInvoke = async () => { setLoading(true); - setProgress({ completed: 0, total: imagePaths.length }); + setProgress({ completed: 0, total: pathsToRequest.length }); unlistenComplete = await listen('thumbnail-generation-complete', () => { setLoading(false); }); try { - await invoke(Invokes.GenerateThumbnailsProgressive, { paths: imagePaths }); + await invoke(Invokes.GenerateThumbnailsProgressive, { paths: pathsToRequest }); } catch (error) { console.error('Failed to invoke thumbnail generation:', error); setLoading(false); @@ -69,7 +90,7 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { unlistenComplete(); } }; - }, [imageList, setThumbnails]); + }, [imageList, requestedPaths, setThumbnails]); return { loading, progress }; } From 4926e01705fe9b3344800bfa29e5384d6a93bb20 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 11:35:43 +0100 Subject: [PATCH 03/11] Reduce editor thumbnail work and avoid base64 IPC --- src-tauri/src/file_management.rs | 8 ++------ src/App.tsx | 5 +++-- src/components/panel/BottomBar.tsx | 3 +++ src/components/panel/Filmstrip.tsx | 13 +++++++++++++ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index f41b3c88c..4f2d2bc23 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -12,7 +12,6 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use anyhow::Result; -use base64::{Engine as _, engine::general_purpose}; use chrono::{DateTime, Utc}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, GenericImageView, ImageBuffer, Luma}; @@ -1214,10 +1213,8 @@ fn generate_single_thumbnail_and_cache( if !force_regenerate && cache_path.exists() - && let Ok(data) = fs::read(&cache_path) { - let base64_str = general_purpose::STANDARD.encode(&data); - return Some((format!("data:image/jpeg;base64,{}", base64_str), rating)); + return Some((cache_path.to_string_lossy().into_owned(), rating)); } if let Ok(thumb_image) = @@ -1225,8 +1222,7 @@ fn generate_single_thumbnail_and_cache( && let Ok(thumb_data) = encode_thumbnail(&thumb_image) { let _ = fs::write(&cache_path, &thumb_data); - let base64_str = general_purpose::STANDARD.encode(&thumb_data); - return Some((format!("data:image/jpeg;base64,{}", base64_str), rating)); + return Some((cache_path.to_string_lossy().into_owned(), rating)); } None } diff --git a/src/App.tsx b/src/App.tsx index e22254388..c01084d91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { invoke } from '@tauri-apps/api/core'; +import { convertFileSrc, invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { platform } from '@tauri-apps/plugin-os'; @@ -3013,7 +3013,7 @@ function App() { if (isEffectActive) { const { path, data, rating } = event.payload; if (data) { - pendingThumbnailUpdatesRef.current[path] = data; + pendingThumbnailUpdatesRef.current[path] = data.startsWith('data:') ? data : convertFileSrc(data); } if (rating !== undefined) { pendingRatingUpdatesRef.current[path] = rating; @@ -4977,6 +4977,7 @@ function App() { onPaste={() => handlePasteAdjustments()} onRate={handleRate} onZoomChange={handleZoomChange} + onVisibleThumbnailPathsChange={setVisibleThumbnailPaths} rating={adjustments.rating || 0} selectedImage={selectedImage} setIsFilmstripVisible={(value: boolean) => diff --git a/src/components/panel/BottomBar.tsx b/src/components/panel/BottomBar.tsx index 155573062..a5821f61e 100644 --- a/src/components/panel/BottomBar.tsx +++ b/src/components/panel/BottomBar.tsx @@ -30,6 +30,7 @@ interface BottomBarProps { onPaste(): void; onRate(rate: number): void; onReset?(): void; + onVisibleThumbnailPathsChange?(paths: Array): void; onZoomChange?(zoomValue: number, fitToWindow?: boolean): void; rating: number; selectedImage?: SelectedImage; @@ -105,6 +106,7 @@ export default function BottomBar({ onPaste, onRate, onReset, + onVisibleThumbnailPathsChange, onZoomChange = () => {}, rating, selectedImage, @@ -244,6 +246,7 @@ export default function BottomBar({ onClearSelection={onClearSelection} onContextMenu={onContextMenu} onImageSelect={onImageSelect} + onVisibleThumbnailPathsChange={onVisibleThumbnailPathsChange} selectedImage={selectedImage} thumbnails={thumbnails} thumbnailAspectRatio={thumbnailAspectRatio} diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index c92bfa0cd..98c1cb632 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -26,6 +26,7 @@ interface ItemData { thumbnailAspectRatio: ThumbnailAspectRatio; onContextMenu?: (event: any, path: string) => void; onImageSelect?: (path: string, event: any) => void; + onVisibleThumbnailPathsChange?: (paths: Array) => void; itemHeight: number; setSize: (index: number, width: number) => void; } @@ -309,6 +310,7 @@ const FilmstripList = ({ const resizeEndTimer = useRef(null); const currentDataRef = useRef(data); currentDataRef.current = data; + const visiblePathsKeyRef = useRef(''); const pendingResizeRef = useRef(null); const lowestPendingIndexRef = useRef(Infinity); const isAnimatingScroll = useRef(false); @@ -383,6 +385,14 @@ const FilmstripList = ({ start: visibleCells.columnStartIndex, stop: visibleCells.columnStopIndex, }; + const nextPaths = currentDataRef.current.imageList + .slice(visibleCells.columnStartIndex, visibleCells.columnStopIndex + 1) + .map((imageFile) => imageFile.path); + const nextKey = nextPaths.join('|'); + if (nextKey !== visiblePathsKeyRef.current) { + visiblePathsKeyRef.current = nextKey; + currentDataRef.current.onVisibleThumbnailPathsChange?.(nextPaths); + } }, []); const isItemVisible = useCallback((index: number) => { @@ -529,6 +539,7 @@ interface FilmStripProps { onClearSelection?(): void; onContextMenu?(event: any, path: string): void; onImageSelect?(path: string, event: any): void; + onVisibleThumbnailPathsChange?(paths: Array): void; selectedImage?: SelectedImage; thumbnails: Record | undefined; thumbnailAspectRatio: ThumbnailAspectRatio; @@ -542,6 +553,7 @@ export default function Filmstrip({ onClearSelection, onContextMenu, onImageSelect, + onVisibleThumbnailPathsChange, selectedImage, thumbnails, thumbnailAspectRatio, @@ -571,6 +583,7 @@ export default function Filmstrip({ thumbnailAspectRatio, onContextMenu, onImageSelect: handleImageSelect, + onVisibleThumbnailPathsChange, clickTriggeredScroll, }} /> From bb6748b1effe137c817813a0985293b89e8d7571 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 12:40:54 +0100 Subject: [PATCH 04/11] Enable cached thumbnail asset protocol --- src-tauri/Cargo.lock | 7 +++++++ src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4273a30dc..e4b0b5d6c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2784,6 +2784,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -6868,6 +6874,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91ad35bb1..654cbf8f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.94" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tauri = { version = "2.10", features = [ "macos-private-api", "native-tls" ] } +tauri = { version = "2.10", features = [ "macos-private-api", "native-tls", "protocol-asset" ] } tauri-plugin-dialog = "2.6.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3c426da0c..cec31f0b5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,11 @@ ], "security": { "csp": null, - "capabilities": ["default"] + "capabilities": ["default"], + "assetProtocol": { + "enable": true, + "scope": ["$APPCACHE/thumbnails/**"] + } }, "macOSPrivateApi": true }, From 80ed937edb492e43972331c0e1bfabe04ddd4c8f Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 13:00:42 +0100 Subject: [PATCH 05/11] Reduce gallery startup burst for large folders --- src-tauri/src/file_management.rs | 9 ++++-- src/components/panel/MainLibrary.tsx | 2 +- src/hooks/useThumbnails.tsx | 41 ++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 4f2d2bc23..5d4aba39d 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -41,6 +41,7 @@ use crate::preset_converter; use crate::tagging::COLOR_TAG_PREFIX; const THUMBNAIL_WIDTH: u32 = 384; +const MAX_XMP_SYNC_LISTING_IMAGES: usize = 200; fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle @@ -577,6 +578,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result) => setLibraryScrollTop(e.currentTarget.scrollTop)} onRowsRendered={(_: { startIndex: number; stopIndex: number }, allRows: { startIndex: number; stopIndex: number }) => { const nextPaths: string[] = []; diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index c505df7b1..1aa11e9c4 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -3,6 +3,8 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { ImageFile, Invokes, Progress } from '../components/ui/AppProperties'; +const THUMBNAIL_REQUEST_BATCH_SIZE = 16; + export function useThumbnails( imageList: Array, thumbnails: Record, @@ -66,17 +68,50 @@ export function useThumbnails( } let unlistenComplete: any; + let isCancelled = false; + let nextBatchStartIndex = 0; const setupListenersAndInvoke = async () => { setLoading(true); setProgress({ completed: 0, total: pathsToRequest.length }); unlistenComplete = await listen('thumbnail-generation-complete', () => { - setLoading(false); + if (isCancelled) { + return; + } + const completed = Math.min(nextBatchStartIndex, pathsToRequest.length); + setProgress({ completed, total: pathsToRequest.length }); + if (completed >= pathsToRequest.length) { + setLoading(false); + return; + } + invokeNextBatch(); }); + const invokeNextBatch = async () => { + if (isCancelled) { + return; + } + const batchPaths = pathsToRequest.slice( + nextBatchStartIndex, + nextBatchStartIndex + THUMBNAIL_REQUEST_BATCH_SIZE, + ); + if (batchPaths.length === 0) { + setLoading(false); + return; + } + nextBatchStartIndex += batchPaths.length; + + try { + await invoke(Invokes.GenerateThumbnailsProgressive, { paths: batchPaths }); + } catch (error) { + console.error('Failed to invoke thumbnail generation:', error); + setLoading(false); + } + }; + try { - await invoke(Invokes.GenerateThumbnailsProgressive, { paths: pathsToRequest }); + await invokeNextBatch(); } catch (error) { console.error('Failed to invoke thumbnail generation:', error); setLoading(false); @@ -86,6 +121,8 @@ export function useThumbnails( setupListenersAndInvoke(); return () => { + isCancelled = true; + invoke('cancel_thumbnail_generation').catch(() => {}); if (unlistenComplete) { unlistenComplete(); } From 52bc0d21a66ef0cd9b65b6b672fcfd72d2e75cf0 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 15:52:19 +0100 Subject: [PATCH 06/11] Use embedded RAW previews and trim large folder listing work --- src-tauri/src/file_management.rs | 109 +++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 5d4aba39d..b9d9d1447 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -42,6 +42,9 @@ use crate::tagging::COLOR_TAG_PREFIX; const THUMBNAIL_WIDTH: u32 = 384; const MAX_XMP_SYNC_LISTING_IMAGES: usize = 200; +const MAX_SIDECAR_METADATA_LISTING_IMAGES: usize = 500; +const EMBEDDED_RAW_THUMBNAIL_EXTENSIONS: &[&str] = + &["arw", "cr2", "cr3", "dng", "nef", "pef", "raf", "rw2", "tfr"]; fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle @@ -579,6 +582,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result(&content).unwrap_or_default() + if should_read_sidecar_metadata_on_list { + let mut metadata = if sidecar_path.exists() { + if let Ok(content) = fs::read_to_string(&sidecar_path) { + serde_json::from_str::(&content).unwrap_or_default() + } else { + ImageMetadata::default() + } } else { ImageMetadata::default() + }; + + let source_path_buf = PathBuf::from(&path_str); + if should_sync_xmp_on_list + && sync_metadata_from_xmp(&source_path_buf, &mut metadata) + && let Ok(json) = serde_json::to_string_pretty(&metadata) + { + let _ = fs::write(&sidecar_path, json); } - } else { - ImageMetadata::default() - }; - let source_path_buf = PathBuf::from(&path_str); - if should_sync_xmp_on_list - && sync_metadata_from_xmp(&source_path_buf, &mut metadata) - && let Ok(json) = serde_json::to_string_pretty(&metadata) - { - let _ = fs::write(&sidecar_path, json); + let edited = metadata.adjustments.as_object().is_some_and(|a| { + a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) + }); + (edited, metadata.tags) + } else { + (false, None) } - - let edited = metadata.adjustments.as_object().is_some_and(|a| { - a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); - (edited, metadata.tags) }; result_list.push(ImageFile { @@ -696,6 +705,8 @@ pub fn list_images_recursive( } let should_sync_xmp_on_list = enable_xmp_sync && image_files.len() <= MAX_XMP_SYNC_LISTING_IMAGES; + let should_read_sidecar_metadata_on_list = + image_files.len() <= MAX_SIDECAR_METADATA_LISTING_IMAGES; let mut result_list = Vec::new(); for (path_str, path_buf) in image_files { @@ -724,28 +735,32 @@ pub fn list_images_recursive( }; let (is_edited, tags) = { - let mut metadata = if sidecar_path.exists() { - if let Ok(content) = fs::read_to_string(&sidecar_path) { - serde_json::from_str::(&content).unwrap_or_default() + if should_read_sidecar_metadata_on_list { + let mut metadata = if sidecar_path.exists() { + if let Ok(content) = fs::read_to_string(&sidecar_path) { + serde_json::from_str::(&content).unwrap_or_default() + } else { + ImageMetadata::default() + } } else { ImageMetadata::default() + }; + + let source_path_buf = PathBuf::from(&path_str); + if should_sync_xmp_on_list + && sync_metadata_from_xmp(&source_path_buf, &mut metadata) + && let Ok(json) = serde_json::to_string_pretty(&metadata) + { + let _ = fs::write(&sidecar_path, json); } - } else { - ImageMetadata::default() - }; - let source_path_buf = PathBuf::from(&path_str); - if should_sync_xmp_on_list - && sync_metadata_from_xmp(&source_path_buf, &mut metadata) - && let Ok(json) = serde_json::to_string_pretty(&metadata) - { - let _ = fs::write(&sidecar_path, json); + let edited = metadata.adjustments.as_object().is_some_and(|a| { + a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) + }); + (edited, metadata.tags) + } else { + (false, None) } - - let edited = metadata.adjustments.as_object().is_some_and(|a| { - a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); - (edited, metadata.tags) }; result_list.push(ImageFile { @@ -919,6 +934,32 @@ pub fn generate_thumbnail_data( let adjustments = metadata .as_ref() .map_or(serde_json::Value::Null, |m| m.adjustments.clone()); + let is_unedited_raw_thumbnail = match adjustments.as_object() { + Some(adjustment_obj) => { + adjustment_obj.is_empty() + || (adjustment_obj.len() == 1 && adjustment_obj.contains_key("rating")) + } + None => adjustments.is_null(), + }; + + if preloaded_image.is_none() + && is_raw + && is_unedited_raw_thumbnail + && source_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + EMBEDDED_RAW_THUMBNAIL_EXTENSIONS + .iter() + .any(|supported_ext| supported_ext.eq_ignore_ascii_case(ext)) + }) + && let Ok(preview_image) = rawler::analyze::extract_thumbnail_pixels( + &source_path, + &rawler::decoders::RawDecodeParams::default(), + ) + { + return Ok(preview_image); + } if let (Some(context), Some(meta)) = (gpu_context, metadata) && !meta.adjustments.is_null() From c0a3221b34d6dff9a3c2b3c90d1e2a8d7ccb58af Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 16:05:51 +0100 Subject: [PATCH 07/11] Skip folder image counting when disabled --- src-tauri/src/file_management.rs | 51 ++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index b9d9d1447..3a915ea58 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -787,7 +787,10 @@ pub struct FolderNode { pub image_count: usize, } -fn scan_dir_and_count(path: &Path) -> Result<(Vec, usize), std::io::Error> { +fn scan_dir_and_count( + path: &Path, + include_image_counts: bool, +) -> Result<(Vec, usize), std::io::Error> { let mut children_folders = Vec::new(); let mut current_dir_image_count = 0; @@ -817,10 +820,15 @@ fn scan_dir_and_count(path: &Path) -> Result<(Vec, usize), std::io:: continue; } - let (grand_children, sub_dir_own_images) = scan_dir_and_count(¤t_path)?; + let (grand_children, sub_dir_own_images) = + scan_dir_and_count(¤t_path, include_image_counts)?; - let grand_children_sum: usize = grand_children.iter().map(|c| c.image_count).sum(); - let total_child_count = sub_dir_own_images + grand_children_sum; + let total_child_count = if include_image_counts { + let grand_children_sum: usize = grand_children.iter().map(|c| c.image_count).sum(); + sub_dir_own_images + grand_children_sum + } else { + 0 + }; children_folders.push(FolderNode { name: name_str.into_owned(), @@ -829,7 +837,10 @@ fn scan_dir_and_count(path: &Path) -> Result<(Vec, usize), std::io:: is_dir: true, image_count: total_child_count, }); - } else if file_type.is_file() && is_supported_image_file(¤t_path) { + } else if include_image_counts + && file_type.is_file() + && is_supported_image_file(¤t_path) + { current_dir_image_count += 1; } } @@ -837,15 +848,20 @@ fn scan_dir_and_count(path: &Path) -> Result<(Vec, usize), std::io:: Ok((children_folders, current_dir_image_count)) } -fn get_folder_tree_sync(path: String) -> Result { +fn get_folder_tree_sync(path: String, include_image_counts: bool) -> Result { let root_path = Path::new(&path); if !root_path.is_dir() { return Err(format!("Directory does not exist: {}", path)); } - let (children, own_count) = scan_dir_and_count(root_path).map_err(|e| e.to_string())?; + let (children, own_count) = + scan_dir_and_count(root_path, include_image_counts).map_err(|e| e.to_string())?; - let children_sum: usize = children.iter().map(|c| c.image_count).sum(); + let children_sum: usize = if include_image_counts { + children.iter().map(|c| c.image_count).sum() + } else { + 0 + }; Ok(FolderNode { name: root_path @@ -861,8 +877,14 @@ fn get_folder_tree_sync(path: String) -> Result { } #[tauri::command] -pub async fn get_folder_tree(path: String) -> Result { - match tauri::async_runtime::spawn_blocking(move || get_folder_tree_sync(path)).await { +pub async fn get_folder_tree(path: String, app_handle: AppHandle) -> Result { + let settings = load_settings(app_handle).unwrap_or_default(); + let include_image_counts = settings.enable_folder_image_counts.unwrap_or(false); + match tauri::async_runtime::spawn_blocking(move || { + get_folder_tree_sync(path, include_image_counts) + }) + .await + { Ok(Ok(folder_node)) => Ok(folder_node), Ok(Err(e)) => Err(e), Err(e) => Err(format!("Failed to execute folder tree task: {}", e)), @@ -870,11 +892,16 @@ pub async fn get_folder_tree(path: String) -> Result { } #[tauri::command] -pub async fn get_pinned_folder_trees(paths: Vec) -> Result, String> { +pub async fn get_pinned_folder_trees( + paths: Vec, + app_handle: AppHandle, +) -> Result, String> { + let settings = load_settings(app_handle).unwrap_or_default(); + let include_image_counts = settings.enable_folder_image_counts.unwrap_or(false); let result = tauri::async_runtime::spawn_blocking(move || { let results: Vec> = paths .par_iter() - .map(|path| get_folder_tree_sync(path.clone())) + .map(|path| get_folder_tree_sync(path.clone(), include_image_counts)) .collect(); let mut folder_nodes = Vec::new(); From d2cc9d02b64a533cadcb09d74efedbe88704470d Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 16:27:46 +0100 Subject: [PATCH 08/11] Defer GPU initialization for gallery thumbnails --- src-tauri/src/file_management.rs | 53 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 3a915ea58..a0267938c 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -961,6 +961,13 @@ pub fn generate_thumbnail_data( let adjustments = metadata .as_ref() .map_or(serde_json::Value::Null, |m| m.adjustments.clone()); + let has_visual_adjustments = match adjustments.as_object() { + Some(adjustment_obj) => { + adjustment_obj.len() > 1 + || (adjustment_obj.len() == 1 && !adjustment_obj.contains_key("rating")) + } + None => !adjustments.is_null(), + }; let is_unedited_raw_thumbnail = match adjustments.as_object() { Some(adjustment_obj) => { adjustment_obj.is_empty() @@ -989,7 +996,7 @@ pub fn generate_thumbnail_data( } if let (Some(context), Some(meta)) = (gpu_context, metadata) - && !meta.adjustments.is_null() + && has_visual_adjustments { let state = app_handle.state::(); const THUMBNAIL_PROCESSING_DIM: u32 = 1280; @@ -1260,20 +1267,28 @@ fn generate_single_thumbnail_and_cache( .ok()? .as_secs(); - let (sidecar_mod_time, rating) = if let Ok(content) = fs::read_to_string(&sidecar_path) { + let (sidecar_mod_time, rating, has_visual_adjustments) = if let Ok(content) = fs::read_to_string(&sidecar_path) { let mod_time = fs::metadata(&sidecar_path) .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); - let rating_val = serde_json::from_str::(&content) - .ok() - .map(|m| m.rating) - .unwrap_or(0); - (mod_time, rating_val) + let metadata = serde_json::from_str::(&content).ok(); + let rating_val = metadata.as_ref().map(|m| m.rating).unwrap_or(0); + let has_visual_adjustments = metadata + .as_ref() + .map(|m| match m.adjustments.as_object() { + Some(adjustment_obj) => { + adjustment_obj.len() > 1 + || (adjustment_obj.len() == 1 && !adjustment_obj.contains_key("rating")) + } + None => !m.adjustments.is_null(), + }) + .unwrap_or(false); + (mod_time, rating_val, has_visual_adjustments) } else { - (0, 0) + (0, 0, false) }; let mut hasher = blake3::Hasher::new(); @@ -1290,8 +1305,18 @@ fn generate_single_thumbnail_and_cache( return Some((cache_path.to_string_lossy().into_owned(), rating)); } + let lazy_gpu_context = if gpu_context.is_none() + && has_visual_adjustments + { + let state = app_handle.state::(); + gpu_processing::get_or_init_gpu_context(&state).ok() + } else { + None + }; + let active_gpu_context = gpu_context.or(lazy_gpu_context.as_ref()); + if let Ok(thumb_image) = - generate_thumbnail_data(path_str, gpu_context, preloaded_image, app_handle) + generate_thumbnail_data(path_str, active_gpu_context, preloaded_image, app_handle) && let Ok(thumb_data) = encode_thumbnail(&thumb_image) { let _ = fs::write(&cache_path, &thumb_data); @@ -1316,16 +1341,13 @@ pub async fn generate_thumbnails( fs::create_dir_all(&thumb_cache_dir).map_err(|e| e.to_string())?; } - let state = app_handle_clone.state::(); - let gpu_context = gpu_processing::get_or_init_gpu_context(&state).ok(); - let thumbnails: HashMap = paths .par_iter() .filter_map(|path_str| { generate_single_thumbnail_and_cache( path_str, &thumb_cache_dir, - gpu_context.as_ref(), + None, None, false, &app_handle_clone, @@ -1372,9 +1394,6 @@ pub fn generate_thumbnails_progressive( let completed_count = Arc::new(AtomicUsize::new(0)); pool.spawn(move || { - let state = app_handle_clone.state::(); - let gpu_context = gpu_processing::get_or_init_gpu_context(&state).ok(); - let _ = paths.par_iter().try_for_each(|path_str| -> Result<(), ()> { if cancellation_token.load(Ordering::Relaxed) { return Err(()); @@ -1383,7 +1402,7 @@ pub fn generate_thumbnails_progressive( let result = generate_single_thumbnail_and_cache( path_str, &thumb_cache_dir, - gpu_context.as_ref(), + None, None, false, &app_handle_clone, From 38efaf0d74abfd7b40e225a644ca1dbaf91d4d96 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 18:04:25 +0100 Subject: [PATCH 09/11] Refine startup thumbnail paths and add GPU diagnostics --- src-tauri/src/file_management.rs | 221 +++++++++++++++++++++++++++---- src-tauri/src/gpu_processing.rs | 33 +++++ src-tauri/src/image_loader.rs | 11 +- src/App.tsx | 39 +++--- 4 files changed, 263 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index a0267938c..5cc19614a 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -46,6 +46,177 @@ const MAX_SIDECAR_METADATA_LISTING_IMAGES: usize = 500; const EMBEDDED_RAW_THUMBNAIL_EXTENSIONS: &[&str] = &["arw", "cr2", "cr3", "dng", "nef", "pef", "raf", "rw2", "tfr"]; +fn is_identity_curve_channel(value: &Value) -> bool { + value.as_array().is_some_and(|points| { + points.len() == 2 + && points[0]["x"].as_i64() == Some(0) + && points[0]["y"].as_i64() == Some(0) + && points[1]["x"].as_i64() == Some(255) + && points[1]["y"].as_i64() == Some(255) + }) +} + +fn has_material_ai_patch(value: &Value) -> bool { + if value.get("patchData").is_some_and(|patch_data| !patch_data.is_null()) { + return true; + } + if value + .get("patchDataBase64") + .and_then(|patch_data| patch_data.as_str()) + .is_some_and(|patch_data| !patch_data.is_empty()) + { + return true; + } + value + .get("subMasks") + .and_then(|sub_masks| sub_masks.as_array()) + .is_some_and(|sub_masks| { + sub_masks.iter().any(|sub_mask| { + sub_mask + .get("parameters") + .and_then(|params| { + params + .get("maskDataBase64") + .or_else(|| params.get("mask_data_base64")) + }) + .and_then(|mask_data| mask_data.as_str()) + .is_some_and(|mask_data| !mask_data.is_empty()) + }) + }) +} + +fn has_meaningful_thumbnail_adjustments(adjustments: &Value) -> bool { + let Some(adjustment_obj) = adjustments.as_object() else { + return !adjustments.is_null(); + }; + + for (key, value) in adjustment_obj { + match key.as_str() { + "rating" | "sectionVisibility" | "aspectRatio" | "showClipping" | "filmBaseColor" => {} + "aiPatches" => { + if value + .as_array() + .is_some_and(|patches| patches.iter().any(has_material_ai_patch)) + { + return true; + } + } + "masks" => { + if value.as_array().is_some_and(|masks| !masks.is_empty()) { + return true; + } + } + "crop" | "lutData" | "lensDistortionParams" => { + if !value.is_null() { + return true; + } + } + "flipHorizontal" + | "flipVertical" + | "enableNegativeConversion" + | "lensDistortionEnabled" + | "lensTcaEnabled" + | "lensVignetteEnabled" => { + if value.as_bool().unwrap_or(false) != (key.starts_with("lens")) { + return true; + } + } + "grainRoughness" | "vignetteFeather" | "vignetteMidpoint" => { + if value.as_i64().unwrap_or(50) != 50 { + return true; + } + } + "grainSize" => { + if value.as_i64().unwrap_or(25) != 25 { + return true; + } + } + "lutIntensity" + | "lensDistortionAmount" + | "lensVignetteAmount" + | "lensTcaAmount" + | "transformScale" => { + if value.as_i64().unwrap_or(100) != 100 { + return true; + } + } + "lutSize" => { + if value.as_i64().unwrap_or(0) != 0 { + return true; + } + } + "toneMapper" => { + if value.as_str().unwrap_or("basic") != "basic" { + return true; + } + } + "lutName" | "lutPath" | "lensMaker" | "lensModel" => { + if value.as_str().is_some_and(|text| !text.is_empty()) { + return true; + } + } + "curves" => { + if !is_identity_curve_channel(&value["luma"]) + || !is_identity_curve_channel(&value["red"]) + || !is_identity_curve_channel(&value["green"]) + || !is_identity_curve_channel(&value["blue"]) + { + return true; + } + } + "colorCalibration" => { + if value["redHue"].as_i64().unwrap_or(0) != 0 + || value["redSaturation"].as_i64().unwrap_or(0) != 0 + || value["greenHue"].as_i64().unwrap_or(0) != 0 + || value["greenSaturation"].as_i64().unwrap_or(0) != 0 + || value["blueHue"].as_i64().unwrap_or(0) != 0 + || value["blueSaturation"].as_i64().unwrap_or(0) != 0 + || value["shadowsTint"].as_i64().unwrap_or(0) != 0 + { + return true; + } + } + "colorGrading" => { + if value["balance"].as_i64().unwrap_or(0) != 0 + || value["blending"].as_i64().unwrap_or(50) != 50 + || value["highlights"]["hue"].as_i64().unwrap_or(0) != 0 + || value["highlights"]["luminance"].as_i64().unwrap_or(0) != 0 + || value["highlights"]["saturation"].as_i64().unwrap_or(0) != 0 + || value["midtones"]["hue"].as_i64().unwrap_or(0) != 0 + || value["midtones"]["luminance"].as_i64().unwrap_or(0) != 0 + || value["midtones"]["saturation"].as_i64().unwrap_or(0) != 0 + || value["shadows"]["hue"].as_i64().unwrap_or(0) != 0 + || value["shadows"]["luminance"].as_i64().unwrap_or(0) != 0 + || value["shadows"]["saturation"].as_i64().unwrap_or(0) != 0 + { + return true; + } + } + "hsl" => { + if value.to_string().contains(":1") + || value.to_string().contains(":2") + || value.to_string().contains(":3") + || value.to_string().contains(":4") + || value.to_string().contains(":5") + || value.to_string().contains(":6") + || value.to_string().contains(":7") + || value.to_string().contains(":8") + || value.to_string().contains(":9") + { + return true; + } + } + _ => { + if value.as_f64().is_some_and(|number| number != 0.0) { + return true; + } + } + } + } + + false +} + fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle .path() @@ -961,20 +1132,8 @@ pub fn generate_thumbnail_data( let adjustments = metadata .as_ref() .map_or(serde_json::Value::Null, |m| m.adjustments.clone()); - let has_visual_adjustments = match adjustments.as_object() { - Some(adjustment_obj) => { - adjustment_obj.len() > 1 - || (adjustment_obj.len() == 1 && !adjustment_obj.contains_key("rating")) - } - None => !adjustments.is_null(), - }; - let is_unedited_raw_thumbnail = match adjustments.as_object() { - Some(adjustment_obj) => { - adjustment_obj.is_empty() - || (adjustment_obj.len() == 1 && adjustment_obj.contains_key("rating")) - } - None => adjustments.is_null(), - }; + let has_visual_adjustments = has_meaningful_thumbnail_adjustments(&adjustments); + let is_unedited_raw_thumbnail = !has_visual_adjustments; if preloaded_image.is_none() && is_raw @@ -995,6 +1154,24 @@ pub fn generate_thumbnail_data( return Ok(preview_image); } + if preloaded_image.is_none() + && !is_raw + && !has_visual_adjustments + && source_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg")) + { + return match read_file_mapped(&source_path) { + Ok(mmap) => image_loader::load_image_with_orientation_native(&mmap, None), + Err(e) => { + log::warn!("Fallback read for {}: {}", source_path_str, e); + let bytes = fs::read(&source_path)?; + image_loader::load_image_with_orientation_native(&bytes, None) + } + }; + } + if let (Some(context), Some(meta)) = (gpu_context, metadata) && has_visual_adjustments { @@ -1241,8 +1418,12 @@ pub fn generate_thumbnail_data( } fn encode_thumbnail(image: &DynamicImage) -> Result> { - let thumbnail = - crate::image_processing::downscale_f32_image(image, THUMBNAIL_WIDTH, THUMBNAIL_WIDTH); + let thumbnail = match image { + DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_) => { + crate::image_processing::downscale_f32_image(image, THUMBNAIL_WIDTH, THUMBNAIL_WIDTH) + } + _ => image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_WIDTH), + }; let mut buf = Cursor::new(Vec::new()); let mut encoder = JpegEncoder::new_with_quality(&mut buf, 75); encoder.encode_image(&thumbnail.to_rgb8())?; @@ -1278,13 +1459,7 @@ fn generate_single_thumbnail_and_cache( let rating_val = metadata.as_ref().map(|m| m.rating).unwrap_or(0); let has_visual_adjustments = metadata .as_ref() - .map(|m| match m.adjustments.as_object() { - Some(adjustment_obj) => { - adjustment_obj.len() > 1 - || (adjustment_obj.len() == 1 && !adjustment_obj.contains_key("rating")) - } - None => !m.adjustments.is_null(), - }) + .map(|m| has_meaningful_thumbnail_adjustments(&m.adjustments)) .unwrap_or(false); (mod_time, rating_val, has_visual_adjustments) } else { diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index b4d3669f7..3bd2bd426 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -22,6 +22,7 @@ pub fn get_or_init_gpu_context(state: &tauri::State) -> Result) -> Result) -> Result) -> Result) -> Result anyhow::Error { pub fn load_image_with_orientation( bytes: &[u8], cancel_token: Option<(Arc, usize)>, +) -> Result { + Ok(DynamicImage::ImageRgb32F( + load_image_with_orientation_native(bytes, cancel_token)?.to_rgb32f(), + )) +} + +pub fn load_image_with_orientation_native( + bytes: &[u8], + cancel_token: Option<(Arc, usize)>, ) -> Result { let check_cancel = || -> Result<()> { if let Some((tracker, generation)) = &cancel_token @@ -161,7 +170,7 @@ pub fn load_image_with_orientation( } }; - Ok(DynamicImage::ImageRgb32F(oriented_image.to_rgb32f())) + Ok(oriented_image) } pub fn composite_patches_on_image( diff --git a/src/App.tsx b/src/App.tsx index c01084d91..154379eab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1684,7 +1684,6 @@ function App() { preloadedDataRef.current = { rootPath: root, currentPath: currentPath, - tree: invoke(Invokes.GetFolderTree, { path: root }), images: invoke(command, { path: currentPath }), }; } @@ -3518,22 +3517,6 @@ function App() { setExpandedFolders(new Set([root])); } - setIsTreeLoading(true); - try { - let treeData; - if (preloadedDataRef.current.rootPath === root && preloadedDataRef.current.tree) { - treeData = await preloadedDataRef.current.tree; - console.log('Preload cache hit for folder tree.'); - } else { - treeData = await invoke(Invokes.GetFolderTree, { path: root }); - } - setFolderTree(treeData); - } catch (err) { - console.error('Failed to restore folder tree:', err); - } finally { - setIsTreeLoading(false); - } - let preloadedImages: ImageFile[] | undefined = undefined; if (preloadedDataRef.current.currentPath === pathToSelect && preloadedDataRef.current.images) { try { @@ -3545,6 +3528,28 @@ function App() { } await handleSelectSubfolder(pathToSelect, false, preloadedImages); + + setIsTreeLoading(true); + const usePreloadedTree = preloadedDataRef.current.rootPath === root && preloadedDataRef.current.tree; + const treePromise = usePreloadedTree ? preloadedDataRef.current.tree : invoke(Invokes.GetFolderTree, { path: root }); + + treePromise + ?.then((treeData) => { + if (usePreloadedTree) { + console.log('Preload cache hit for folder tree.'); + } + if (preloadedDataRef.current.rootPath === root) { + setFolderTree(treeData); + } + }) + .catch((err) => { + console.error('Failed to restore folder tree:', err); + }) + .finally(() => { + if (preloadedDataRef.current.rootPath === root) { + setIsTreeLoading(false); + } + }); }; restore().catch((err) => { console.error('Failed to restore session, folder might be missing:', err); From a82bc980e2b1e7ee41ee1b33cee8ed64c787c93b Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Sat, 21 Mar 2026 18:14:19 +0100 Subject: [PATCH 10/11] Add branch summary for freeze investigation --- README.bytemedic-fix-large-folder-freeze.md | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 README.bytemedic-fix-large-folder-freeze.md diff --git a/README.bytemedic-fix-large-folder-freeze.md b/README.bytemedic-fix-large-folder-freeze.md new file mode 100644 index 000000000..10ff62c45 --- /dev/null +++ b/README.bytemedic-fix-large-folder-freeze.md @@ -0,0 +1,153 @@ +# Branch Summary: `bytemedic-fix-large-folder-freeze` + +## Goal + +This branch investigates and mitigates a severe desktop freeze that happened when RapidRAW opened photo folders, especially folders containing many RAW files and large JPEGs. + +The work was intentionally kept incremental and low-risk: + +- no large refactor +- no architecture rewrite +- no changes to the `src-tauri/rawler` vendor subtree +- focused patches only in the hot paths that were actually involved in folder opening, thumbnail generation, and startup restore + +## Initial Problem + +Opening a photo folder could trigger a large burst of work across several layers at once: + +- eager thumbnail requests for many images +- expensive RAW thumbnail generation +- heavy JPEG decode paths +- large IPC payloads +- extra folder-tree and listing work during startup +- early GPU initialization during gallery thumbnail work + +On the affected machine, this could escalate from a slow app to a full system freeze. + +## Commit-by-Commit Breakdown + +### `0b7e1b58` `Reduce thumbnail pressure for large folders` + +This first patch reduced the raw amount of work done when thumbnails started loading. + +- lowered thumbnail width to reduce encode, memory, and IPC cost +- reduced gallery thumbnail concurrency +- reduced progress-event chatter +- batched frontend thumbnail state updates to avoid thousands of individual React writes + +Why it mattered: +This cut the initial thumbnail burst without changing the overall architecture. + +### `f017fb92` `Request thumbnails only for visible library rows` + +This moved thumbnail requests from "whole folder" to "currently visible rows plus overscan" in the main library. + +- wired visible library rows from the virtualized grid back to `useThumbnails` +- stopped requesting thumbnails for the entire image list up front + +Why it mattered: +This was the first asymptotic improvement. The library could stop behaving like "load everything immediately". + +### `4926e017` `Reduce editor thumbnail work and avoid base64 IPC` + +This applied the same visible-range idea to the editor filmstrip and reduced thumbnail transport overhead. + +- limited filmstrip thumbnail requests to the visible range +- replaced base64 thumbnail IPC payloads with cached thumbnail file paths +- switched the frontend to asset URLs for cached thumbnails + +Why it mattered: +This removed a large amount of string allocation, copying, and IPC overhead from thumbnail delivery. + +### `bb6748b1` `Enable cached thumbnail asset protocol` + +This was the compatibility follow-up for the previous commit. + +- enabled the Tauri asset protocol and related configuration needed to load cached thumbnails correctly + +Why it mattered: +Without this, the cache-path transport change would not reliably display thumbnails. + +### `80ed937e` `Reduce gallery startup burst for large folders` + +This patch reduced the amount of work triggered right at gallery startup. + +- chunked visible thumbnail requests into smaller batches +- reduced library overscan +- skipped XMP sync work during listing for larger folders + +Why it mattered: +The goal here was not just to reduce total work, but to smooth the startup spike that was more dangerous than steady-state work. + +### `52bc0d21` `Use embedded RAW previews and trim large folder listing work` + +This patch attacked two different hot spots with a single localized backend change. + +- for supported unedited RAW files, tried to use embedded RAW previews before falling back to the full RAW path +- trimmed sidecar-derived metadata work during listing for large folders + +Why it mattered: +Using embedded previews is much cheaper than fully developing a RAW just to show a gallery thumbnail. + +### `c0a3221b` `Skip folder image counting when disabled` + +This fixed a mismatch between settings and actual backend behavior. + +- stopped recursive folder image counting when `enableFolderImageCounts` was disabled + +Why it mattered: +This removed unnecessary tree-scan work during folder loading and startup restore. + +### `d2cc9d02` `Defer GPU initialization for gallery thumbnails` + +This patch delayed GPU startup until it was actually needed. + +- removed eager GPU initialization from gallery thumbnail flows +- only initialized GPU late when a thumbnail path truly required visual adjustment processing + +Why it mattered: +This was important because the freezes were severe enough to look like driver-level or system-level overload, not just slow UI work. + +### `38efaf0d` `Refine startup thumbnail paths and add GPU diagnostics` + +This was the final investigation and stabilization commit. + +- made thumbnail adjustment detection more selective so "neutral" sidecar content would not force heavier paths +- added a lighter JPEG thumbnail path +- stopped startup restore from blocking on folder-tree loading +- added targeted GPU timing and lifecycle diagnostics to confirm where the hot path really was + +Why it mattered: +This commit helped confirm that the remaining freezes were not caused by a single issue, but by overlapping startup work. It also provided the last improvements that made the tested startup path stable. + +## Result + +On the tested setup, the branch significantly improved folder opening behavior and removed the full-system freeze that originally happened during startup and gallery loading. + +The final result came from combining several small changes rather than one large rewrite: + +- less eager work +- fewer thumbnails requested +- lighter thumbnail transport +- less folder-tree and listing overhead +- deferred GPU work +- lighter JPEG and RAW thumbnail paths + +## What This Branch Does Not Intentionally Solve + +These items were investigated but left out of scope for this branch because they were not primary causes of the freeze: + +- the `save_settings` frontend/backend payload warning +- the `Decoder has no thumbnail/preview image support` warnings emitted by `rawler` fallbacks + +They may still be worth cleaning up later, but they were not treated as blockers for the freeze investigation. + +## Validation Notes + +During the investigation, changes were repeatedly validated with: + +- `cargo check --manifest-path src-tauri/Cargo.toml` +- `npm run build` +- `npm run start` + +The branch was kept intentionally surgical so each commit could be reviewed, reverted, or cherry-picked independently. From 55b80c72f2018c97b257893ca56f0a8b2b7c50a8 Mon Sep 17 00:00:00 2001 From: ByteMedic Date: Fri, 27 Mar 2026 22:39:54 +0100 Subject: [PATCH 11/11] Simplify freeze mitigation changes for review --- README.bytemedic-fix-large-folder-freeze.md | 153 --------- src-tauri/Cargo.lock | 7 - src-tauri/Cargo.toml | 2 +- src-tauri/src/file_management.rs | 355 +++++--------------- src-tauri/src/gpu_processing.rs | 33 -- src-tauri/src/image_loader.rs | 11 +- src-tauri/tauri.conf.json | 6 +- src/App.tsx | 42 +-- 8 files changed, 85 insertions(+), 524 deletions(-) delete mode 100644 README.bytemedic-fix-large-folder-freeze.md diff --git a/README.bytemedic-fix-large-folder-freeze.md b/README.bytemedic-fix-large-folder-freeze.md deleted file mode 100644 index 10ff62c45..000000000 --- a/README.bytemedic-fix-large-folder-freeze.md +++ /dev/null @@ -1,153 +0,0 @@ -# Branch Summary: `bytemedic-fix-large-folder-freeze` - -## Goal - -This branch investigates and mitigates a severe desktop freeze that happened when RapidRAW opened photo folders, especially folders containing many RAW files and large JPEGs. - -The work was intentionally kept incremental and low-risk: - -- no large refactor -- no architecture rewrite -- no changes to the `src-tauri/rawler` vendor subtree -- focused patches only in the hot paths that were actually involved in folder opening, thumbnail generation, and startup restore - -## Initial Problem - -Opening a photo folder could trigger a large burst of work across several layers at once: - -- eager thumbnail requests for many images -- expensive RAW thumbnail generation -- heavy JPEG decode paths -- large IPC payloads -- extra folder-tree and listing work during startup -- early GPU initialization during gallery thumbnail work - -On the affected machine, this could escalate from a slow app to a full system freeze. - -## Commit-by-Commit Breakdown - -### `0b7e1b58` `Reduce thumbnail pressure for large folders` - -This first patch reduced the raw amount of work done when thumbnails started loading. - -- lowered thumbnail width to reduce encode, memory, and IPC cost -- reduced gallery thumbnail concurrency -- reduced progress-event chatter -- batched frontend thumbnail state updates to avoid thousands of individual React writes - -Why it mattered: -This cut the initial thumbnail burst without changing the overall architecture. - -### `f017fb92` `Request thumbnails only for visible library rows` - -This moved thumbnail requests from "whole folder" to "currently visible rows plus overscan" in the main library. - -- wired visible library rows from the virtualized grid back to `useThumbnails` -- stopped requesting thumbnails for the entire image list up front - -Why it mattered: -This was the first asymptotic improvement. The library could stop behaving like "load everything immediately". - -### `4926e017` `Reduce editor thumbnail work and avoid base64 IPC` - -This applied the same visible-range idea to the editor filmstrip and reduced thumbnail transport overhead. - -- limited filmstrip thumbnail requests to the visible range -- replaced base64 thumbnail IPC payloads with cached thumbnail file paths -- switched the frontend to asset URLs for cached thumbnails - -Why it mattered: -This removed a large amount of string allocation, copying, and IPC overhead from thumbnail delivery. - -### `bb6748b1` `Enable cached thumbnail asset protocol` - -This was the compatibility follow-up for the previous commit. - -- enabled the Tauri asset protocol and related configuration needed to load cached thumbnails correctly - -Why it mattered: -Without this, the cache-path transport change would not reliably display thumbnails. - -### `80ed937e` `Reduce gallery startup burst for large folders` - -This patch reduced the amount of work triggered right at gallery startup. - -- chunked visible thumbnail requests into smaller batches -- reduced library overscan -- skipped XMP sync work during listing for larger folders - -Why it mattered: -The goal here was not just to reduce total work, but to smooth the startup spike that was more dangerous than steady-state work. - -### `52bc0d21` `Use embedded RAW previews and trim large folder listing work` - -This patch attacked two different hot spots with a single localized backend change. - -- for supported unedited RAW files, tried to use embedded RAW previews before falling back to the full RAW path -- trimmed sidecar-derived metadata work during listing for large folders - -Why it mattered: -Using embedded previews is much cheaper than fully developing a RAW just to show a gallery thumbnail. - -### `c0a3221b` `Skip folder image counting when disabled` - -This fixed a mismatch between settings and actual backend behavior. - -- stopped recursive folder image counting when `enableFolderImageCounts` was disabled - -Why it mattered: -This removed unnecessary tree-scan work during folder loading and startup restore. - -### `d2cc9d02` `Defer GPU initialization for gallery thumbnails` - -This patch delayed GPU startup until it was actually needed. - -- removed eager GPU initialization from gallery thumbnail flows -- only initialized GPU late when a thumbnail path truly required visual adjustment processing - -Why it mattered: -This was important because the freezes were severe enough to look like driver-level or system-level overload, not just slow UI work. - -### `38efaf0d` `Refine startup thumbnail paths and add GPU diagnostics` - -This was the final investigation and stabilization commit. - -- made thumbnail adjustment detection more selective so "neutral" sidecar content would not force heavier paths -- added a lighter JPEG thumbnail path -- stopped startup restore from blocking on folder-tree loading -- added targeted GPU timing and lifecycle diagnostics to confirm where the hot path really was - -Why it mattered: -This commit helped confirm that the remaining freezes were not caused by a single issue, but by overlapping startup work. It also provided the last improvements that made the tested startup path stable. - -## Result - -On the tested setup, the branch significantly improved folder opening behavior and removed the full-system freeze that originally happened during startup and gallery loading. - -The final result came from combining several small changes rather than one large rewrite: - -- less eager work -- fewer thumbnails requested -- lighter thumbnail transport -- less folder-tree and listing overhead -- deferred GPU work -- lighter JPEG and RAW thumbnail paths - -## What This Branch Does Not Intentionally Solve - -These items were investigated but left out of scope for this branch because they were not primary causes of the freeze: - -- the `save_settings` frontend/backend payload warning -- the `Decoder has no thumbnail/preview image support` warnings emitted by `rawler` fallbacks - -They may still be worth cleaning up later, but they were not treated as blockers for the freeze investigation. - -## Validation Notes - -During the investigation, changes were repeatedly validated with: - -- `cargo check --manifest-path src-tauri/Cargo.toml` -- `npm run build` -- `npm run start` - -The branch was kept intentionally surgical so each commit could be reviewed, reverted, or cherry-picked independently. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e4b0b5d6c..4273a30dc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2784,12 +2784,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -6874,7 +6868,6 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 654cbf8f4..91ad35bb1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.94" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tauri = { version = "2.10", features = [ "macos-private-api", "native-tls", "protocol-asset" ] } +tauri = { version = "2.10", features = [ "macos-private-api", "native-tls" ] } tauri-plugin-dialog = "2.6.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 5cc19614a..463837d17 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use anyhow::Result; +use base64::{Engine as _, engine::general_purpose}; use chrono::{DateTime, Utc}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, GenericImageView, ImageBuffer, Luma}; @@ -40,182 +41,8 @@ use crate::mask_generation::MaskDefinition; use crate::preset_converter; use crate::tagging::COLOR_TAG_PREFIX; -const THUMBNAIL_WIDTH: u32 = 384; +const THUMBNAIL_WIDTH: u32 = 640; const MAX_XMP_SYNC_LISTING_IMAGES: usize = 200; -const MAX_SIDECAR_METADATA_LISTING_IMAGES: usize = 500; -const EMBEDDED_RAW_THUMBNAIL_EXTENSIONS: &[&str] = - &["arw", "cr2", "cr3", "dng", "nef", "pef", "raf", "rw2", "tfr"]; - -fn is_identity_curve_channel(value: &Value) -> bool { - value.as_array().is_some_and(|points| { - points.len() == 2 - && points[0]["x"].as_i64() == Some(0) - && points[0]["y"].as_i64() == Some(0) - && points[1]["x"].as_i64() == Some(255) - && points[1]["y"].as_i64() == Some(255) - }) -} - -fn has_material_ai_patch(value: &Value) -> bool { - if value.get("patchData").is_some_and(|patch_data| !patch_data.is_null()) { - return true; - } - if value - .get("patchDataBase64") - .and_then(|patch_data| patch_data.as_str()) - .is_some_and(|patch_data| !patch_data.is_empty()) - { - return true; - } - value - .get("subMasks") - .and_then(|sub_masks| sub_masks.as_array()) - .is_some_and(|sub_masks| { - sub_masks.iter().any(|sub_mask| { - sub_mask - .get("parameters") - .and_then(|params| { - params - .get("maskDataBase64") - .or_else(|| params.get("mask_data_base64")) - }) - .and_then(|mask_data| mask_data.as_str()) - .is_some_and(|mask_data| !mask_data.is_empty()) - }) - }) -} - -fn has_meaningful_thumbnail_adjustments(adjustments: &Value) -> bool { - let Some(adjustment_obj) = adjustments.as_object() else { - return !adjustments.is_null(); - }; - - for (key, value) in adjustment_obj { - match key.as_str() { - "rating" | "sectionVisibility" | "aspectRatio" | "showClipping" | "filmBaseColor" => {} - "aiPatches" => { - if value - .as_array() - .is_some_and(|patches| patches.iter().any(has_material_ai_patch)) - { - return true; - } - } - "masks" => { - if value.as_array().is_some_and(|masks| !masks.is_empty()) { - return true; - } - } - "crop" | "lutData" | "lensDistortionParams" => { - if !value.is_null() { - return true; - } - } - "flipHorizontal" - | "flipVertical" - | "enableNegativeConversion" - | "lensDistortionEnabled" - | "lensTcaEnabled" - | "lensVignetteEnabled" => { - if value.as_bool().unwrap_or(false) != (key.starts_with("lens")) { - return true; - } - } - "grainRoughness" | "vignetteFeather" | "vignetteMidpoint" => { - if value.as_i64().unwrap_or(50) != 50 { - return true; - } - } - "grainSize" => { - if value.as_i64().unwrap_or(25) != 25 { - return true; - } - } - "lutIntensity" - | "lensDistortionAmount" - | "lensVignetteAmount" - | "lensTcaAmount" - | "transformScale" => { - if value.as_i64().unwrap_or(100) != 100 { - return true; - } - } - "lutSize" => { - if value.as_i64().unwrap_or(0) != 0 { - return true; - } - } - "toneMapper" => { - if value.as_str().unwrap_or("basic") != "basic" { - return true; - } - } - "lutName" | "lutPath" | "lensMaker" | "lensModel" => { - if value.as_str().is_some_and(|text| !text.is_empty()) { - return true; - } - } - "curves" => { - if !is_identity_curve_channel(&value["luma"]) - || !is_identity_curve_channel(&value["red"]) - || !is_identity_curve_channel(&value["green"]) - || !is_identity_curve_channel(&value["blue"]) - { - return true; - } - } - "colorCalibration" => { - if value["redHue"].as_i64().unwrap_or(0) != 0 - || value["redSaturation"].as_i64().unwrap_or(0) != 0 - || value["greenHue"].as_i64().unwrap_or(0) != 0 - || value["greenSaturation"].as_i64().unwrap_or(0) != 0 - || value["blueHue"].as_i64().unwrap_or(0) != 0 - || value["blueSaturation"].as_i64().unwrap_or(0) != 0 - || value["shadowsTint"].as_i64().unwrap_or(0) != 0 - { - return true; - } - } - "colorGrading" => { - if value["balance"].as_i64().unwrap_or(0) != 0 - || value["blending"].as_i64().unwrap_or(50) != 50 - || value["highlights"]["hue"].as_i64().unwrap_or(0) != 0 - || value["highlights"]["luminance"].as_i64().unwrap_or(0) != 0 - || value["highlights"]["saturation"].as_i64().unwrap_or(0) != 0 - || value["midtones"]["hue"].as_i64().unwrap_or(0) != 0 - || value["midtones"]["luminance"].as_i64().unwrap_or(0) != 0 - || value["midtones"]["saturation"].as_i64().unwrap_or(0) != 0 - || value["shadows"]["hue"].as_i64().unwrap_or(0) != 0 - || value["shadows"]["luminance"].as_i64().unwrap_or(0) != 0 - || value["shadows"]["saturation"].as_i64().unwrap_or(0) != 0 - { - return true; - } - } - "hsl" => { - if value.to_string().contains(":1") - || value.to_string().contains(":2") - || value.to_string().contains(":3") - || value.to_string().contains(":4") - || value.to_string().contains(":5") - || value.to_string().contains(":6") - || value.to_string().contains(":7") - || value.to_string().contains(":8") - || value.to_string().contains(":9") - { - return true; - } - } - _ => { - if value.as_f64().is_some_and(|number| number != 0.0) { - return true; - } - } - } - } - - false -} fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle @@ -229,6 +56,23 @@ fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result anyhow::Result { + let params = rawler::decoders::RawDecodeParams::default(); + let rawfile = rawler::rawsource::RawSource::new(source_path)?; + let decoder = rawler::get_decoder(&rawfile)?; + let preview = rawler::analyze::extract_thumbnail_pixels(source_path, ¶ms)?; + let orientation = decoder + .raw_metadata(&rawfile, ¶ms)? + .exif + .orientation + .map(rawler::decoders::Orientation::from_u16) + .unwrap_or(rawler::decoders::Orientation::Normal); + Ok(crate::image_processing::apply_orientation( + preview, + orientation, + )) +} + fn emit_thumbnail_cache_setup_error(app_handle: &AppHandle, path: &str, reason: &str) { let _ = app_handle.emit( "thumbnail-generation-error", @@ -752,9 +596,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result(&content).unwrap_or_default() - } else { - ImageMetadata::default() - } + let mut metadata = if sidecar_path.exists() { + if let Ok(content) = fs::read_to_string(&sidecar_path) { + serde_json::from_str::(&content).unwrap_or_default() } else { ImageMetadata::default() - }; - - let source_path_buf = PathBuf::from(&path_str); - if should_sync_xmp_on_list - && sync_metadata_from_xmp(&source_path_buf, &mut metadata) - && let Ok(json) = serde_json::to_string_pretty(&metadata) - { - let _ = fs::write(&sidecar_path, json); } - - let edited = metadata.adjustments.as_object().is_some_and(|a| { - a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); - (edited, metadata.tags) } else { - (false, None) + ImageMetadata::default() + }; + + let source_path_buf = PathBuf::from(&path_str); + if should_sync_xmp_on_list + && sync_metadata_from_xmp(&source_path_buf, &mut metadata) + && let Ok(json) = serde_json::to_string_pretty(&metadata) + { + let _ = fs::write(&sidecar_path, json); } + + let edited = metadata.adjustments.as_object().is_some_and(|a| { + a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) + }); + (edited, metadata.tags) }; result_list.push(ImageFile { @@ -875,9 +714,8 @@ pub fn list_images_recursive( } } - let should_sync_xmp_on_list = enable_xmp_sync && image_files.len() <= MAX_XMP_SYNC_LISTING_IMAGES; - let should_read_sidecar_metadata_on_list = - image_files.len() <= MAX_SIDECAR_METADATA_LISTING_IMAGES; + let should_sync_xmp_on_list = + enable_xmp_sync && image_files.len() <= MAX_XMP_SYNC_LISTING_IMAGES; let mut result_list = Vec::new(); for (path_str, path_buf) in image_files { @@ -906,32 +744,28 @@ pub fn list_images_recursive( }; let (is_edited, tags) = { - if should_read_sidecar_metadata_on_list { - let mut metadata = if sidecar_path.exists() { - if let Ok(content) = fs::read_to_string(&sidecar_path) { - serde_json::from_str::(&content).unwrap_or_default() - } else { - ImageMetadata::default() - } + let mut metadata = if sidecar_path.exists() { + if let Ok(content) = fs::read_to_string(&sidecar_path) { + serde_json::from_str::(&content).unwrap_or_default() } else { ImageMetadata::default() - }; - - let source_path_buf = PathBuf::from(&path_str); - if should_sync_xmp_on_list - && sync_metadata_from_xmp(&source_path_buf, &mut metadata) - && let Ok(json) = serde_json::to_string_pretty(&metadata) - { - let _ = fs::write(&sidecar_path, json); } - - let edited = metadata.adjustments.as_object().is_some_and(|a| { - a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); - (edited, metadata.tags) } else { - (false, None) + ImageMetadata::default() + }; + + let source_path_buf = PathBuf::from(&path_str); + if should_sync_xmp_on_list + && sync_metadata_from_xmp(&source_path_buf, &mut metadata) + && let Ok(json) = serde_json::to_string_pretty(&metadata) + { + let _ = fs::write(&sidecar_path, json); } + + let edited = metadata.adjustments.as_object().is_some_and(|a| { + a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) + }); + (edited, metadata.tags) }; result_list.push(ImageFile { @@ -1132,48 +966,18 @@ pub fn generate_thumbnail_data( let adjustments = metadata .as_ref() .map_or(serde_json::Value::Null, |m| m.adjustments.clone()); - let has_visual_adjustments = has_meaningful_thumbnail_adjustments(&adjustments); - let is_unedited_raw_thumbnail = !has_visual_adjustments; + let has_adjustments = !adjustments.is_null(); if preloaded_image.is_none() && is_raw - && is_unedited_raw_thumbnail - && source_path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - EMBEDDED_RAW_THUMBNAIL_EXTENSIONS - .iter() - .any(|supported_ext| supported_ext.eq_ignore_ascii_case(ext)) - }) - && let Ok(preview_image) = rawler::analyze::extract_thumbnail_pixels( - &source_path, - &rawler::decoders::RawDecodeParams::default(), - ) + && !has_adjustments + && let Ok(preview_image) = try_extract_embedded_raw_thumbnail(&source_path) { return Ok(preview_image); } - if preloaded_image.is_none() - && !is_raw - && !has_visual_adjustments - && source_path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg")) - { - return match read_file_mapped(&source_path) { - Ok(mmap) => image_loader::load_image_with_orientation_native(&mmap, None), - Err(e) => { - log::warn!("Fallback read for {}: {}", source_path_str, e); - let bytes = fs::read(&source_path)?; - image_loader::load_image_with_orientation_native(&bytes, None) - } - }; - } - if let (Some(context), Some(meta)) = (gpu_context, metadata) - && has_visual_adjustments + && has_adjustments { let state = app_handle.state::(); const THUMBNAIL_PROCESSING_DIM: u32 = 1280; @@ -1418,12 +1222,8 @@ pub fn generate_thumbnail_data( } fn encode_thumbnail(image: &DynamicImage) -> Result> { - let thumbnail = match image { - DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_) => { - crate::image_processing::downscale_f32_image(image, THUMBNAIL_WIDTH, THUMBNAIL_WIDTH) - } - _ => image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_WIDTH), - }; + let thumbnail = + crate::image_processing::downscale_f32_image(image, THUMBNAIL_WIDTH, THUMBNAIL_WIDTH); let mut buf = Cursor::new(Vec::new()); let mut encoder = JpegEncoder::new_with_quality(&mut buf, 75); encoder.encode_image(&thumbnail.to_rgb8())?; @@ -1448,7 +1248,7 @@ fn generate_single_thumbnail_and_cache( .ok()? .as_secs(); - let (sidecar_mod_time, rating, has_visual_adjustments) = if let Ok(content) = fs::read_to_string(&sidecar_path) { + let (sidecar_mod_time, rating, has_adjustments) = if let Ok(content) = fs::read_to_string(&sidecar_path) { let mod_time = fs::metadata(&sidecar_path) .ok() .and_then(|m| m.modified().ok()) @@ -1457,11 +1257,11 @@ fn generate_single_thumbnail_and_cache( .unwrap_or(0); let metadata = serde_json::from_str::(&content).ok(); let rating_val = metadata.as_ref().map(|m| m.rating).unwrap_or(0); - let has_visual_adjustments = metadata + let has_adjustments = metadata .as_ref() - .map(|m| has_meaningful_thumbnail_adjustments(&m.adjustments)) + .map(|m| !m.adjustments.is_null()) .unwrap_or(false); - (mod_time, rating_val, has_visual_adjustments) + (mod_time, rating_val, has_adjustments) } else { (0, 0, false) }; @@ -1476,12 +1276,14 @@ fn generate_single_thumbnail_and_cache( if !force_regenerate && cache_path.exists() + && let Ok(data) = fs::read(&cache_path) { - return Some((cache_path.to_string_lossy().into_owned(), rating)); + let base64_str = general_purpose::STANDARD.encode(&data); + return Some((format!("data:image/jpeg;base64,{}", base64_str), rating)); } let lazy_gpu_context = if gpu_context.is_none() - && has_visual_adjustments + && has_adjustments { let state = app_handle.state::(); gpu_processing::get_or_init_gpu_context(&state).ok() @@ -1495,7 +1297,8 @@ fn generate_single_thumbnail_and_cache( && let Ok(thumb_data) = encode_thumbnail(&thumb_image) { let _ = fs::write(&cache_path, &thumb_data); - return Some((cache_path.to_string_lossy().into_owned(), rating)); + let base64_str = general_purpose::STANDARD.encode(&thumb_data); + return Some((format!("data:image/jpeg;base64,{}", base64_str), rating)); } None } @@ -1548,7 +1351,7 @@ pub fn generate_thumbnails_progressive( .store(false, Ordering::SeqCst); let cancellation_token = state.thumbnail_cancellation_token.clone(); - const MAX_THUMBNAIL_THREADS: usize = 2; + const MAX_THUMBNAIL_THREADS: usize = 6; let num_threads = (num_cpus::get_physical().saturating_sub(1)).clamp(1, MAX_THUMBNAIL_THREADS); let pool = ThreadPoolBuilder::new() @@ -1597,12 +1400,10 @@ pub fn generate_thumbnails_progressive( if cancellation_token.load(Ordering::Relaxed) { return Err(()); } - if completed == total_count || completed % 8 == 0 { - let _ = app_handle_clone.emit( - "thumbnail-progress", - serde_json::json!({ "completed": completed, "total": total_count }), - ); - } + let _ = app_handle_clone.emit( + "thumbnail-progress", + serde_json::json!({ "completed": completed, "total": total_count }), + ); Ok(()) }); diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 3bd2bd426..b4d3669f7 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -22,7 +22,6 @@ pub fn get_or_init_gpu_context(state: &tauri::State) -> Result) -> Result) -> Result) -> Result) -> Result anyhow::Error { pub fn load_image_with_orientation( bytes: &[u8], cancel_token: Option<(Arc, usize)>, -) -> Result { - Ok(DynamicImage::ImageRgb32F( - load_image_with_orientation_native(bytes, cancel_token)?.to_rgb32f(), - )) -} - -pub fn load_image_with_orientation_native( - bytes: &[u8], - cancel_token: Option<(Arc, usize)>, ) -> Result { let check_cancel = || -> Result<()> { if let Some((tracker, generation)) = &cancel_token @@ -170,7 +161,7 @@ pub fn load_image_with_orientation_native( } }; - Ok(oriented_image) + Ok(DynamicImage::ImageRgb32F(oriented_image.to_rgb32f())) } pub fn composite_patches_on_image( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cec31f0b5..3c426da0c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,11 +21,7 @@ ], "security": { "csp": null, - "capabilities": ["default"], - "assetProtocol": { - "enable": true, - "scope": ["$APPCACHE/thumbnails/**"] - } + "capabilities": ["default"] }, "macOSPrivateApi": true }, diff --git a/src/App.tsx b/src/App.tsx index 154379eab..3aecc64f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { convertFileSrc, invoke } from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { platform } from '@tauri-apps/plugin-os'; @@ -418,9 +418,6 @@ function App() { const [thumbnails, setThumbnails] = useState>({}); const [visibleThumbnailPaths, setVisibleThumbnailPaths] = useState>([]); const { loading: isThumbnailsLoading } = useThumbnails(imageList, thumbnails, setThumbnails, visibleThumbnailPaths); - const pendingThumbnailUpdatesRef = useRef>({}); - const pendingRatingUpdatesRef = useRef>({}); - const thumbnailFlushFrameRef = useRef(null); const transformWrapperRef = useRef(null); const isProgrammaticZoom = useRef(false); const currentResRef = useRef(1280); @@ -436,20 +433,6 @@ function App() { const previewJobIdRef = useRef(0); const latestRenderedJobIdRef = useRef(0); - const flushThumbnailUpdates = useCallback(() => { - thumbnailFlushFrameRef.current = null; - const thumbnailUpdates = pendingThumbnailUpdatesRef.current; - const ratingUpdates = pendingRatingUpdatesRef.current; - pendingThumbnailUpdatesRef.current = {}; - pendingRatingUpdatesRef.current = {}; - if (Object.keys(thumbnailUpdates).length > 0) { - setThumbnails((prev) => ({ ...prev, ...thumbnailUpdates })); - } - if (Object.keys(ratingUpdates).length > 0) { - setImageRatings((prev) => ({ ...prev, ...ratingUpdates })); - } - }, []); - useEffect(() => { if (currentFolderPath) { preloadedDataRef.current = { @@ -1905,12 +1888,6 @@ function App() { setSearchCriteria({ tags: [], text: '', mode: 'OR' }); setLibraryScrollTop(0); setVisibleThumbnailPaths([]); - pendingThumbnailUpdatesRef.current = {}; - pendingRatingUpdatesRef.current = {}; - if (thumbnailFlushFrameRef.current !== null) { - cancelAnimationFrame(thumbnailFlushFrameRef.current); - thumbnailFlushFrameRef.current = null; - } setThumbnails({}); imageCacheRef.current.clear(); try { @@ -3012,13 +2989,10 @@ function App() { if (isEffectActive) { const { path, data, rating } = event.payload; if (data) { - pendingThumbnailUpdatesRef.current[path] = data.startsWith('data:') ? data : convertFileSrc(data); + setThumbnails((prev) => ({ ...prev, [path]: data })); } if (rating !== undefined) { - pendingRatingUpdatesRef.current[path] = rating; - } - if ((data || rating !== undefined) && thumbnailFlushFrameRef.current === null) { - thumbnailFlushFrameRef.current = requestAnimationFrame(flushThumbnailUpdates); + setImageRatings((prev) => ({ ...prev, [path]: rating })); } } }), @@ -3157,15 +3131,7 @@ function App() { isEffectActive = false; listeners.forEach((p) => p.then((unlisten) => unlisten())); }; - }, [flushThumbnailUpdates, refreshAllFolderTrees, handleSelectSubfolder]); - - useEffect(() => { - return () => { - if (thumbnailFlushFrameRef.current !== null) { - cancelAnimationFrame(thumbnailFlushFrameRef.current); - } - }; - }, []); + }, [refreshAllFolderTrees, handleSelectSubfolder]); useEffect(() => { if ([Status.Success, Status.Error, Status.Cancelled].includes(exportState.status)) {