Compare commits
5 Commits
ebed194f17
..
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e1e89b0f87 | |||
| 0909b58110 | |||
| 4f9ab0b821 | |||
| a06e76c7a7 | |||
| 1e008244ae |
+30
-1
@@ -5,10 +5,23 @@ All notable changes to Phokus are documented here. The format is based on
|
|||||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||||
(0.x: anything may change between minor versions).
|
(0.x: anything may change between minor versions).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Custom multi-folder picker** — replaces the native OS dialog with an
|
||||||
|
in-app folder browser that lets you navigate, stage folders from multiple
|
||||||
|
locations, and add them all in one go. Duplicate roots are skipped
|
||||||
|
automatically; partially-failed batches remove successfully-added entries
|
||||||
|
from the staging panel so only failed folders remain to retry.
|
||||||
|
- **Rebuild semantic index** maintenance action in Settings — drops and
|
||||||
|
recreates the vector tables at the current model dimension, then re-queues
|
||||||
|
every image for embedding. Fixes "dimension mismatch" search errors that
|
||||||
|
occur after switching between CLIP models with different output sizes.
|
||||||
|
- **Video playback settings** — new Video Playback group in Settings with two
|
||||||
|
persisted toggles: "Autoplay in lightbox" (default on) and "Start muted"
|
||||||
|
(default off). Settings apply to the next opened video rather than the
|
||||||
|
current one.
|
||||||
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
|
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
|
||||||
any period in the library. Timeline now loads the full filtered set so the
|
any period in the library. Timeline now loads the full filtered set so the
|
||||||
scrubber spans the whole library instead of just the first page.
|
scrubber spans the whole library instead of just the first page.
|
||||||
@@ -25,6 +38,11 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Settings sections are reordered — General is now the first and default
|
||||||
|
section instead of AI Workspace.
|
||||||
|
- The Duplicate Finder group list is now virtualised — only on-screen cards
|
||||||
|
mount, so large result sets (e.g. 5,000+ pairs) scroll without lag rather
|
||||||
|
than mounting every thumbnail at once.
|
||||||
- The gallery grid is now row-virtualised, so very large libraries scroll
|
- The gallery grid is now row-virtualised, so very large libraries scroll
|
||||||
smoothly and only on-screen thumbnails are rendered.
|
smoothly and only on-screen thumbnails are rendered.
|
||||||
- Polished the new theme surfaces before release, including readable
|
- Polished the new theme surfaces before release, including readable
|
||||||
@@ -35,6 +53,16 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **AVIF thumbnails** — AVIF files are now processed correctly by routing
|
||||||
|
thumbnail generation through the bundled FFmpeg path instead of the Rust
|
||||||
|
image decoder (which has no dav1d dependency). Previously-failed AVIF jobs
|
||||||
|
are requeued on startup; JPEG derivatives are fed to the embedding and
|
||||||
|
tagging pipeline while the lightbox continues to display the original file.
|
||||||
|
- Accent text is now readable in the Subtle Light theme.
|
||||||
|
- Folder picker chevron tooltip now correctly shows "No subfolders" for leaf
|
||||||
|
entries instead of "Open folder" in both branches.
|
||||||
|
- Folder picker Unix breadcrumb root now shows "/" instead of always "Home"
|
||||||
|
for non-home paths such as `/mnt/data`.
|
||||||
- Video embedding jobs are no longer claimed before their thumbnail exists, and
|
- Video embedding jobs are no longer claimed before their thumbnail exists, and
|
||||||
any that previously failed for that reason are requeued on startup — videos no
|
any that previously failed for that reason are requeued on startup — videos no
|
||||||
longer churn through failed embeddings.
|
longer churn through failed embeddings.
|
||||||
@@ -93,4 +121,5 @@ installer with a built-in updater.
|
|||||||
Settings, with live size/reclaimable stats.
|
Settings, with live size/reclaimable stats.
|
||||||
- **Window state** persistence and single-instance handling.
|
- **Window state** persistence and single-instance handling.
|
||||||
|
|
||||||
|
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
|
||||||
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Phokus v0.1.1
|
||||||
|
|
||||||
|
> Draft for the GitHub Release body. Fill in the checksums and trim as needed.
|
||||||
|
|
||||||
|
**Phokus is a local-first desktop media library for Windows** — point it at
|
||||||
|
your image and video folders and it builds a fast, searchable gallery with
|
||||||
|
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
|
||||||
|
cleanup. Everything is processed on your machine; nothing is uploaded.
|
||||||
|
|
||||||
|
This is a quality-of-life release on top of 0.1.0: a new in-app folder
|
||||||
|
picker, themes, smoother scrolling on large libraries, and a batch of fixes.
|
||||||
|
If you're updating from 0.1.0, the built-in updater will fetch this for you.
|
||||||
|
|
||||||
|
## Install / update
|
||||||
|
|
||||||
|
- **Updating from 0.1.0:** the in-app updater will offer 0.1.1 on launch — one
|
||||||
|
click downloads, installs, and relaunches.
|
||||||
|
- **Fresh install:** download `Phokus_0.1.1_x64-setup.exe` below and run it.
|
||||||
|
**Windows SmartScreen will warn** that the publisher is unrecognized — this
|
||||||
|
build is **not code-signed**. Click **More info → Run anyway**.
|
||||||
|
- Requires **Windows 10/11**. WebView2 is fetched automatically if missing.
|
||||||
|
|
||||||
|
NVIDIA users wanting GPU embedding speed can use the
|
||||||
|
`Phokus_0.1.1_x64-cuda-setup.exe` variant instead (larger download; bundles the
|
||||||
|
CUDA runtime DLLs).
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom multi-folder picker** — navigate and stage folders from multiple
|
||||||
|
locations and add them in one go, replacing the native OS dialog.
|
||||||
|
- **Themes** — Phokus, Subtle Light, and Conventional Dark chrome, with an
|
||||||
|
inline theme picker in first-run onboarding.
|
||||||
|
- **Timeline scrubber** — a year/month rail to jump anywhere in the library.
|
||||||
|
- **Folder reordering** in the sidebar (drag-and-drop or keyboard), persisted,
|
||||||
|
plus A–Z / Z–A / Custom sort for the Libraries list.
|
||||||
|
- **Video playback settings** — autoplay-in-lightbox and start-muted toggles.
|
||||||
|
- **Rebuild semantic index** maintenance action — fixes "dimension mismatch"
|
||||||
|
search errors after switching CLIP models.
|
||||||
|
- Locate and filter images with failed AI-tagging jobs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Gallery grid and Duplicate Finder list are now row-virtualised — large
|
||||||
|
libraries and large result sets (5,000+ pairs) scroll without lag.
|
||||||
|
- Settings now open on General by default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **AVIF thumbnails** now generate correctly (routed through bundled FFmpeg);
|
||||||
|
previously-failed AVIF jobs are requeued on startup.
|
||||||
|
- Video embedding jobs no longer churn through failed states before their
|
||||||
|
thumbnail exists; previously-failed ones are requeued.
|
||||||
|
- Timeline scrolling is smooth on large libraries (per-row virtualisation);
|
||||||
|
background batches no longer re-sort the whole loaded set.
|
||||||
|
- Numerous Subtle Light theme readability/parity fixes.
|
||||||
|
|
||||||
|
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
|
||||||
|
for the full list.
|
||||||
|
|
||||||
|
## Checksums
|
||||||
|
|
||||||
|
```
|
||||||
|
SHA-256 (Phokus_0.1.1_x64-setup.exe) = <fill in>
|
||||||
|
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = <fill in>
|
||||||
|
```
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "phokus",
|
"name": "phokus",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -4595,7 +4595,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"candle-core",
|
"candle-core",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Local-first desktop media library"
|
description = "Local-first desktop media library"
|
||||||
authors = ["JezzWTF"]
|
authors = ["JezzWTF"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
|
|||||||
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
||||||
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CaptionAcceleration {
|
pub enum CaptionAcceleration {
|
||||||
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Cpu,
|
Cpu,
|
||||||
Directml,
|
Directml,
|
||||||
@@ -96,17 +97,12 @@ impl CaptionAcceleration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CaptionAcceleration {
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
fn default() -> Self {
|
|
||||||
Self::Auto
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CaptionDetail {
|
pub enum CaptionDetail {
|
||||||
Short,
|
Short,
|
||||||
Detailed,
|
Detailed,
|
||||||
|
#[default]
|
||||||
Paragraph,
|
Paragraph,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +132,6 @@ impl CaptionDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CaptionDetail {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Paragraph
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CaptionModelStatus {
|
pub struct CaptionModelStatus {
|
||||||
pub model_id: &'static str,
|
pub model_id: &'static str,
|
||||||
|
|||||||
+25
-15
@@ -330,7 +330,12 @@ pub async fn add_folders(
|
|||||||
Ok(paths
|
Ok(paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
match add_one_folder(app.clone(), db.inner().clone(), watcher.inner().clone(), path) {
|
match add_one_folder(
|
||||||
|
app.clone(),
|
||||||
|
db.inner().clone(),
|
||||||
|
watcher.inner().clone(),
|
||||||
|
path,
|
||||||
|
) {
|
||||||
Ok(AddOutcome::Added(folder)) => FolderAddResult::Added(folder),
|
Ok(AddOutcome::Added(folder)) => FolderAddResult::Added(folder),
|
||||||
Ok(AddOutcome::Skipped(folder)) => FolderAddResult::Skipped(folder.path),
|
Ok(AddOutcome::Skipped(folder)) => FolderAddResult::Skipped(folder.path),
|
||||||
Err(error) => FolderAddResult::Error(error),
|
Err(error) => FolderAddResult::Error(error),
|
||||||
@@ -365,13 +370,15 @@ fn path_to_string(path: &Path) -> String {
|
|||||||
|
|
||||||
fn directory_has_children(path: &Path) -> bool {
|
fn directory_has_children(path: &Path) -> bool {
|
||||||
std::fs::read_dir(path)
|
std::fs::read_dir(path)
|
||||||
.map(|mut entries| entries.any(|entry| {
|
.map(|mut entries| {
|
||||||
entry
|
entries.any(|entry| {
|
||||||
.ok()
|
entry
|
||||||
.and_then(|entry| entry.file_type().ok())
|
.ok()
|
||||||
.map(|file_type| file_type.is_dir())
|
.and_then(|entry| entry.file_type().ok())
|
||||||
.unwrap_or(false)
|
.map(|file_type| file_type.is_dir())
|
||||||
}))
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +437,7 @@ fn list_roots() -> Vec<DirEntry> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +453,10 @@ fn list_child_directories(path: &Path) -> Result<Vec<DirEntry>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let child_path = entry.path();
|
let child_path = entry.path();
|
||||||
let is_dir = entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false);
|
let is_dir = entry
|
||||||
|
.file_type()
|
||||||
|
.map(|file_type| file_type.is_dir())
|
||||||
|
.unwrap_or(false);
|
||||||
if !is_dir {
|
if !is_dir {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -458,7 +468,7 @@ fn list_child_directories(path: &Path) -> Result<Vec<DirEntry>, String> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,7 +1352,7 @@ pub async fn find_duplicates(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == total {
|
if done.is_multiple_of(100) || done == total {
|
||||||
let _ = app_hash.emit(
|
let _ = app_hash.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1403,7 +1413,7 @@ pub async fn find_duplicates(
|
|||||||
hash_skipped.fetch_add(1, Ordering::Relaxed);
|
hash_skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == c_total {
|
if done.is_multiple_of(100) || done == c_total {
|
||||||
let _ = app_hash.emit(
|
let _ = app_hash.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1492,7 +1502,7 @@ pub async fn find_duplicates(
|
|||||||
confirm_skipped.fetch_add(1, Ordering::Relaxed);
|
confirm_skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == confirming_total {
|
if done.is_multiple_of(100) || done == confirming_total {
|
||||||
let _ = app_confirm.emit(
|
let _ = app_confirm.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1545,7 +1555,7 @@ pub async fn find_duplicates(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Largest duplicates first — wastes the most space
|
// Largest duplicates first — wastes the most space
|
||||||
groups.sort_by(|a, b| b.file_size.cmp(&a.file_size));
|
groups.sort_by_key(|group| std::cmp::Reverse(group.file_size));
|
||||||
|
|
||||||
// Persist results so they survive restart — best-effort, ignore errors.
|
// Persist results so they survive restart — best-effort, ignore errors.
|
||||||
let folder_scope = match folder_id {
|
let folder_scope = match folder_id {
|
||||||
|
|||||||
+55
-7
@@ -114,6 +114,7 @@ pub struct TaggingJob {
|
|||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
pub folder_id: i64,
|
pub folder_id: i64,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -1202,14 +1203,14 @@ fn get_pending_thumbnail_jobs_excluding(
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible
|
/// Video and AVIF thumbnail jobs need FFmpeg; while it isn't provisioned they
|
||||||
/// to both claiming and the tier-priority checks, or pending video jobs would
|
/// must be invisible to both claiming and tier-priority checks, or pending
|
||||||
/// stall every lower tier indefinitely.
|
/// FFmpeg-backed jobs would stall every lower tier indefinitely.
|
||||||
fn media_kind_clause(include_videos: bool) -> &'static str {
|
fn media_kind_clause(include_videos: bool) -> &'static str {
|
||||||
if include_videos {
|
if include_videos {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
"AND i.media_kind = 'image'"
|
"AND i.media_kind = 'image' AND lower(i.path) NOT GLOB '*.avif'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,7 +1594,7 @@ pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
|
|||||||
WHERE status = 'failed'
|
WHERE status = 'failed'
|
||||||
AND last_error LIKE ?1
|
AND last_error LIKE ?1
|
||||||
AND image_id IN (
|
AND image_id IN (
|
||||||
SELECT id FROM images WHERE media_kind = 'video'
|
SELECT id FROM images WHERE media_kind = 'video' OR lower(path) GLOB '*.avif'
|
||||||
)",
|
)",
|
||||||
[pattern],
|
[pattern],
|
||||||
)?;
|
)?;
|
||||||
@@ -1602,12 +1603,56 @@ pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
|
|||||||
SET embedding_status = 'pending', embedding_error = NULL
|
SET embedding_status = 'pending', embedding_error = NULL
|
||||||
WHERE embedding_status = 'failed'
|
WHERE embedding_status = 'failed'
|
||||||
AND embedding_error LIKE ?1
|
AND embedding_error LIKE ?1
|
||||||
AND media_kind = 'video'",
|
AND (media_kind = 'video' OR lower(path) GLOB '*.avif')",
|
||||||
[pattern],
|
[pattern],
|
||||||
)?;
|
)?;
|
||||||
Ok(repaired)
|
Ok(repaired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn repair_avif_jobs(conn: &Connection) -> Result<usize> {
|
||||||
|
let unsupported_pattern = "%Avif%not supported%";
|
||||||
|
let thumbnail_repaired = conn.execute(
|
||||||
|
"UPDATE thumbnail_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
let embedding_repaired = conn.execute(
|
||||||
|
"UPDATE embedding_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images
|
||||||
|
SET embedding_status = 'pending', embedding_error = NULL
|
||||||
|
WHERE embedding_status = 'failed'
|
||||||
|
AND lower(path) GLOB '*.avif'
|
||||||
|
AND embedding_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
let tagging_repaired = conn.execute(
|
||||||
|
"UPDATE tagging_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images
|
||||||
|
SET ai_tagger_error = NULL
|
||||||
|
WHERE lower(path) GLOB '*.avif'
|
||||||
|
AND ai_tagger_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
Ok(thumbnail_repaired + embedding_repaired + tagging_repaired)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
|
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET name = ?2 WHERE id = ?1",
|
"UPDATE folders SET name = ?2 WHERE id = ?1",
|
||||||
@@ -1727,6 +1772,7 @@ pub fn get_images(
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn count_images(
|
pub fn count_images(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
@@ -2101,10 +2147,11 @@ fn get_pending_tagging_jobs_excluding(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<TaggingJob>> {
|
) -> Result<Vec<TaggingJob>> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT j.image_id, i.folder_id, i.path
|
"SELECT j.image_id, i.folder_id, i.path, i.thumbnail_path
|
||||||
FROM tagging_jobs j
|
FROM tagging_jobs j
|
||||||
JOIN images i ON i.id = j.image_id
|
JOIN images i ON i.id = j.image_id
|
||||||
WHERE j.status = 'pending'
|
WHERE j.status = 'pending'
|
||||||
|
AND NOT (lower(i.path) GLOB '*.avif' AND i.thumbnail_path IS NULL)
|
||||||
{}
|
{}
|
||||||
ORDER BY j.created_at ASC
|
ORDER BY j.created_at ASC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
@@ -2117,6 +2164,7 @@ fn get_pending_tagging_jobs_excluding(
|
|||||||
image_id: row.get(0)?,
|
image_id: row.get(0)?,
|
||||||
folder_id: row.get(1)?,
|
folder_id: row.get(1)?,
|
||||||
path: row.get(2)?,
|
path: row.get(2)?,
|
||||||
|
thumbnail_path: row.get(3)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|||||||
@@ -220,19 +220,19 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
|
|||||||
|
|
||||||
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
|
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
|
||||||
///
|
///
|
||||||
/// For videos the thumbnail image is used (because CLIP only understands still images).
|
/// For videos and AVIFs the thumbnail image is used because CLIP preprocessing
|
||||||
/// Video jobs without thumbnails are excluded when jobs are claimed. The error remains
|
/// only uses decoders from the `image` crate, while AVIF is decoded through
|
||||||
/// as a guard against a race where a thumbnail disappears after a job is claimed.
|
/// FFmpeg into a JPEG thumbnail.
|
||||||
pub fn embedding_source_path(
|
pub fn embedding_source_path(
|
||||||
path: &str,
|
path: &str,
|
||||||
thumbnail_path: Option<&str>,
|
thumbnail_path: Option<&str>,
|
||||||
media_kind: &str,
|
media_kind: &str,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
if media_kind == "video" {
|
if media_kind == "video" || is_avif_path(path) {
|
||||||
match thumbnail_path {
|
match thumbnail_path {
|
||||||
Some(thumb) => Ok(PathBuf::from(thumb)),
|
Some(thumb) => Ok(PathBuf::from(thumb)),
|
||||||
None => Err(anyhow::anyhow!(
|
None => Err(anyhow::anyhow!(
|
||||||
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated",
|
"No thumbnail available yet for '{}' — embedding deferred until thumbnail is generated",
|
||||||
std::path::Path::new(path)
|
std::path::Path::new(path)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy())
|
.map(|n| n.to_string_lossy())
|
||||||
@@ -243,3 +243,10 @@ pub fn embedding_source_path(
|
|||||||
Ok(PathBuf::from(path))
|
Ok(PathBuf::from(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_avif_path(path: &str) -> bool {
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -663,10 +663,13 @@ fn process_thumbnail_batch(
|
|||||||
|
|
||||||
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
|
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
|
||||||
jobs.into_iter().partition(|job| job.media_kind == "image");
|
jobs.into_iter().partition(|job| job.media_kind == "image");
|
||||||
|
let (avif_jobs, raster_jobs): (Vec<_>, Vec<_>) = image_jobs
|
||||||
|
.into_iter()
|
||||||
|
.partition(|job| is_avif_path(Path::new(&job.path)));
|
||||||
|
|
||||||
// Images: parallel decode, committed as one batch.
|
// Images: parallel decode, committed as one batch.
|
||||||
if !image_jobs.is_empty() {
|
if !raster_jobs.is_empty() {
|
||||||
let results = image_jobs
|
let results = raster_jobs
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|job| {
|
.map(|job| {
|
||||||
(
|
(
|
||||||
@@ -678,6 +681,15 @@ fn process_thumbnail_batch(
|
|||||||
persist_thumbnail_results(app, pool, results)?;
|
persist_thumbnail_results(app, pool, results)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AVIF: FFmpeg-backed decode, like videos. Keep this off the shared rayon
|
||||||
|
// pool because each subprocess blocks its worker thread.
|
||||||
|
for job in &avif_jobs {
|
||||||
|
let result =
|
||||||
|
thumbnail::generate_avif_thumbnail(media_tools, Path::new(&job.path), cache_dir)
|
||||||
|
.map(Some);
|
||||||
|
persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
|
||||||
|
}
|
||||||
|
|
||||||
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its
|
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its
|
||||||
// thread, and a video-heavy batch on the shared pool would starve image
|
// thread, and a video-heavy batch on the shared pool would starve image
|
||||||
// decoding across all workers. Committed per item so progress keeps
|
// decoding across all workers. Committed per item so progress keeps
|
||||||
@@ -749,6 +761,12 @@ fn persist_thumbnail_results(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_avif_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
|
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
|
||||||
/// the queue was empty.
|
/// the queue was empty.
|
||||||
fn process_metadata_batch(
|
fn process_metadata_batch(
|
||||||
@@ -885,7 +903,7 @@ fn process_embedding_batch(
|
|||||||
// image_id -> early error message for jobs that cannot be embedded yet
|
// image_id -> early error message for jobs that cannot be embedded yet
|
||||||
let mut pre_failed: HashMap<i64, String> = HashMap::new();
|
let mut pre_failed: HashMap<i64, String> = HashMap::new();
|
||||||
|
|
||||||
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() {
|
for (i, (job, result)) in jobs.iter().zip(source_results).enumerate() {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
embeddable_indices.push(i);
|
embeddable_indices.push(i);
|
||||||
@@ -907,16 +925,13 @@ fn process_embedding_batch(
|
|||||||
|
|
||||||
match embedder.embed_images(&embeddable_paths) {
|
match embedder.embed_images(&embeddable_paths) {
|
||||||
Ok(embeddings) => {
|
Ok(embeddings) => {
|
||||||
for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) {
|
for (job, embedding) in embeddable_jobs.iter().zip(embeddings) {
|
||||||
embed_results.insert(job.image_id, Ok(embedding));
|
embed_results.insert(job.image_id, Ok(embedding));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(batch_error) => {
|
Err(batch_error) => {
|
||||||
log::error!("Embedding batch fallback to per-image mode: {batch_error}");
|
log::error!("Embedding batch fallback to per-image mode: {batch_error}");
|
||||||
for (job, source_path) in embeddable_jobs
|
for (job, source_path) in embeddable_jobs.into_iter().zip(embeddable_paths) {
|
||||||
.into_iter()
|
|
||||||
.zip(embeddable_paths.into_iter())
|
|
||||||
{
|
|
||||||
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
|
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1145,9 +1160,14 @@ fn process_tagging_batch(
|
|||||||
let tag_results = jobs
|
let tag_results = jobs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|job| {
|
.map(|job| {
|
||||||
|
let source_path = if is_avif_path(Path::new(&job.path)) {
|
||||||
|
job.thumbnail_path.as_deref().unwrap_or(&job.path)
|
||||||
|
} else {
|
||||||
|
&job.path
|
||||||
|
};
|
||||||
(
|
(
|
||||||
job.clone(),
|
job.clone(),
|
||||||
tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS),
|
tagger_ref.run(Path::new(source_path), tagger::DEFAULT_MAX_TAGS),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub fn run() {
|
|||||||
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
|
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
|
||||||
|
|
||||||
// FFmpeg provisioning happens in the background so the window
|
// FFmpeg provisioning happens in the background so the window
|
||||||
// appears immediately; workers gate video jobs on readiness and
|
// appears immediately; workers gate video/AVIF jobs on readiness and
|
||||||
// the onboarding/Settings UI shows progress and retry.
|
// the onboarding/Settings UI shows progress and retry.
|
||||||
media::spawn_ffmpeg_provision(app.handle().clone());
|
media::spawn_ffmpeg_provision(app.handle().clone());
|
||||||
|
|
||||||
@@ -68,7 +68,12 @@ pub fn run() {
|
|||||||
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
|
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
|
||||||
.expect("Failed to repair deferred embedding jobs");
|
.expect("Failed to repair deferred embedding jobs");
|
||||||
if repaired_deferred > 0 {
|
if repaired_deferred > 0 {
|
||||||
log::info!("Requeued {repaired_deferred} deferred video embedding jobs.");
|
log::info!("Requeued {repaired_deferred} deferred embedding jobs.");
|
||||||
|
}
|
||||||
|
let repaired_avif =
|
||||||
|
db::repair_avif_jobs(&conn).expect("Failed to repair AVIF jobs");
|
||||||
|
if repaired_avif > 0 {
|
||||||
|
log::info!("Requeued {repaired_avif} AVIF jobs.");
|
||||||
}
|
}
|
||||||
let backfilled =
|
let backfilled =
|
||||||
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
|
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
|||||||
// Settings types
|
// Settings types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum TaggerAcceleration {
|
pub enum TaggerAcceleration {
|
||||||
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Cpu,
|
Cpu,
|
||||||
Directml,
|
Directml,
|
||||||
@@ -63,12 +64,6 @@ impl TaggerAcceleration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TaggerAcceleration {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Auto
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status / probe types exposed to the frontend
|
// Status / probe types exposed to the frontend
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -241,6 +241,90 @@ pub fn generate_video_thumbnail(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn generate_avif_thumbnail(
|
||||||
|
tools: &MediaTools,
|
||||||
|
image_path: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
) -> Result<GeneratedThumbnail> {
|
||||||
|
let path_str = image_path.to_string_lossy();
|
||||||
|
let out_path = thumb_path(cache_dir, &path_str);
|
||||||
|
let original_dimensions = ffprobe_dimensions(tools, image_path);
|
||||||
|
|
||||||
|
if out_path.exists() {
|
||||||
|
return Ok(GeneratedThumbnail {
|
||||||
|
path: out_path,
|
||||||
|
width: original_dimensions.0,
|
||||||
|
height: original_dimensions.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = out_path.to_string_lossy().into_owned();
|
||||||
|
let output = tools
|
||||||
|
.ffmpeg_command()
|
||||||
|
.args([
|
||||||
|
"-y",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
|
"-i",
|
||||||
|
path_str.as_ref(),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
|
"-q:v",
|
||||||
|
"4",
|
||||||
|
&output_path,
|
||||||
|
])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if output.status.success() && out_path.exists() {
|
||||||
|
return Ok(GeneratedThumbnail {
|
||||||
|
path: out_path,
|
||||||
|
width: original_dimensions.0,
|
||||||
|
height: original_dimensions.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
|
||||||
|
let output = tools
|
||||||
|
.ffprobe_command()
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0:s=x",
|
||||||
|
&image_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut parts = stdout.trim().split('x');
|
||||||
|
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
|
||||||
|
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
|
||||||
|
(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
||||||
thumb_path_with_ext(cache_dir, image_path, "jpg")
|
thumb_path_with_ext(cache_dir, image_path, "jpg")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user