diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 3489519bf..463837d17 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -42,6 +42,7 @@ use crate::preset_converter; use crate::tagging::COLOR_TAG_PREFIX; const THUMBNAIL_WIDTH: u32 = 640; +const MAX_XMP_SYNC_LISTING_IMAGES: usize = 200; fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result { let cache_dir = app_handle @@ -55,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", @@ -578,6 +596,9 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result 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; @@ -798,10 +825,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(), @@ -810,7 +842,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; } } @@ -818,15 +853,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 @@ -842,8 +882,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)), @@ -851,11 +897,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(); @@ -915,9 +966,18 @@ pub fn generate_thumbnail_data( let adjustments = metadata .as_ref() .map_or(serde_json::Value::Null, |m| m.adjustments.clone()); + let has_adjustments = !adjustments.is_null(); + + if preloaded_image.is_none() + && is_raw + && !has_adjustments + && let Ok(preview_image) = try_extract_embedded_raw_thumbnail(&source_path) + { + return Ok(preview_image); + } if let (Some(context), Some(meta)) = (gpu_context, metadata) - && !meta.adjustments.is_null() + && has_adjustments { let state = app_handle.state::(); const THUMBNAIL_PROCESSING_DIM: u32 = 1280; @@ -1188,20 +1248,22 @@ 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_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_adjustments = metadata + .as_ref() + .map(|m| !m.adjustments.is_null()) + .unwrap_or(false); + (mod_time, rating_val, has_adjustments) } else { - (0, 0) + (0, 0, false) }; let mut hasher = blake3::Hasher::new(); @@ -1220,8 +1282,18 @@ fn generate_single_thumbnail_and_cache( return Some((format!("data:image/jpeg;base64,{}", base64_str), rating)); } + let lazy_gpu_context = if gpu_context.is_none() + && has_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); @@ -1247,16 +1319,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, @@ -1303,9 +1372,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(()); @@ -1314,7 +1380,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, diff --git a/src/App.tsx b/src/App.tsx index 8a355448b..3aecc64f2 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 transformWrapperRef = useRef(null); const isProgrammaticZoom = useRef(false); const currentResRef = useRef(1280); @@ -1666,7 +1667,6 @@ function App() { preloadedDataRef.current = { rootPath: root, currentPath: currentPath, - tree: invoke(Invokes.GetFolderTree, { path: root }), images: invoke(command, { path: currentPath }), }; } @@ -1887,6 +1887,7 @@ function App() { setIsViewLoading(true); setSearchCriteria({ tags: [], text: '', mode: 'OR' }); setLibraryScrollTop(0); + setVisibleThumbnailPaths([]); setThumbnails({}); imageCacheRef.current.clear(); try { @@ -3482,22 +3483,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 { @@ -3509,6 +3494,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); @@ -4767,6 +4774,7 @@ function App() { thumbnailAspectRatio={thumbnailAspectRatio} thumbnails={thumbnails} thumbnailSize={thumbnailSize} + onVisibleThumbnailPathsChange={setVisibleThumbnailPaths} onNavigateToCommunity={() => setActiveView('community')} /> )} @@ -4940,6 +4948,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, }} /> diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index a6bbaef3e..63e8a7f33 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={2} 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 37674b6d4..1aa11e9c4 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -3,22 +3,26 @@ 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) { +const THUMBNAIL_REQUEST_BATCH_SIZE = 16; + +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 +30,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,24 +50,68 @@ 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; - let unlistenProgress: any; + let isCancelled = false; + let nextBatchStartIndex = 0; 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 }); - }); + 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: imagePaths }); + await invokeNextBatch(); } catch (error) { console.error('Failed to invoke thumbnail generation:', error); setLoading(false); @@ -71,14 +121,13 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { setupListenersAndInvoke(); return () => { + isCancelled = true; + invoke('cancel_thumbnail_generation').catch(() => {}); if (unlistenComplete) { unlistenComplete(); } - if (unlistenProgress) { - unlistenProgress(); - } }; - }, [imageList, setThumbnails]); + }, [imageList, requestedPaths, setThumbnails]); return { loading, progress }; -} \ No newline at end of file +}