diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..803af0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + # windows-latest to match the release target — the ML crates (ort, candle) + # and the NSIS bundle only ever ship from Windows. + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + # tsc + vite build; also produces dist/ which tauri's build script expects + - name: Type-check and build frontend + run: pnpm build:vite + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Rustfmt + working-directory: src-tauri + run: cargo fmt --check + + # --no-default-features: default enables candle-cuda for the main dev + # machine; CI runners have no CUDA toolkit and must build CPU-only. + - name: Clippy + working-directory: src-tauri + run: cargo clippy --all-targets --locked --no-default-features -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..242ea79 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +# Tag pushes mirrored from Gitea (git.jezz.wtf) trigger this on the GitHub +# mirror. Builds the NSIS installer and attaches it to a DRAFT GitHub Release — +# review and publish manually. +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + release: + permissions: + contents: write + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Build and create draft release + uses: tauri-apps/tauri-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Updater artifact signing (Phase 3) — set these repo secrets once + # the updater keypair exists; empty values are ignored until then. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: 'Phokus v__VERSION__' + releaseBody: 'See the assets below to download and install this version.' + releaseDraft: true + prerelease: false + uploadUpdaterJson: true + updaterJsonPreferNsis: true + # Cargo args after `--`: ship the CPU/DirectML build — default + # features enable candle-cuda, which runners (and most users) lack. + args: '-- --no-default-features' diff --git a/.gitignore b/.gitignore index 7e7224f..df5794f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ dist-ssr *.json *.pyc + +# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit +# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant". +src-tauri/cuda-redist/ + +# Keep the CUDA build config overlay tracked despite the *.json rule above. +!src-tauri/tauri.cuda.conf.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..358d6db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to Phokus are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +(0.x: anything may change between minor versions). + +## [0.1.0] — Unreleased + +First public release. Windows desktop, distributed as an unsigned NSIS +installer with a built-in updater. + +### Added + +- **Local media library** — add folders, recursive background indexing with + live progress, and a filesystem watcher that keeps the library in sync as + files are added, edited, moved, renamed, or removed (thumbnails and + embeddings are preserved across renames). +- **Gallery** — virtualized grid (handles very large libraries), favorites, + star ratings, video durations; filter by folder, type, favorites, or + rating; sort by date added, date taken (EXIF), name, size, rating, or + duration; compact / comfortable / detail density. +- **Search** — filename, semantic (`/s`, via CLIP visual embeddings), and tag + (`/t`) search from one prefix-aware search bar. +- **Discovery** — similar-image search (by image or selected region), an + Explore view with a visual cluster map and tag cloud, and a Timeline grouped + by EXIF capture date. Explore, Timeline, and Duplicates are folder-scopable + from their headers. +- **Lightbox** — keyboard navigation, zoom, pan, inline tag editing, and + rating controls, plus a custom edge-to-edge video player (scrubbing, volume, + speed, loop, fullscreen, keyboard support). +- **AI tagging** — WD tagger (ONNX, CPU/DirectML) with adjustable confidence + threshold, batch size, and per-folder queue targeting. Optional. +- **Duplicate finder** — three-phase exact-duplicate scan + (size → sample hash → full hash) with live progress and bulk delete. +- **Background pipeline** — strict-priority workers (thumbnails → metadata → + embeddings → tags) with per-folder pausing from the sidebar context menu or + the background-tasks bar. +- **Guided first-run onboarding** — background FFmpeg provisioning with live + progress and retry, a walkthrough of the library, pipeline, search modes, + views, and updates, plus an optional AI-tagger download. Re-runnable from + Settings. +- **Updater** — checks GitHub Releases on launch and from Settings; a title-bar + indicator lights up when a new version is ready, and one click downloads, + installs the signed update, and relaunches. +- **Maintenance** — database compaction and orphaned-thumbnail cleanup from + Settings, with live size/reclaimable stats. +- **Window state** persistence and single-instance handling. + +[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9e0202 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JezzWTF + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6ee6ac6..457480a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,55 @@ A local-first desktop media library for browsing, filtering, and curating image | jpg, jpeg, png, gif, bmp | mp4, mov, m4v | | tiff, tif, webp, avif, heic, heif | webm | +## Installation + +Phokus is a **Windows desktop app**. Download the latest installer from the +[Releases page](https://github.com/JezzWTF/phokus/releases/latest) and run it. + +**Requirements:** Windows 10 or 11. The installer will fetch the WebView2 +runtime automatically if it isn't already present (it ships with Windows 11). + +### A note on the unsigned build + +Phokus 0.1.0 installers are **not code-signed**. Code-signing certificates are +an ongoing expense that's hard to justify for an unfunded open-source project, +so signing is on the roadmap rather than in place today. + +In practice this means Windows SmartScreen will show a blue +**"Windows protected your PC"** warning the first time you run the installer. +To proceed: click **More info → Run anyway**. If a release publishes a SHA-256 +checksum, you can verify the download against it first. + +### First run + +On first launch Phokus downloads a few tools and models — all one-time, and all +processed on your machine: + +- **FFmpeg** (~tens of MB) for video thumbnails and metadata — downloaded in the + background; the guided first-run tour shows progress. +- **WD tagger model** (~1.3 GB) — **optional**, only if you enable AI tagging. +- **CLIP embedding model** (~580 MB) — downloaded automatically the first time + visual embeddings run (powers semantic search and similar images). + +## Privacy & local-first + +Phokus is local-first by design. **Your media and everything derived from it — +thumbnails, embeddings, tags, ratings — never leave your machine.** There is no +account, no telemetry, and no cloud sync. + +The only network activity is: + +- **One-time tool/model downloads** on first use (FFmpeg, the CLIP and WD + models, and the ONNX runtime), fetched from their official sources + (FFmpeg builds, Hugging Face, NuGet). These pull *tools to your machine* — + none of your images are uploaded. +- **Update checks** against the GitHub Releases page, so the app can offer new + versions. This can be ignored if you never update. + +Everything Phokus stores lives in its app-data directory (`gallery.db`, +`thumbnails/`, `models/`, `settings/`). Removing that directory resets the app +completely; your original media folders are never modified. + ## Stack - Tauri 2 + Rust backend diff --git a/branding/phokus-aperture.svg b/branding/phokus-aperture.svg new file mode 100644 index 0000000..df554c4 --- /dev/null +++ b/branding/phokus-aperture.svg @@ -0,0 +1,43 @@ + + Phokus + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/release-notes-0.1.0.md b/docs/release-notes-0.1.0.md new file mode 100644 index 0000000..993d186 --- /dev/null +++ b/docs/release-notes-0.1.0.md @@ -0,0 +1,59 @@ +# Phokus v0.1.0 + +> Draft for the GitHub Release body. Paste into the release, fill in the +> checksum, 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 the **first public release**. Expect rough edges, and please file +issues. + +## Install + +1. Download `Phokus_0.1.0_x64-setup.exe` below. +2. Run it. **Windows SmartScreen will warn** that the publisher is + unrecognized — this build is **not code-signed** (cost; signing is on the + roadmap). Click **More info → Run anyway** to proceed. +3. Requires **Windows 10/11**. The installer fetches the WebView2 runtime + automatically if needed. + +On first launch, Phokus runs a short guided tour and downloads FFmpeg in the +background (one-time, ~tens of MB). AI tagging (~1.3 GB) is optional; the CLIP +model for semantic search (~580 MB) downloads automatically the first time +embeddings run. **All of this stays on your machine.** + +## Highlights + +- Virtualized gallery for very large libraries; favorites, ratings, filters, sorts +- Filename, semantic (`/s`), and tag (`/t`) search +- Similar-image search, Explore clusters + tag cloud, EXIF Timeline +- Lightbox with zoom/pan and a custom video player +- Optional on-device AI tagging (WD tagger) +- Exact-duplicate finder with bulk delete +- Built-in updater + +See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md) +for the full list. + +## Privacy + +Your media and all derived data (thumbnails, embeddings, tags, ratings) never +leave your machine. No account, no telemetry, no cloud. The only network +activity is the one-time tool/model downloads above and update checks against +this Releases page. + +## Known limitations + +- **Unsigned installer** — SmartScreen warning as described above. +- **Windows only** for now. +- CPU/DirectML inference in the shipped build; very large libraries take time + to embed on first index. + +## Verify your download (optional) + +``` +SHA-256 (Phokus_0.1.0_x64-setup.exe) = +``` diff --git a/package.json b/package.json index 542a05d..b6d5842 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,16 @@ "name": "phokus", "private": true, "version": "0.1.0", + "license": "MIT", "type": "module", "scripts": { "build:app": "tauri build", "build:vite": "tsc && vite build", "clean:app": "cd src-tauri && cargo clean", "dev:app": "tauri dev", + "dev:app:cpu": "tauri dev -- --no-default-features", + "build:app:cpu": "tauri build -- --no-default-features", + "build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json", "dev:vite": "vite", "preview": "vite preview", "tauri": "tauri" @@ -19,6 +23,8 @@ "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "d3-force": "^3.0.0", "framer-motion": "^12.38.0", "react": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec0add6..4192a50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 d3-force: specifier: ^3.0.0 version: 3.0.0 @@ -662,6 +668,12 @@ packages: '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1462,6 +1474,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index df4ef2c..52ec1be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -52,6 +63,23 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -378,6 +406,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -409,6 +449,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "8.0.2" @@ -436,6 +500,40 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -1458,6 +1556,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -1482,7 +1590,7 @@ checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 1.0.1", "jiff", "log", ] @@ -1616,6 +1724,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "ffmpeg-sidecar" version = "2.5.0" @@ -1767,6 +1884,12 @@ dependencies = [ "libc", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2462,6 +2585,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2469,7 +2595,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -3345,6 +3471,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lzma-rust2" @@ -3495,6 +3624,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3890,6 +4025,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3986,6 +4130,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -4155,6 +4311,20 @@ dependencies = [ "ureq", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -4456,12 +4626,16 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-plugin-window-state", "tokenizers", "tokio", "ureq", - "uuid", "walkdir", "xxhash-rust", "zip 4.6.1", @@ -4672,6 +4846,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pulp" version = "0.21.5" @@ -4782,6 +4976,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -5055,6 +5255,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -5112,15 +5321,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -5179,6 +5393,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -5193,6 +5436,23 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5236,6 +5496,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -5245,6 +5517,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -5373,6 +5672,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "3.7.0" @@ -5670,6 +5975,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -6021,6 +6332,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -6212,6 +6529,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -6253,6 +6592,79 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -6461,7 +6873,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -6494,13 +6908,28 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokenizers" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ - "ahash", + "ahash 0.8.12", "aho-corasick", "compact_str", "dary_heap", @@ -6537,25 +6966,11 @@ dependencies = [ "bytes", "libc", "mio 1.2.0", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -7020,6 +7435,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -7051,6 +7472,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -8159,6 +8586,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 84a8156..be7b68f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "phokus" version = "0.1.0" -description = "A performant image gallery application" +description = "Local-first desktop media library" authors = ["JezzWTF"] +license = "MIT" edition = "2021" [lib] @@ -13,7 +14,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [features] -default = [] +# CUDA is on by default for the main dev machine; build with +# `--no-default-features` (pnpm dev:app:cpu) on machines without the toolkit. +default = ["candle-cuda"] candle-cuda = ["candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"] [dependencies] @@ -32,18 +35,16 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", fast_image_resize = { version = "6.0.0", features = ["image"] } walkdir = "2" rayon = "1" -tokio = { version = "1", features = ["full"] } -chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1", features = ["v4"] } +tokio = { version = "1", features = ["rt-multi-thread"] } +chrono = "0.4" anyhow = "1" -log = "0.4" ffmpeg-sidecar = "2.5.0" xxhash-rust = { version = "0.8", features = ["xxh3"] } memmap2 = "0.9" sysinfo = "0.38.4" -candle-core = { version = "0.10.2", features = ["cuda"] } -candle-nn = { version = "0.10.2", features = ["cuda"] } -candle-transformers = { version = "0.10.2", features = ["cuda"] } +candle-core = "0.10.2" +candle-nn = "0.10.2" +candle-transformers = "0.10.2" hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] } tokenizers = "0.22.1" ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "download-binaries", "copy-dylibs", "load-dynamic", "directml", "api-24", "tls-native"] } @@ -54,6 +55,12 @@ kamadak-exif = "0.5" notify = "6" tauri-plugin-notification = "2" mozjpeg = "0.10.13" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +tauri-plugin-log = "2" +tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" +log = "0.4" # ── Dev-mode performance ──────────────────────────────────────────────────── # opt-level=1 on the main crate keeps incremental compile short. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f6be0ab..48bbd5b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -15,13 +15,17 @@ fn main() { if let Ok(path) = &cuda_path { println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled."); } else { - println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."); + println!( + "cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled." + ); } } else { println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found."); println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads"); - println!("cargo:warning= Or build without GPU support: cargo build --no-default-features"); + println!( + "cargo:warning= Or build without GPU support: cargo build --no-default-features" + ); println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a526b2c..93b55f7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,8 @@ "fs:read-files", "fs:read-dirs", "notification:default", + "updater:default", + "process:allow-restart", "core:window:allow-minimize", "core:window:allow-close", "core:window:allow-toggle-maximize", diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..3ac46c3 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..bed3505 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..765acfc 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..e08a3b1 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..d1cb051 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..9ebbb89 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..a426964 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..bf6c34a 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..d403045 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..2ef15e8 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..f1b1cc1 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..3a7d498 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..fbc1726 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..e90612b 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..81434ec 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..a79f9da 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/captioner.rs b/src-tauri/src/captioner.rs index aea7833..b97da92 100644 --- a/src-tauri/src/captioner.rs +++ b/src-tauri/src/captioner.rs @@ -8,13 +8,20 @@ use ort::session::{builder::GraphOptimizationLevel, Session}; use ort::value::{Shape, Tensor}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::io::{Cursor, Read}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::OnceLock; +use std::sync::Mutex; use std::time::Instant; use tokenizers::Tokenizer; +// Suppress the console window when spawning curl.exe from the GUI app. +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft"; pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4"; const ONNX_RUNTIME_NUGET_URL: &str = @@ -62,7 +69,10 @@ const REQUIRED_FILES: &[&str] = &[ "onnx/embed_tokens_fp16.onnx", ]; -static ORT_RUNTIME_INIT: OnceLock> = OnceLock::new(); +// Mutex rather than OnceLock: a failed attempt (DLL not yet +// downloaded) must NOT be cached, or a later successful download could never +// recover within the same app session. +static ORT_RUNTIME_INIT: Mutex = Mutex::new(false); /// Set to `true` by `set_caption_acceleration` so the caption worker loop /// knows to drop its cached `FlorenceCaptioner` and reload with the new EP. @@ -470,7 +480,7 @@ impl FlorenceCaptioner { acceleration, false, )?; - println!( + log::info!( "Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})", sessions_started_at.elapsed(), acceleration, @@ -490,9 +500,9 @@ impl FlorenceCaptioner { pub fn generate(&mut self, image_path: &Path) -> Result { let started_at = Instant::now(); - println!("Florence caption started: {}", image_path.display()); + log::info!("Florence caption started: {}", image_path.display()); let image_features = run_vision_encoder(&mut self.vision_session, image_path)?; - println!("Florence vision encoder done in {:?}", started_at.elapsed()); + log::info!("Florence vision encoder done in {:?}", started_at.elapsed()); let prompt_ids = self .tokenizer .encode(self.caption_detail.prompt(), false) @@ -502,7 +512,7 @@ impl FlorenceCaptioner { .map(|id| i64::from(*id)) .collect::>(); let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?; - println!( + log::info!( "Florence token embeddings done in {:?}", started_at.elapsed() ); @@ -513,7 +523,7 @@ impl FlorenceCaptioner { &encoder_embeds, &encoder_attention_mask, )?; - println!("Florence encoder done in {:?}", started_at.elapsed()); + log::info!("Florence encoder done in {:?}", started_at.elapsed()); let generated_ids = run_decoder( &mut self.decoder_prefill_session, @@ -523,7 +533,7 @@ impl FlorenceCaptioner { &encoder_attention_mask, self.caption_detail.max_new_tokens(), )?; - println!("Florence decoder done in {:?}", started_at.elapsed()); + log::info!("Florence decoder done in {:?}", started_at.elapsed()); let generated_u32 = generated_ids .into_iter() @@ -648,23 +658,61 @@ fn probe_vision_session( } pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> { + let mut initialized = ORT_RUNTIME_INIT + .lock() + .map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?; + if *initialized { + return Ok(()); + } let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE); - ORT_RUNTIME_INIT - .get_or_init(|| { - if !dll_path.exists() { - return Err(format!( - "ONNX Runtime DLL is missing: {}", - dll_path.display() - )); - } - ort::environment::init_from(&dll_path) - .map_err(|error| error.to_string())? - .with_name("phokus-florence") - .commit(); - Ok(()) - }) - .clone() - .map_err(anyhow::Error::msg) + if !dll_path.exists() { + anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display()); + } + ort::environment::init_from(&dll_path) + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .with_name("phokus-florence") + .commit(); + *initialized = true; + Ok(()) +} + +/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file +/// byte progress as `(short_label, downloaded_bytes, total_bytes)`. +/// `total_bytes` is `None` when the server omits Content-Length. Unlike +/// `ensure_onnx_runtime` (init only), this actually provisions the files — +/// callers that can run on a clean install must call this first. The callback +/// fires per chunk; callers should throttle. +pub fn provision_onnx_runtime_with_progress( + local_dir: &Path, + mut on_progress: impl FnMut(&str, u64, Option), +) -> Result<()> { + for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES { + let destination = local_dir.join(destination_file); + if destination.exists() { + continue; + } + // Strip the "onnxruntime/" prefix for a clean label. + let label = destination_file + .rsplit('/') + .next() + .unwrap_or(destination_file); + download_nuget_file( + source_url, + archive_path, + &destination, + |downloaded, total| on_progress(label, downloaded, total), + )?; + } + Ok(()) +} + +/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress +/// step counts before downloading). +pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize { + ONNX_RUNTIME_FILES + .iter() + .filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists()) + .count() } fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> { @@ -676,35 +724,218 @@ fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> { for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES { let destination = local_dir.join(destination_file); - download_nuget_file(source_url, archive_path, &destination)?; + download_nuget_file(source_url, archive_path, &destination, |_, _| {})?; } Ok(()) } -fn download_nuget_file(source_url: &str, archive_path: &str, destination: &Path) -> Result<()> { - let mut response = ureq::get(source_url) - .call() - .map_err(|error| anyhow::anyhow!("{error}"))?; - let mut bytes = Vec::new(); - response - .body_mut() - .as_reader() - .read_to_end(&mut bytes) - .map_err(|error| anyhow::anyhow!("{error}"))?; +// Give up only after this many *consecutive* curl runs that download nothing; +// a run that makes any progress resets the counter, so a large file completes +// across however many resumes it takes. Kept low so a hard stall (e.g. a +// broken VM NIC) fails in a couple of minutes — surfacing a retryable error — +// rather than locking the UI on "preparing" for many minutes. +const MAX_STALL_RETRIES: usize = 3; - let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?; - let mut dll = archive.by_name(archive_path)?; +/// Resiliently download `url` to `destination` using the system `curl.exe`. +/// +/// ureq's read timeout does not fire on a stalled large transfer on Windows +/// (schannel doesn't honor the socket read timeout), so a stall there hangs +/// forever. curl detects an inactivity stall (`--speed-time`), resumes from +/// the partial file (`-C -`), and retries internally — the same behavior a +/// browser gets. We monitor the `.part` file's size for the progress bar and +/// wrap curl in an outer progress-aware retry as a backstop. The partial file +/// survives an app restart, so a later retry continues from disk. +pub fn download_file_resilient( + url: &str, + destination: &Path, + mut on_progress: impl FnMut(u64, Option), +) -> Result<()> { if let Some(parent) = destination.parent() { std::fs::create_dir_all(parent)?; } + let part = match destination.extension() { + Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())), + None => destination.with_extension("part"), + }; + let name = destination + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| url.to_string()); + log::info!("{name}: resolving download size"); + let total = remote_content_length(url); + + // Reconcile any existing `.part` against the real size: exactly complete → + // finish; oversized (stale/corrupt) → discard so curl restarts cleanly + // (otherwise `curl -C -` would 416 forever). + if let Some(total) = total { + let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if size == total { + std::fs::rename(&part, destination)?; + return Ok(()); + } + if size > total { + let _ = std::fs::remove_file(&part); + } + } + log::info!( + "{name}: downloading via curl ({} bytes)", + total + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown size".into()) + ); + + let mut stalls = 0usize; + loop { + let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + match run_curl_download(url, &part, total, &mut on_progress) { + Ok(()) => break, + Err(error) => { + let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if after > before { + log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}"); + stalls = 0; + } else { + stalls += 1; + log::warn!( + "{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}" + ); + if stalls >= MAX_STALL_RETRIES { + // Discard the partial so a future attempt restarts clean + // rather than getting stuck re-resuming a bad file. + let _ = std::fs::remove_file(&part); + return Err(error); + } + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + } + } + + if let Some(total) = total { + let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if got < total { + anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)"); + } + } + std::fs::rename(&part, destination)?; + Ok(()) +} + +/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total +/// from the `Content-Range: bytes 0-0/` header. Uses curl rather than +/// ureq so no part of the download path depends on ureq (which hangs on this +/// VM's TLS stack). Returns None if the server doesn't report a size. +fn remote_content_length(url: &str) -> Option { + let mut command = Command::new("curl.exe"); + command.args([ + "-sL", + "-r", + "0-0", + "-D", + "-", + "-o", + "NUL", + "--connect-timeout", + "30", + "--max-time", + "30", + url, + ]); + #[cfg(target_os = "windows")] + command.creation_flags(CREATE_NO_WINDOW); + let output = command.output().ok()?; + let headers = String::from_utf8_lossy(&output.stdout); + for line in headers.lines() { + if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") { + if let Some(total) = rest.rsplit('/').next().map(str::trim) { + if let Ok(n) = total.parse::() { + return Some(n); + } + } + } + } + None +} + +/// Run one `curl.exe` download to `dest`, resuming from any partial file, while +/// reporting progress from the growing file size. Returns an error (leaving the +/// partial in place) if curl exits non-zero. +fn run_curl_download( + url: &str, + dest: &Path, + total: Option, + on_progress: &mut impl FnMut(u64, Option), +) -> Result<()> { + let mut command = Command::new("curl.exe"); + command + .arg("-fSL") // fail on HTTP errors, follow redirects, show errors + .args(["-C", "-"]) // resume from the existing output file + .args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"]) + .args(["--connect-timeout", "30"]) + // Abort (then --retry resumes) if under 1 KB/s for 30s — a real + // inactivity timeout, which is what ureq couldn't deliver here. + .args(["--speed-limit", "1024", "--speed-time", "30"]) + .arg("-s") // no progress meter (we watch the file instead) + .arg("-o") + .arg(dest) + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + #[cfg(target_os = "windows")] + command.creation_flags(CREATE_NO_WINDOW); + + let mut child = command + .spawn() + .map_err(|e| anyhow::anyhow!("failed to launch curl.exe (required for downloads): {e}"))?; + + loop { + if let Some(status) = child.try_wait()? { + if status.success() { + return Ok(()); + } + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_string(&mut stderr); + } + anyhow::bail!( + "curl exited with {}: {}", + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()), + stderr.trim() + ); + } + let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0); + on_progress(downloaded, total); + std::thread::sleep(std::time::Duration::from_millis(300)); + } +} + +fn download_nuget_file( + source_url: &str, + archive_path: &str, + destination: &Path, + on_progress: impl FnMut(u64, Option), +) -> Result<()> { + // Download the .nupkg (a zip) resiliently, then extract the one DLL. + let package = destination.with_extension("nupkg"); + download_file_resilient(source_url, &package, on_progress)?; + + log::info!("extracting {archive_path} from package"); + let file = std::fs::File::open(&package)?; + let mut archive = zip::ZipArchive::new(file)?; + let mut dll = archive.by_name(archive_path)?; let temp_destination = destination.with_extension("tmp"); { - let mut file = std::fs::File::create(&temp_destination)?; - std::io::copy(&mut dll, &mut file)?; + let mut out = std::fs::File::create(&temp_destination)?; + std::io::copy(&mut dll, &mut out)?; } - std::fs::rename(temp_destination, destination)?; + std::fs::rename(&temp_destination, destination)?; + let _ = std::fs::remove_file(&package); + log::info!("extracted {archive_path}"); Ok(()) } @@ -784,7 +1015,7 @@ fn run_decoder( "inputs_embeds" => prefill_inputs_embeds_tensor }) .map_err(|error| anyhow::anyhow!("{error}"))?; - println!( + log::info!( "Florence decoder prefill done in {:?}", prefill_started_at.elapsed() ); @@ -848,7 +1079,7 @@ fn run_decoder( decoder_kv = collect_present_key_values(&outputs, DECODER_LAYERS)?; } - println!("Florence decoder produced {} token(s)", generated.len()); + log::info!("Florence decoder produced {} token(s)", generated.len()); Ok(generated) } @@ -886,7 +1117,7 @@ fn create_session( CaptionAcceleration::Auto | CaptionAcceleration::Directml => { // `allow_directml` is false for the 4 text/decoder sessions — // they intentionally run on CPU regardless of the setting. - println!( + log::info!( "Florence: using CPU for {} (DirectML disabled for this session type)", path.display() ); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3451f92..77cd5ab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -227,6 +227,14 @@ pub async fn add_folder( return Err("Path is not a valid directory".into()); } + // Let the webview load media from this folder via the asset protocol. + if let Err(error) = app + .asset_protocol_scope() + .allow_directory(&folder_path, true) + { + log::error!("Failed to allow asset scope for {path}: {error}"); + } + let name = folder_path .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -395,8 +403,17 @@ pub async fn update_folder_path( ) -> Result<(), String> { let new_path_buf = PathBuf::from(&new_path); if !new_path_buf.is_dir() { - return Err(format!("Path is not a valid directory: {}", new_path)); + return Err(format!("Path is not a valid directory: {new_path}")); } + + // Let the webview load media from the relocated folder via the asset protocol. + if let Err(error) = app + .asset_protocol_scope() + .allow_directory(&new_path_buf, true) + { + log::error!("Failed to allow asset scope for {new_path}: {error}"); + } + let new_name = new_path_buf .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -445,7 +462,7 @@ pub async fn find_similar_images( offset, limit + 1, ) - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())?; let has_more = matches.len() > limit; let image_ids = matches .into_iter() @@ -499,8 +516,9 @@ pub async fn find_similar_by_region( None => { // Fetch one extra candidate to compensate for the source image that // will be removed, so has_more is accurate and results span multiple pages. - let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) - .map_err(|e| e.to_string())?; + let mut ids = + vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) + .map_err(|e| e.to_string())?; ids.retain(|&id| id != params.image_id); ids } @@ -575,7 +593,7 @@ pub async fn semantic_search_images( let has_filters = params.folder_id.is_some() || params.media_kind.is_some() || params.favorites_only.unwrap_or(false) - || params.rating_min.map_or(false, |r| r > 0); + || params.rating_min.is_some_and(|r| r > 0); if !has_filters { // No post-query filtering — a single fetch of exactly `limit` results is optimal. @@ -639,7 +657,12 @@ pub async fn search_images_by_tag( offset, ) .map_err(|e| e.to_string())?; - Ok(TagSearchPage { images, total, offset, limit }) + Ok(TagSearchPage { + images, + total, + offset, + limit, + }) } #[tauri::command] @@ -835,7 +858,6 @@ pub struct TagCloudEntry { pub image_ids: Vec, } - /// Clusters the library's image embeddings with k-means and returns one representative /// image per cluster — the member whose embedding is closest to its cluster centroid. /// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated @@ -872,7 +894,7 @@ pub async fn get_tag_cloud( hasher.digest() }; let folder_scope = match folder_id { - Some(id) => format!("folder_{}", id), + Some(id) => format!("folder_{id}"), None => "all".to_string(), }; @@ -957,7 +979,8 @@ pub async fn get_explore_tags( params: GetExploreTagsParams, ) -> Result, String> { let conn = db.get().map_err(|e| e.to_string())?; - db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)).map_err(|e| e.to_string()) + db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -969,8 +992,13 @@ pub async fn search_tags_autocomplete( return Ok(vec![]); } let conn = db.get().map_err(|e| e.to_string())?; - db::search_tags_autocomplete(&conn, ¶ms.query, params.folder_id, params.limit.unwrap_or(10)) - .map_err(|e| e.to_string()) + db::search_tags_autocomplete( + &conn, + ¶ms.query, + params.folder_id, + params.limit.unwrap_or(10), + ) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -985,12 +1013,15 @@ pub async fn find_duplicates( }; let total = records.len(); - let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "checking".to_string(), - processed: 0, - total, - skipped: 0, - }); + let _ = app.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "checking".to_string(), + processed: 0, + total, + skipped: 0, + }, + ); // Three-phase detection. // @@ -1007,126 +1038,140 @@ pub async fn find_duplicates( // For sample-hash groups that contain files > 64 KB, compute a full-file // hash to confirm the match before presenting them as deletable duplicates. let app_hash = app.clone(); - let (pairs, skipped_before_confirm, candidate_total): - (Vec<(u64, u64, i64, String)>, usize, usize) = tokio::task::spawn_blocking(move || { - use memmap2::Mmap; - use rayon::prelude::*; - use std::collections::HashMap; - use std::sync::atomic::{AtomicUsize, Ordering}; - use xxhash_rust::xxh3::{xxh3_64, Xxh3}; + // (size, sample/full hash, image_id, path) for each confirmed candidate. + type HashedCandidate = (u64, u64, i64, String); + let (pairs, skipped_before_confirm, candidate_total): (Vec, usize, usize) = + tokio::task::spawn_blocking(move || { + use memmap2::Mmap; + use rayon::prelude::*; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use xxhash_rust::xxh3::{xxh3_64, Xxh3}; - const WINDOW: usize = 16 * 1024; // 16 KB per sample window - const N: usize = 4; // windows at 0%, 33%, 66%, 100% - const COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold + const WINDOW: usize = 16 * 1024; // 16 KB per sample window + const N: usize = 4; // windows at 0%, 33%, 66%, 100% + const COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold - // Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is - // equivalent to hashing the entire file. - fn sample(mmap: &[u8]) -> u64 { - if mmap.len() <= COVERED { - return xxh3_64(mmap); + // Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is + // equivalent to hashing the entire file. + fn sample(mmap: &[u8]) -> u64 { + if mmap.len() <= COVERED { + return xxh3_64(mmap); + } + let mut h = Xxh3::new(); + for i in 0..N { + let pos = (mmap.len() - WINDOW) * i / (N - 1); + h.update(&mmap[pos..pos + WINDOW]); + } + h.digest() } - let mut h = Xxh3::new(); - for i in 0..N { - let pos = (mmap.len() - WINDOW) * i / (N - 1); - h.update(&mmap[pos..pos + WINDOW]); - } - h.digest() - } - // ── Phase 1: stat ───────────────────────────────────────────────── - let stat_counter = AtomicUsize::new(0); - let stat_skipped = AtomicUsize::new(0); - let sized: Vec<(i64, String, u64)> = records - .par_iter() - .filter_map(|r| { - let result = match std::fs::metadata(&r.path) { - Ok(metadata) => Some((r.id, r.path.clone(), metadata.len())), - Err(_) => { - stat_skipped.fetch_add(1, Ordering::Relaxed); - None + // ── Phase 1: stat ───────────────────────────────────────────────── + let stat_counter = AtomicUsize::new(0); + let stat_skipped = AtomicUsize::new(0); + let sized: Vec<(i64, String, u64)> = records + .par_iter() + .filter_map(|r| { + let result = match std::fs::metadata(&r.path) { + Ok(metadata) => Some((r.id, r.path.clone(), metadata.len())), + Err(_) => { + stat_skipped.fetch_add(1, Ordering::Relaxed); + None + } + }; + let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1; + if done % 100 == 0 || done == total { + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "checking".to_string(), + processed: done, + total, + skipped: stat_skipped.load(Ordering::Relaxed), + }, + ); } - }; - let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1; - if done % 100 == 0 || done == total { - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "checking".to_string(), - processed: done, - total, - skipped: stat_skipped.load(Ordering::Relaxed), - }); - } - result - }) - .collect(); - let stat_skipped = stat_skipped.load(Ordering::Relaxed); + result + }) + .collect(); + let stat_skipped = stat_skipped.load(Ordering::Relaxed); - let mut by_size: HashMap> = HashMap::new(); - for (i, (_, _, size)) in sized.iter().enumerate() { - by_size.entry(*size).or_default().push(i); - } - let candidates: Vec = by_size - .into_values() - .filter(|g| g.len() > 1) - .flatten() - .collect(); + let mut by_size: HashMap> = HashMap::new(); + for (i, (_, _, size)) in sized.iter().enumerate() { + by_size.entry(*size).or_default().push(i); + } + let candidates: Vec = by_size + .into_values() + .filter(|g| g.len() > 1) + .flatten() + .collect(); - if candidates.is_empty() { - return (vec![], stat_skipped, 0); - } + if candidates.is_empty() { + return (vec![], stat_skipped, 0); + } - // ── Phase 2: sample hash ────────────────────────────────────────── - let c_total = candidates.len(); - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "hashing".to_string(), - processed: 0, - total: c_total, - skipped: stat_skipped, - }); - let counter = AtomicUsize::new(0); - let hash_skipped = AtomicUsize::new(0); + // ── Phase 2: sample hash ────────────────────────────────────────── + let c_total = candidates.len(); + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "hashing".to_string(), + processed: 0, + total: c_total, + skipped: stat_skipped, + }, + ); + let counter = AtomicUsize::new(0); + let hash_skipped = AtomicUsize::new(0); - let pairs = candidates - .par_iter() - .filter_map(|&idx| { - let (id, path, size) = &sized[idx]; - let result = if *size == 0 { - Some((xxh3_64(&[]), *size, *id, path.clone())) - } else { - std::fs::File::open(path) - .ok() - // SAFETY: read-only; no external truncation expected during scan. - .and_then(|file| unsafe { Mmap::map(&file).ok() }) - .map(|mmap| (sample(&mmap), *size, *id, path.clone())) - }; - if result.is_none() { - hash_skipped.fetch_add(1, Ordering::Relaxed); - } - let done = counter.fetch_add(1, Ordering::Relaxed) + 1; - if done % 100 == 0 || done == c_total { - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "hashing".to_string(), - processed: done, - total: c_total, - skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed), - }); - } - result - }) - .collect(); - ( - pairs, - stat_skipped + hash_skipped.load(Ordering::Relaxed), - c_total, - ) - }) - .await - .map_err(|e| e.to_string())?; + let pairs = candidates + .par_iter() + .filter_map(|&idx| { + let (id, path, size) = &sized[idx]; + let result = if *size == 0 { + Some((xxh3_64(&[]), *size, *id, path.clone())) + } else { + std::fs::File::open(path) + .ok() + // SAFETY: read-only; no external truncation expected during scan. + .and_then(|file| unsafe { Mmap::map(&file).ok() }) + .map(|mmap| (sample(&mmap), *size, *id, path.clone())) + }; + if result.is_none() { + hash_skipped.fetch_add(1, Ordering::Relaxed); + } + let done = counter.fetch_add(1, Ordering::Relaxed) + 1; + if done % 100 == 0 || done == c_total { + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "hashing".to_string(), + processed: done, + total: c_total, + skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed), + }, + ); + } + result + }) + .collect(); + ( + pairs, + stat_skipped + hash_skipped.load(Ordering::Relaxed), + c_total, + ) + }) + .await + .map_err(|e| e.to_string())?; // Group by (sample_hash, file_size). let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<(i64, String)>> = std::collections::HashMap::new(); for (hash, file_size, id, path) in pairs { - size_hash_map.entry((hash, file_size)).or_default().push((id, path)); + size_hash_map + .entry((hash, file_size)) + .or_default() + .push((id, path)); } // Phase 3: for large-file groups (> 64 KB) the sample hash is not exact; @@ -1140,12 +1185,15 @@ pub async fn find_duplicates( .map(|(_, entries)| entries.len()) .sum(); if confirming_total > 0 { - let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "confirming".to_string(), - processed: 0, - total: confirming_total, - skipped: skipped_before_confirm, - }); + let _ = app.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "confirming".to_string(), + processed: 0, + total: confirming_total, + skipped: skipped_before_confirm, + }, + ); } let app_confirm = app.clone(); let (confirmed, skipped_files): (Vec<(u64, u64, Vec)>, usize) = @@ -1163,7 +1211,11 @@ pub async fn find_duplicates( .flat_map(|((sample_hash, file_size), entries)| { if file_size <= COVERED { // Sample was already a full hash — one group, no re-read needed. - return vec![(sample_hash, file_size, entries.into_iter().map(|(id, _)| id).collect())]; + return vec![( + sample_hash, + file_size, + entries.into_iter().map(|(id, _)| id).collect(), + )]; } // Full-file hash pass. Group by full hash so that two sets of // files that collide only in the sample produce separate groups. @@ -1179,13 +1231,16 @@ pub async fn find_duplicates( } let done = counter.fetch_add(1, Ordering::Relaxed) + 1; if done % 100 == 0 || done == confirming_total { - let _ = app_confirm.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "confirming".to_string(), - processed: done, - total: confirming_total, - skipped: skipped_before_confirm - + confirm_skipped.load(Ordering::Relaxed), - }); + let _ = app_confirm.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "confirming".to_string(), + processed: done, + total: confirming_total, + skipped: skipped_before_confirm + + confirm_skipped.load(Ordering::Relaxed), + }, + ); } (*id, hash) }) @@ -1220,7 +1275,7 @@ pub async fn find_duplicates( .filter_map(|(hash, file_size, ids)| { let images = db::get_images_by_ids(&conn, &ids).ok()?; Some(DuplicateGroup { - file_hash: format!("{:016x}", hash), + file_hash: format!("{hash:016x}"), file_size, images, }) @@ -1232,7 +1287,7 @@ pub async fn find_duplicates( // Persist results so they survive restart — best-effort, ignore errors. let folder_scope = match folder_id { - Some(id) => format!("folder:{}", id), + Some(id) => format!("folder:{id}"), None => "all".to_string(), }; if let Ok(json) = serde_json::to_string(&groups) { @@ -1253,7 +1308,7 @@ pub async fn load_duplicate_scan_cache( folder_id: Option, ) -> Result, String> { let folder_scope = match folder_id { - Some(id) => format!("folder:{}", id), + Some(id) => format!("folder:{id}"), None => "all".to_string(), }; let conn = db.get().map_err(|e| e.to_string())?; @@ -1273,7 +1328,7 @@ pub async fn invalidate_duplicate_scan_cache( folder_id: Option, ) -> Result<(), String> { let folder_scope = match folder_id { - Some(id) => format!("folder:{}", id), + Some(id) => format!("folder:{id}"), None => "all".to_string(), }; let conn = db.get().map_err(|e| e.to_string())?; @@ -1326,7 +1381,7 @@ fn dot(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() } -fn normalize(v: &mut Vec) { +fn normalize(v: &mut [f32]) { let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); if norm > 1e-10 { v.iter_mut().for_each(|x| *x /= norm); @@ -1470,16 +1525,17 @@ pub async fn get_worker_states(folder_ids: Vec) -> Result Result { let conn = db.get().map_err(|e| e.to_string())?; let requested_folder_ids = params.folder_ids.unwrap_or_default(); - let (total, folder_ids) = match (params.folder_id, params.image_id, requested_folder_ids.is_empty()) { + let (total, folder_ids) = match ( + params.folder_id, + params.image_id, + requested_folder_ids.is_empty(), + ) { (_, Some(image_id), _) => { db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?; // Look up just this image's folder_id rather than fetching all folders @@ -1675,27 +1735,29 @@ pub async fn clear_tagging_jobs( ) -> Result { let conn = db.get().map_err(|e| e.to_string())?; let requested_folder_ids = params.folder_ids.unwrap_or_default(); - let (n, folder_ids): (usize, Vec) = match (params.folder_id, requested_folder_ids.is_empty()) { - (Some(id), _) => ( - db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?, - vec![id], - ), - (None, false) => { - let mut total = 0usize; - for &folder_id in &requested_folder_ids { - total += db::clear_tagging_jobs(&conn, Some(folder_id)).map_err(|e| e.to_string())?; + let (n, folder_ids): (usize, Vec) = + match (params.folder_id, requested_folder_ids.is_empty()) { + (Some(id), _) => ( + db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?, + vec![id], + ), + (None, false) => { + let mut total = 0usize; + for &folder_id in &requested_folder_ids { + total += db::clear_tagging_jobs(&conn, Some(folder_id)) + .map_err(|e| e.to_string())?; + } + (total, requested_folder_ids) } - (total, requested_folder_ids) - } - (None, true) => ( - db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?, - db::get_folders(&conn) - .map_err(|e| e.to_string())? - .into_iter() - .map(|f| f.id) - .collect(), - ), - }; + (None, true) => ( + db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?, + db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|f| f.id) + .collect(), + ), + }; drop(conn); indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); Ok(n) @@ -1747,7 +1809,11 @@ pub async fn get_tagging_queue_scope(app: AppHandle) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); let value = std::fs::read_to_string(path).unwrap_or_default(); - Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() }) + Ok(if value.trim() == "selected" { + "selected".to_string() + } else { + "all".to_string() + }) } #[tauri::command] @@ -1760,7 +1826,11 @@ pub async fn set_tagging_queue_scope( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let value = if params.scope == "selected" { "selected" } else { "all" }; + let value = if params.scope == "selected" { + "selected" + } else { + "all" + }; std::fs::write(path, value).map_err(|e| e.to_string())?; Ok(value.to_string()) } @@ -1825,14 +1895,21 @@ pub struct VacuumResult { } #[tauri::command] -pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result { +pub async fn get_database_info( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let db_path = app_dir.join("gallery.db"); let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); let conn = db.get().map_err(|e| e.to_string())?; - let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?; - let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?; + let page_size: i64 = conn + .query_row("PRAGMA page_size", [], |r| r.get(0)) + .map_err(|e| e.to_string())?; + let freelist_count: i64 = conn + .query_row("PRAGMA freelist_count", [], |r| r.get(0)) + .map_err(|e| e.to_string())?; Ok(DatabaseInfo { size_mb: size_bytes as f64 / 1_048_576.0, @@ -1841,13 +1918,17 @@ pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result } #[tauri::command] -pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result { +pub async fn vacuum_database( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let db_path = app_dir.join("gallery.db"); let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); let conn = db.get().map_err(|e| e.to_string())?; - conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;") + .map_err(|e| e.to_string())?; drop(conn); let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); @@ -1870,7 +1951,9 @@ pub struct CleanupOrphanedThumbnailsResult { pub freed_mb: f64, } -fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> Result, String> { +fn collect_db_thumbnail_filenames( + conn: &rusqlite::Connection, +) -> Result, String> { let mut stmt = conn .prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL") .map_err(|e| e.to_string())?; @@ -1993,8 +2076,7 @@ pub async fn set_muted_folder_ids( .map(|id| id.to_string()) .collect::>() .join(","); - std::fs::write(settings_dir.join("muted_folder_ids.txt"), content) - .map_err(|e| e.to_string()) + std::fs::write(settings_dir.join("muted_folder_ids.txt"), content).map_err(|e| e.to_string()) } #[tauri::command] @@ -2019,3 +2101,50 @@ pub async fn set_notifications_paused(app: AppHandle, paused: bool) -> Result<() ) .map_err(|e| e.to_string()) } + +#[derive(Serialize)] +pub struct FfmpegStatus { + pub installed: bool, + pub downloading: bool, + pub failed: bool, +} + +#[tauri::command] +pub async fn get_ffmpeg_status() -> Result { + Ok(FfmpegStatus { + installed: crate::media::ffmpeg_ready(), + downloading: crate::media::ffmpeg_downloading(), + failed: crate::media::ffmpeg_failed(), + }) +} + +#[tauri::command] +pub async fn retry_ffmpeg_download(app: AppHandle) -> Result<(), String> { + crate::media::spawn_ffmpeg_provision(app); + Ok(()) +} + +const ONBOARDING_COMPLETED_FILE: &str = "settings/onboarding_completed.txt"; + +#[tauri::command] +pub async fn get_onboarding_completed(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(ONBOARDING_COMPLETED_FILE); + if !path.exists() { + return Ok(false); + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + Ok(content.trim() == "true") +} + +#[tauri::command] +pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let settings_dir = app_dir.join("settings"); + std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; + std::fs::write( + settings_dir.join("onboarding_completed.txt"), + if completed { "true" } else { "false" }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 3542e5d..f62d823 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -99,6 +99,8 @@ pub struct MetadataJob { pub path: String, } +// Caption worker disabled (lib.rs) — kept for future re-enabling. +#[allow(dead_code)] #[derive(Debug, Clone)] pub struct CaptionJob { pub image_id: i64, @@ -318,9 +320,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { // Index must be created after ensure_column adds the column; it cannot live // in the execute_batch above because that batch runs before the column exists // on databases that predate Phase 1. - conn.execute_batch( - "CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);", - )?; + conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; ensure_column(conn, "folders", "scan_error", "TEXT")?; vector::migrate(conn)?; @@ -590,6 +590,7 @@ pub fn enqueue_caption_job(conn: &Connection, image_id: i64) -> Result<()> { Ok(()) } +#[allow(dead_code)] // caption worker disabled (lib.rs) pub fn requeue_caption_jobs(conn: &Connection, image_ids: &[i64]) -> Result<()> { for image_id in image_ids { conn.execute( @@ -668,6 +669,7 @@ pub fn clear_caption_jobs(conn: &Connection, folder_id: Option) -> Result Result { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM caption_jobs WHERE image_id = ?1 AND status = 'cancelled'", @@ -865,6 +867,7 @@ pub fn claim_embedding_jobs( Ok(claimed) } +#[allow(dead_code)] // caption worker disabled (lib.rs) fn get_pending_caption_jobs_excluding( conn: &Connection, excluded_folder_ids: &std::collections::HashSet, @@ -893,6 +896,7 @@ fn get_pending_caption_jobs_excluding( Ok(rows.collect::>>()?) } +#[allow(dead_code)] // caption worker disabled (lib.rs) pub fn claim_caption_jobs( conn: &mut Connection, paused_folder_ids: &std::collections::HashSet, @@ -1128,6 +1132,7 @@ pub fn get_all_folder_job_progress(conn: &Connection) -> Result, + include_videos: bool, limit: usize, ) -> Result> { let sql = format!( @@ -1136,8 +1141,10 @@ fn get_pending_thumbnail_jobs_excluding( JOIN images i ON i.id = j.image_id WHERE j.status = 'pending' {} + {} ORDER BY j.updated_at, j.image_id LIMIT ?1", + media_kind_clause(include_videos), folder_exclusion_clause("i", excluded_folder_ids) ); let mut stmt = conn.prepare(&sql)?; @@ -1152,6 +1159,17 @@ fn get_pending_thumbnail_jobs_excluding( Ok(rows.collect::>>()?) } +/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible +/// to both claiming and the tier-priority checks, or pending video jobs would +/// stall every lower tier indefinitely. +fn media_kind_clause(include_videos: bool) -> &'static str { + if include_videos { + "" + } else { + "AND i.media_kind = 'image'" + } +} + /// True if any claimable (pending, non-excluded) jobs exist in `job_table`. /// Used by lower-priority workers to defer to higher-priority queues. /// `extra_predicate` narrows the image join (e.g. videos only for metadata). @@ -1180,8 +1198,14 @@ fn has_claimable_jobs( pub fn has_claimable_thumbnail_jobs( conn: &Connection, excluded_folder_ids: &std::collections::HashSet, + include_videos: bool, ) -> Result { - has_claimable_jobs(conn, "thumbnail_jobs", "", excluded_folder_ids) + has_claimable_jobs( + conn, + "thumbnail_jobs", + media_kind_clause(include_videos), + excluded_folder_ids, + ) } pub fn has_claimable_metadata_jobs( @@ -1207,6 +1231,7 @@ pub fn claim_thumbnail_jobs( conn: &mut Connection, active_folder_ids: &std::collections::HashSet, paused_folder_ids: &std::collections::HashSet, + include_videos: bool, fetch_limit: usize, claim_limit: usize, ) -> Result> { @@ -1215,7 +1240,12 @@ pub fn claim_thumbnail_jobs( .union(paused_folder_ids) .copied() .collect::>(); - let candidates = get_pending_thumbnail_jobs_excluding(&tx, &excluded_folder_ids, fetch_limit)?; + let candidates = get_pending_thumbnail_jobs_excluding( + &tx, + &excluded_folder_ids, + include_videos, + fetch_limit, + )?; let mut claimed = Vec::with_capacity(claim_limit); for job in candidates { @@ -1410,7 +1440,10 @@ pub fn update_image_details( /// Look up the lightweight indexed-media entry for a single path. /// Used by the filesystem watcher to run change-detection before upserting. -pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result> { +pub fn get_indexed_entry_by_path( + conn: &Connection, + path: &str, +) -> Result> { let result = conn.query_row( "SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1", [path], @@ -1433,12 +1466,11 @@ pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result