Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 97 additions & 31 deletions src-tauri/src/file_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf, String> {
let cache_dir = app_handle
Expand All @@ -55,6 +56,23 @@ fn resolve_thumbnail_cache_dir(app_handle: &AppHandle) -> std::result::Result<Pa
Ok(thumb_cache_dir)
}

fn try_extract_embedded_raw_thumbnail(source_path: &Path) -> anyhow::Result<DynamicImage> {
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, &params)?;
let orientation = decoder
.raw_metadata(&rawfile, &params)?
.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",
Expand Down Expand Up @@ -578,6 +596,9 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
}
}

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 {
let modified = fs::metadata(&path_buf)
Expand Down Expand Up @@ -616,7 +637,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
};

let source_path_buf = PathBuf::from(&path_str);
if enable_xmp_sync
if should_sync_xmp_on_list
&& sync_metadata_from_xmp(&source_path_buf, &mut metadata)
&& let Ok(json) = serde_json::to_string_pretty(&metadata)
{
Expand Down Expand Up @@ -693,6 +714,9 @@ pub fn list_images_recursive(
}
}

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 {
let modified = fs::metadata(&path_buf)
Expand Down Expand Up @@ -731,7 +755,7 @@ pub fn list_images_recursive(
};

let source_path_buf = PathBuf::from(&path_str);
if enable_xmp_sync
if should_sync_xmp_on_list
&& sync_metadata_from_xmp(&source_path_buf, &mut metadata)
&& let Ok(json) = serde_json::to_string_pretty(&metadata)
{
Expand Down Expand Up @@ -768,7 +792,10 @@ pub struct FolderNode {
pub image_count: usize,
}

fn scan_dir_and_count(path: &Path) -> Result<(Vec<FolderNode>, usize), std::io::Error> {
fn scan_dir_and_count(
path: &Path,
include_image_counts: bool,
) -> Result<(Vec<FolderNode>, usize), std::io::Error> {
let mut children_folders = Vec::new();
let mut current_dir_image_count = 0;

Expand Down Expand Up @@ -798,10 +825,15 @@ fn scan_dir_and_count(path: &Path) -> Result<(Vec<FolderNode>, usize), std::io::
continue;
}

let (grand_children, sub_dir_own_images) = scan_dir_and_count(&current_path)?;
let (grand_children, sub_dir_own_images) =
scan_dir_and_count(&current_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(),
Expand All @@ -810,23 +842,31 @@ fn scan_dir_and_count(path: &Path) -> Result<(Vec<FolderNode>, usize), std::io::
is_dir: true,
image_count: total_child_count,
});
} else if file_type.is_file() && is_supported_image_file(&current_path) {
} else if include_image_counts
&& file_type.is_file()
&& is_supported_image_file(&current_path)
{
current_dir_image_count += 1;
}
}

Ok((children_folders, current_dir_image_count))
}

fn get_folder_tree_sync(path: String) -> Result<FolderNode, String> {
fn get_folder_tree_sync(path: String, include_image_counts: bool) -> Result<FolderNode, String> {
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
Expand All @@ -842,20 +882,31 @@ fn get_folder_tree_sync(path: String) -> Result<FolderNode, String> {
}

#[tauri::command]
pub async fn get_folder_tree(path: String) -> Result<FolderNode, String> {
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<FolderNode, String> {
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)),
}
}

#[tauri::command]
pub async fn get_pinned_folder_trees(paths: Vec<String>) -> Result<Vec<FolderNode>, String> {
pub async fn get_pinned_folder_trees(
paths: Vec<String>,
app_handle: AppHandle,
) -> Result<Vec<FolderNode>, 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<Result<FolderNode, String>> = 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();
Expand Down Expand Up @@ -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::<AppState>();
const THUMBNAIL_PROCESSING_DIM: u32 = 1280;
Expand Down Expand Up @@ -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::<ImageMetadata>(&content)
.ok()
.map(|m| m.rating)
.unwrap_or(0);
(mod_time, rating_val)
let metadata = serde_json::from_str::<ImageMetadata>(&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();
Expand All @@ -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::<AppState>();
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);
Expand All @@ -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::<AppState>();
let gpu_context = gpu_processing::get_or_init_gpu_context(&state).ok();

let thumbnails: HashMap<String, String> = 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,
Expand Down Expand Up @@ -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::<AppState>();
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(());
Expand All @@ -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,
Expand Down
45 changes: 27 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ function App() {
const [libraryScrollTop, setLibraryScrollTop] = useState<number>(0);
const { showContextMenu } = useContextMenu();
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const { loading: isThumbnailsLoading } = useThumbnails(imageList, setThumbnails);
const [visibleThumbnailPaths, setVisibleThumbnailPaths] = useState<Array<string>>([]);
const { loading: isThumbnailsLoading } = useThumbnails(imageList, thumbnails, setThumbnails, visibleThumbnailPaths);
const transformWrapperRef = useRef<any>(null);
const isProgrammaticZoom = useRef(false);
const currentResRef = useRef<number>(1280);
Expand Down Expand Up @@ -1666,7 +1667,6 @@ function App() {
preloadedDataRef.current = {
rootPath: root,
currentPath: currentPath,
tree: invoke(Invokes.GetFolderTree, { path: root }),
images: invoke(command, { path: currentPath }),
};
}
Expand Down Expand Up @@ -1887,6 +1887,7 @@ function App() {
setIsViewLoading(true);
setSearchCriteria({ tags: [], text: '', mode: 'OR' });
setLibraryScrollTop(0);
setVisibleThumbnailPaths([]);
setThumbnails({});
imageCacheRef.current.clear();
try {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -4767,6 +4774,7 @@ function App() {
thumbnailAspectRatio={thumbnailAspectRatio}
thumbnails={thumbnails}
thumbnailSize={thumbnailSize}
onVisibleThumbnailPathsChange={setVisibleThumbnailPaths}
onNavigateToCommunity={() => setActiveView('community')}
/>
)}
Expand Down Expand Up @@ -4940,6 +4948,7 @@ function App() {
onPaste={() => handlePasteAdjustments()}
onRate={handleRate}
onZoomChange={handleZoomChange}
onVisibleThumbnailPathsChange={setVisibleThumbnailPaths}
rating={adjustments.rating || 0}
selectedImage={selectedImage}
setIsFilmstripVisible={(value: boolean) =>
Expand Down
3 changes: 3 additions & 0 deletions src/components/panel/BottomBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface BottomBarProps {
onPaste(): void;
onRate(rate: number): void;
onReset?(): void;
onVisibleThumbnailPathsChange?(paths: Array<string>): void;
onZoomChange?(zoomValue: number, fitToWindow?: boolean): void;
rating: number;
selectedImage?: SelectedImage;
Expand Down Expand Up @@ -105,6 +106,7 @@ export default function BottomBar({
onPaste,
onRate,
onReset,
onVisibleThumbnailPathsChange,
onZoomChange = () => {},
rating,
selectedImage,
Expand Down Expand Up @@ -244,6 +246,7 @@ export default function BottomBar({
onClearSelection={onClearSelection}
onContextMenu={onContextMenu}
onImageSelect={onImageSelect}
onVisibleThumbnailPathsChange={onVisibleThumbnailPathsChange}
selectedImage={selectedImage}
thumbnails={thumbnails}
thumbnailAspectRatio={thumbnailAspectRatio}
Expand Down
Loading
Loading