Compare commits
56 Commits
0ca4d142d8
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 40fcd1b469 | |||
| 6b504aaae1 | |||
| 0bd99e2c7a | |||
| 0144526a3d | |||
| 2785b7d5e6 | |||
| 797247e900 | |||
| e4373195fe | |||
| 72a1a886a7 | |||
| c15eed6655 | |||
| 602c271531 | |||
| 54fa8ab117 | |||
| 23095a6d05 | |||
| 075c7e4cfb | |||
| 2f66b0bdb8 | |||
| ed9c061ac1 | |||
| 09810cb868 | |||
| f8e981c6f6 | |||
| 9c135179a3 | |||
| b72f140737 | |||
| 3b7190d353 | |||
| 7403f0cfeb | |||
| 1640e30330 | |||
| 178754f6c3 | |||
| 3fb4a9685f | |||
| 890c23bdce | |||
| 34ced67fe1 | |||
| 1629339569 | |||
| 1d782a6d57 | |||
| 69e53ed62a | |||
| d84c74e241 | |||
| 4cd3bbd4fd | |||
| 86ce7bc8e2 | |||
| b02bf1da2b | |||
| cd7dd89f00 | |||
| b1290268a7 | |||
| d30fe47876 | |||
| cbfcbea96a | |||
| 948a489a8a | |||
| 334ac54e00 | |||
| a4486547e8 | |||
| 665c315f56 | |||
| a34d38d9d3 | |||
| b89e7406e9 | |||
| ceb51f8fad | |||
| 8eaa0bd8e8 | |||
| 3707a35cc4 | |||
| d1eb75a4f5 | |||
| fbdd43d9d9 | |||
| 33fb3c6c77 | |||
| a40e5c2771 | |||
| a2804d8c1b | |||
| 0ab156d2d9 | |||
| 9ace1f6778 | |||
| ec6be96c6a | |||
| ae9e806e61 | |||
| 9ee5b08c93 |
@@ -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
|
||||||
@@ -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@v0.6.2
|
||||||
|
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
|
||||||
|
includeUpdaterJson: 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'
|
||||||
@@ -35,3 +35,14 @@ dist-ssr
|
|||||||
*.json
|
*.json
|
||||||
|
|
||||||
*.pyc
|
*.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 Tauri configs tracked despite the *.json rule above. tauri.conf.json
|
||||||
|
# must also be un-ignored so tauri-action can find it: it globs for the config
|
||||||
|
# honoring .gitignore, and an ignored config makes the release build fail with
|
||||||
|
# "Failed to resolve Tauri path".
|
||||||
|
!src-tauri/tauri.conf.json
|
||||||
|
!src-tauri/tauri.cuda.conf.json
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -80,7 +80,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
|
|||||||
| `media-updated` | `ThumbnailBatch` |
|
| `media-updated` | `ThumbnailBatch` |
|
||||||
| `caption-model-progress` | `CaptionModelProgress` |
|
| `caption-model-progress` | `CaptionModelProgress` |
|
||||||
| `tagger-model-progress` | `TaggerModelProgress` |
|
| `tagger-model-progress` | `TaggerModelProgress` |
|
||||||
| `duplicate_scan_progress` | `[scanned, total]` |
|
| `duplicate_scan_progress` | `{ phase, processed, total, skipped }` |
|
||||||
|
|
||||||
### Key types
|
### Key types
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Library
|
||||||
- Add and remove media folders; background indexing with live progress
|
- Add and remove media folders; background indexing with live progress
|
||||||
|
- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings
|
||||||
- Browse all media or filter by folder, type (image/video), favorites, or star rating
|
- Browse all media or filter by folder, type (image/video), favorites, or star rating
|
||||||
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
|
- Sort by date added, date taken (EXIF), name, size, rating, or duration
|
||||||
- **Similar image search** — find visually similar media by image or selected region
|
|
||||||
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control
|
|
||||||
- **Explore view** — visual cluster map and tag cloud for browsing by theme
|
|
||||||
- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete
|
|
||||||
- Lightbox preview with keyboard navigation, inline tag editing, and rating controls
|
|
||||||
- Sort by date, name, size, rating, or duration
|
|
||||||
- Grid density controls (compact / comfortable / detail)
|
- Grid density controls (compact / comfortable / detail)
|
||||||
|
- Desktop notifications, batched per folder, with per-folder mute and a global pause
|
||||||
|
|
||||||
|
### Search & discovery
|
||||||
|
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
|
||||||
|
- **Similar image search** — find visually similar media by image or a selected region
|
||||||
|
- **Explore view** — visual cluster map and tag cloud for browsing by theme
|
||||||
|
- **Timeline view** — media grouped chronologically by capture date (EXIF)
|
||||||
|
- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete
|
||||||
|
- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar
|
||||||
|
|
||||||
|
### Viewing & curation
|
||||||
|
- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls
|
||||||
|
- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support
|
||||||
|
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
- Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats
|
||||||
|
- Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging)
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
|
|
||||||
@@ -22,13 +36,63 @@ A local-first desktop media library for browsing, filtering, and curating image
|
|||||||
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
|
||||||
| tiff, tif, webp, avif, heic, heif | webm |
|
| 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
|
## Stack
|
||||||
|
|
||||||
- Tauri 2 + Rust backend
|
- Tauri 2 + Rust backend
|
||||||
- React 19 + TypeScript + Zustand
|
- React 19 + TypeScript + Zustand
|
||||||
- SQLite + `sqlite-vec` (vector search)
|
- SQLite + `sqlite-vec` (vector search) + HNSW index
|
||||||
- ONNX Runtime (`ort`) for AI tagging
|
- ONNX Runtime (`ort`) for AI tagging
|
||||||
- Candle (Rust ML) for visual embeddings
|
- Candle (Rust ML) for CLIP visual embeddings
|
||||||
|
- mozjpeg for fast scaled JPEG decoding
|
||||||
- FFmpeg sidecar for video thumbnails and metadata
|
- FFmpeg sidecar for video thumbnails and metadata
|
||||||
- Vite + Tailwind CSS v4
|
- Vite + Tailwind CSS v4
|
||||||
|
|
||||||
@@ -47,12 +111,16 @@ pnpm dev:vite
|
|||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
pnpm build:app
|
pnpm build:app
|
||||||
|
|
||||||
|
# Type-check the frontend
|
||||||
|
pnpm build:vite
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
|
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
|
||||||
2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.).
|
2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
|
||||||
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
|
3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database.
|
||||||
4. Progress events stream back to the UI while the gallery updates incrementally.
|
4. Progress events stream back to the UI while the gallery updates incrementally.
|
||||||
5. Embeddings power semantic search and the similar images feature via an HNSW index.
|
5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans.
|
||||||
|
6. Embeddings power semantic search and the similar-images feature via an HNSW index.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Phokus">
|
||||||
|
<title>Phokus</title>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Phokus app mark — a camera iris.
|
||||||
|
The hexagon in the middle is the lens OPENING; each blade edge sweeps from a
|
||||||
|
corner of the opening out to the rim, all leaning the same way (the pinwheel).
|
||||||
|
|
||||||
|
Tuning knobs (edit and re-open in any browser/Figma):
|
||||||
|
* opening roundness -> the "52" radius in the opening <path> arcs.
|
||||||
|
smaller (toward 30) = rounder/softer hole; larger (toward 90) = flatter, sharper.
|
||||||
|
* blade curve -> the "110" radius in each blade <path>.
|
||||||
|
smaller = more pronounced curve; larger = straighter blade.
|
||||||
|
* blade sweep amount -> the +40deg baked into the blade endpoint (70.71,-84.27).
|
||||||
|
* curve DIRECTION -> the final flag in each "A r r 0 0 X" command (0<->1 flips the bow).
|
||||||
|
* weight -> stroke-width on the <g>.
|
||||||
|
* color -> stroke on the <g>; swap "#e9e9ec" for currentColor to inherit.
|
||||||
|
A brand-accent focal dot is provided at the bottom (commented out).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<rect x="8" y="8" width="240" height="240" rx="52" fill="#0e0f14"/>
|
||||||
|
|
||||||
|
<g transform="translate(128 128)" fill="none" stroke="#e9e9ec" stroke-width="9"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|
||||||
|
<!-- outer rim -->
|
||||||
|
<circle r="110"/>
|
||||||
|
|
||||||
|
<!-- lens opening: soft, arc-rounded hexagon -->
|
||||||
|
<path d="M0,-46 A52,52 0 0 0 39.84,-23 A52,52 0 0 0 39.84,23 A52,52 0 0 0 0,46 A52,52 0 0 0 -39.84,23 A52,52 0 0 0 -39.84,-23 A52,52 0 0 0 0,-46 Z"/>
|
||||||
|
|
||||||
|
<!-- blades: one curved edge, swept six times -->
|
||||||
|
<g transform="rotate(0)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
<g transform="rotate(60)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
<g transform="rotate(120)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
<g transform="rotate(180)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
<g transform="rotate(240)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
<g transform="rotate(300)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- optional brand focal point -->
|
||||||
|
<!-- <circle cx="128" cy="128" r="9" fill="#e6a23c"/> -->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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) = <fill in at publish time>
|
||||||
|
```
|
||||||
@@ -2,11 +2,16 @@
|
|||||||
"name": "phokus",
|
"name": "phokus",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:app": "tauri build",
|
"build:app": "tauri build",
|
||||||
"build:vite": "tsc && vite build",
|
"build:vite": "tsc && vite build",
|
||||||
|
"clean:app": "cd src-tauri && cargo clean",
|
||||||
"dev:app": "tauri dev",
|
"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",
|
"dev:vite": "vite",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
@@ -18,6 +23,8 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ importers:
|
|||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.3
|
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:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@@ -662,6 +668,12 @@ packages:
|
|||||||
'@tauri-apps/plugin-opener@2.5.3':
|
'@tauri-apps/plugin-opener@2.5.3':
|
||||||
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
|
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':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -1462,6 +1474,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@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':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -8,6 +8,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -52,6 +63,23 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -143,6 +171,12 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -372,6 +406,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -403,6 +449,30 @@ dependencies = [
|
|||||||
"piper",
|
"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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.2"
|
||||||
@@ -430,6 +500,40 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
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]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
@@ -635,6 +739,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1450,6 +1556,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1474,7 +1590,7 @@ checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"env_filter",
|
"env_filter 1.0.1",
|
||||||
"jiff",
|
"jiff",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
@@ -1608,6 +1724,15 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fern"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ffmpeg-sidecar"
|
name = "ffmpeg-sidecar"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1750,6 +1875,21 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsevent-sys"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "funty"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2445,6 +2585,9 @@ name = "hashbrown"
|
|||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.7.8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
@@ -2452,7 +2595,7 @@ version = "0.14.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash 0.8.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2932,6 +3075,26 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -3079,6 +3242,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.94"
|
version = "0.3.94"
|
||||||
@@ -3113,6 +3286,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kamadak-exif"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
|
||||||
|
dependencies = [
|
||||||
|
"mutate_once",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyboard-types"
|
name = "keyboard-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3124,6 +3306,26 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
@@ -3269,6 +3471,9 @@ name = "log"
|
|||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
dependencies = [
|
||||||
|
"value-bag",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lzma-rust2"
|
name = "lzma-rust2"
|
||||||
@@ -3419,6 +3624,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3429,6 +3640,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -3489,6 +3712,31 @@ dependencies = [
|
|||||||
"pxfm",
|
"pxfm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mozjpeg"
|
||||||
|
version = "0.10.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bytemuck",
|
||||||
|
"libc",
|
||||||
|
"mozjpeg-sys",
|
||||||
|
"rgb",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mozjpeg-sys"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"dunce",
|
||||||
|
"libc",
|
||||||
|
"nasm-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.2"
|
version = "0.17.2"
|
||||||
@@ -3510,6 +3758,22 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mutate_once"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nasm-rs"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
|
||||||
|
dependencies = [
|
||||||
|
"jobserver",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -3606,6 +3870,25 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"filetime",
|
||||||
|
"fsevent-sys",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio 0.8.11",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.17.0"
|
version = "4.17.0"
|
||||||
@@ -3742,6 +4025,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -3838,6 +4130,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4007,6 +4311,20 @@ dependencies = [
|
|||||||
"ureq",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -4290,8 +4608,11 @@ dependencies = [
|
|||||||
"hf-hub",
|
"hf-hub",
|
||||||
"hnsw_rs",
|
"hnsw_rs",
|
||||||
"image",
|
"image",
|
||||||
|
"kamadak-exif",
|
||||||
"log",
|
"log",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
|
"mozjpeg",
|
||||||
|
"notify",
|
||||||
"ort",
|
"ort",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
@@ -4305,12 +4626,16 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-updater",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
"tokenizers",
|
"tokenizers",
|
||||||
"tokio",
|
"tokio",
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
"zip 4.6.1",
|
"zip 4.6.1",
|
||||||
@@ -4521,6 +4846,26 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "pulp"
|
name = "pulp"
|
||||||
version = "0.21.5"
|
version = "0.21.5"
|
||||||
@@ -4631,6 +4976,12 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "radium"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -4904,6 +5255,15 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rend"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
||||||
|
dependencies = [
|
||||||
|
"bytecheck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -4961,15 +5321,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -5005,6 +5370,15 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -5019,6 +5393,35 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.32.1"
|
version = "0.32.1"
|
||||||
@@ -5033,6 +5436,23 @@ dependencies = [
|
|||||||
"smallvec",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -5076,6 +5496,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -5085,6 +5517,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.10"
|
version = "0.103.10"
|
||||||
@@ -5213,6 +5672,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "seahash"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -5510,6 +5975,12 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -5861,6 +6332,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tap"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -6052,6 +6529,28 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-notification"
|
name = "tauri-plugin-notification"
|
||||||
version = "2.3.3"
|
version = "2.3.3"
|
||||||
@@ -6093,6 +6592,79 @@ dependencies = [
|
|||||||
"zbus",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -6301,7 +6873,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -6334,13 +6908,28 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokenizers"
|
name = "tokenizers"
|
||||||
version = "0.22.2"
|
version = "0.22.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
|
checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash 0.8.12",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
"dary_heap",
|
"dary_heap",
|
||||||
@@ -6376,26 +6965,12 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.2.0",
|
||||||
"parking_lot",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -6860,6 +7435,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-width"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8-zero"
|
name = "utf8-zero"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -6891,6 +7472,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "value-bag"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -7503,6 +8090,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -7990,6 +8586,15 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wyz"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"tap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A performant image gallery application"
|
description = "Local-first desktop media library"
|
||||||
authors = ["JezzWTF"]
|
authors = ["JezzWTF"]
|
||||||
|
license = "MIT"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -13,7 +14,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[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"]
|
candle-cuda = ["candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -32,25 +35,32 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
fast_image_resize = { version = "6.0.0", features = ["image"] }
|
fast_image_resize = { version = "6.0.0", features = ["image"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = "0.4"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
log = "0.4"
|
|
||||||
ffmpeg-sidecar = "2.5.0"
|
ffmpeg-sidecar = "2.5.0"
|
||||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||||
memmap2 = "0.9"
|
memmap2 = "0.9"
|
||||||
sysinfo = "0.38.4"
|
sysinfo = "0.38.4"
|
||||||
candle-core = { version = "0.10.2", features = ["cuda"] }
|
candle-core = "0.10.2"
|
||||||
candle-nn = { version = "0.10.2", features = ["cuda"] }
|
candle-nn = "0.10.2"
|
||||||
candle-transformers = { version = "0.10.2", features = ["cuda"] }
|
candle-transformers = "0.10.2"
|
||||||
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
|
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
|
||||||
tokenizers = "0.22.1"
|
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"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "download-binaries", "copy-dylibs", "load-dynamic", "directml", "api-24", "tls-native"] }
|
||||||
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
|
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
|
||||||
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
|
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
|
||||||
csv = "1"
|
csv = "1"
|
||||||
|
kamadak-exif = "0.5"
|
||||||
|
notify = "6"
|
||||||
tauri-plugin-notification = "2"
|
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 ────────────────────────────────────────────────────
|
# ── Dev-mode performance ────────────────────────────────────────────────────
|
||||||
# opt-level=1 on the main crate keeps incremental compile short.
|
# opt-level=1 on the main crate keeps incremental compile short.
|
||||||
@@ -79,6 +89,10 @@ opt-level = 3
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
[profile.dev.package.fast_image_resize]
|
[profile.dev.package.fast_image_resize]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
[profile.dev.package.mozjpeg]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.mozjpeg-sys]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
# Parallel work scheduler
|
# Parallel work scheduler
|
||||||
[profile.dev.package.rayon]
|
[profile.dev.package.rayon]
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ fn main() {
|
|||||||
if let Ok(path) = &cuda_path {
|
if let Ok(path) = &cuda_path {
|
||||||
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
|
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
|
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= 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=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"fs:read-files",
|
"fs:read-files",
|
||||||
"fs:read-dirs",
|
"fs:read-dirs",
|
||||||
"notification:default",
|
"notification:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:allow-restart",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 23 KiB |
@@ -8,13 +8,20 @@ use ort::session::{builder::GraphOptimizationLevel, Session};
|
|||||||
use ort::value::{Shape, Tensor};
|
use ort::value::{Shape, Tensor};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::io::{Cursor, Read};
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::OnceLock;
|
use std::sync::Mutex;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokenizers::Tokenizer;
|
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_MODEL_ID: &str = "onnx-community/Florence-2-base-ft";
|
||||||
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
|
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
|
||||||
const ONNX_RUNTIME_NUGET_URL: &str =
|
const ONNX_RUNTIME_NUGET_URL: &str =
|
||||||
@@ -62,7 +69,10 @@ const REQUIRED_FILES: &[&str] = &[
|
|||||||
"onnx/embed_tokens_fp16.onnx",
|
"onnx/embed_tokens_fp16.onnx",
|
||||||
];
|
];
|
||||||
|
|
||||||
static ORT_RUNTIME_INIT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
|
// Mutex<bool> rather than OnceLock<Result>: 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<bool> = Mutex::new(false);
|
||||||
|
|
||||||
/// Set to `true` by `set_caption_acceleration` so the caption worker loop
|
/// Set to `true` by `set_caption_acceleration` so the caption worker loop
|
||||||
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
||||||
@@ -470,7 +480,7 @@ impl FlorenceCaptioner {
|
|||||||
acceleration,
|
acceleration,
|
||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
println!(
|
log::info!(
|
||||||
"Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})",
|
"Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})",
|
||||||
sessions_started_at.elapsed(),
|
sessions_started_at.elapsed(),
|
||||||
acceleration,
|
acceleration,
|
||||||
@@ -490,9 +500,9 @@ impl FlorenceCaptioner {
|
|||||||
|
|
||||||
pub fn generate(&mut self, image_path: &Path) -> Result<String> {
|
pub fn generate(&mut self, image_path: &Path) -> Result<String> {
|
||||||
let started_at = Instant::now();
|
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)?;
|
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
|
let prompt_ids = self
|
||||||
.tokenizer
|
.tokenizer
|
||||||
.encode(self.caption_detail.prompt(), false)
|
.encode(self.caption_detail.prompt(), false)
|
||||||
@@ -502,7 +512,7 @@ impl FlorenceCaptioner {
|
|||||||
.map(|id| i64::from(*id))
|
.map(|id| i64::from(*id))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?;
|
let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?;
|
||||||
println!(
|
log::info!(
|
||||||
"Florence token embeddings done in {:?}",
|
"Florence token embeddings done in {:?}",
|
||||||
started_at.elapsed()
|
started_at.elapsed()
|
||||||
);
|
);
|
||||||
@@ -513,7 +523,7 @@ impl FlorenceCaptioner {
|
|||||||
&encoder_embeds,
|
&encoder_embeds,
|
||||||
&encoder_attention_mask,
|
&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(
|
let generated_ids = run_decoder(
|
||||||
&mut self.decoder_prefill_session,
|
&mut self.decoder_prefill_session,
|
||||||
@@ -523,7 +533,7 @@ impl FlorenceCaptioner {
|
|||||||
&encoder_attention_mask,
|
&encoder_attention_mask,
|
||||||
self.caption_detail.max_new_tokens(),
|
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
|
let generated_u32 = generated_ids
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -648,23 +658,61 @@ fn probe_vision_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
|
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);
|
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
|
||||||
ORT_RUNTIME_INIT
|
if !dll_path.exists() {
|
||||||
.get_or_init(|| {
|
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
|
||||||
if !dll_path.exists() {
|
}
|
||||||
return Err(format!(
|
ort::environment::init_from(&dll_path)
|
||||||
"ONNX Runtime DLL is missing: {}",
|
.map_err(|error| anyhow::anyhow!(error.to_string()))?
|
||||||
dll_path.display()
|
.with_name("phokus-florence")
|
||||||
));
|
.commit();
|
||||||
}
|
*initialized = true;
|
||||||
ort::environment::init_from(&dll_path)
|
Ok(())
|
||||||
.map_err(|error| error.to_string())?
|
}
|
||||||
.with_name("phokus-florence")
|
|
||||||
.commit();
|
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
|
||||||
Ok(())
|
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
|
||||||
})
|
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
|
||||||
.clone()
|
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
|
||||||
.map_err(anyhow::Error::msg)
|
/// 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<u64>),
|
||||||
|
) -> 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<()> {
|
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 {
|
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
|
||||||
let destination = local_dir.join(destination_file);
|
let destination = local_dir.join(destination_file);
|
||||||
download_nuget_file(source_url, archive_path, &destination)?;
|
download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_nuget_file(source_url: &str, archive_path: &str, destination: &Path) -> Result<()> {
|
// Give up only after this many *consecutive* curl runs that download nothing;
|
||||||
let mut response = ureq::get(source_url)
|
// a run that makes any progress resets the counter, so a large file completes
|
||||||
.call()
|
// across however many resumes it takes. Kept low so a hard stall (e.g. a
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
|
||||||
let mut bytes = Vec::new();
|
// rather than locking the UI on "preparing" for many minutes.
|
||||||
response
|
const MAX_STALL_RETRIES: usize = 3;
|
||||||
.body_mut()
|
|
||||||
.as_reader()
|
|
||||||
.read_to_end(&mut bytes)
|
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
|
||||||
|
|
||||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
|
/// Resiliently download `url` to `destination` using the system `curl.exe`.
|
||||||
let mut dll = archive.by_name(archive_path)?;
|
///
|
||||||
|
/// 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<u64>),
|
||||||
|
) -> Result<()> {
|
||||||
if let Some(parent) = destination.parent() {
|
if let Some(parent) = destination.parent() {
|
||||||
std::fs::create_dir_all(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/<total>` 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<u64> {
|
||||||
|
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::<u64>() {
|
||||||
|
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<u64>,
|
||||||
|
on_progress: &mut impl FnMut(u64, Option<u64>),
|
||||||
|
) -> 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<u64>),
|
||||||
|
) -> 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 temp_destination = destination.with_extension("tmp");
|
||||||
{
|
{
|
||||||
let mut file = std::fs::File::create(&temp_destination)?;
|
let mut out = std::fs::File::create(&temp_destination)?;
|
||||||
std::io::copy(&mut dll, &mut file)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +1015,7 @@ fn run_decoder(
|
|||||||
"inputs_embeds" => prefill_inputs_embeds_tensor
|
"inputs_embeds" => prefill_inputs_embeds_tensor
|
||||||
})
|
})
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
println!(
|
log::info!(
|
||||||
"Florence decoder prefill done in {:?}",
|
"Florence decoder prefill done in {:?}",
|
||||||
prefill_started_at.elapsed()
|
prefill_started_at.elapsed()
|
||||||
);
|
);
|
||||||
@@ -848,7 +1079,7 @@ fn run_decoder(
|
|||||||
decoder_kv = collect_present_key_values(&outputs, DECODER_LAYERS)?;
|
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)
|
Ok(generated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,7 +1117,7 @@ fn create_session(
|
|||||||
CaptionAcceleration::Auto | CaptionAcceleration::Directml => {
|
CaptionAcceleration::Auto | CaptionAcceleration::Directml => {
|
||||||
// `allow_directml` is false for the 4 text/decoder sessions —
|
// `allow_directml` is false for the 4 text/decoder sessions —
|
||||||
// they intentionally run on CPU regardless of the setting.
|
// they intentionally run on CPU regardless of the setting.
|
||||||
println!(
|
log::info!(
|
||||||
"Florence: using CPU for {} (DirectML disabled for this session type)",
|
"Florence: using CPU for {} (DirectML disabled for this session type)",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub struct ImageRecord {
|
|||||||
pub file_size: i64,
|
pub file_size: i64,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub modified_at: Option<String>,
|
pub modified_at: Option<String>,
|
||||||
|
pub taken_at: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub media_kind: String,
|
pub media_kind: String,
|
||||||
pub duration_ms: Option<i64>,
|
pub duration_ms: Option<i64>,
|
||||||
@@ -98,6 +99,8 @@ pub struct MetadataJob {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caption worker disabled (lib.rs) — kept for future re-enabling.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CaptionJob {
|
pub struct CaptionJob {
|
||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
@@ -313,6 +316,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
ensure_column(conn, "images", "ai_tagger_model", "TEXT")?;
|
ensure_column(conn, "images", "ai_tagger_model", "TEXT")?;
|
||||||
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
|
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
|
||||||
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
|
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
|
||||||
|
ensure_column(conn, "images", "taken_at", "TEXT")?;
|
||||||
|
// 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);")?;
|
||||||
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
||||||
|
|
||||||
vector::migrate(conn)?;
|
vector::migrate(conn)?;
|
||||||
@@ -334,8 +342,8 @@ pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
|
|||||||
|
|
||||||
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
||||||
let id = conn.query_row(
|
let id = conn.query_row(
|
||||||
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error)
|
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31)
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
folder_id = excluded.folder_id,
|
folder_id = excluded.folder_id,
|
||||||
filename = excluded.filename,
|
filename = excluded.filename,
|
||||||
@@ -345,6 +353,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
|||||||
file_size = excluded.file_size,
|
file_size = excluded.file_size,
|
||||||
created_at = excluded.created_at,
|
created_at = excluded.created_at,
|
||||||
modified_at = excluded.modified_at,
|
modified_at = excluded.modified_at,
|
||||||
|
taken_at = excluded.taken_at,
|
||||||
mime_type = excluded.mime_type,
|
mime_type = excluded.mime_type,
|
||||||
media_kind = excluded.media_kind,
|
media_kind = excluded.media_kind,
|
||||||
duration_ms = excluded.duration_ms,
|
duration_ms = excluded.duration_ms,
|
||||||
@@ -375,6 +384,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
|||||||
img.file_size,
|
img.file_size,
|
||||||
img.created_at,
|
img.created_at,
|
||||||
img.modified_at,
|
img.modified_at,
|
||||||
|
img.taken_at,
|
||||||
img.mime_type,
|
img.mime_type,
|
||||||
img.media_kind,
|
img.media_kind,
|
||||||
img.duration_ms,
|
img.duration_ms,
|
||||||
@@ -580,6 +590,7 @@ pub fn enqueue_caption_job(conn: &Connection, image_id: i64) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // caption worker disabled (lib.rs)
|
||||||
pub fn requeue_caption_jobs(conn: &Connection, image_ids: &[i64]) -> Result<()> {
|
pub fn requeue_caption_jobs(conn: &Connection, image_ids: &[i64]) -> Result<()> {
|
||||||
for image_id in image_ids {
|
for image_id in image_ids {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -658,6 +669,7 @@ pub fn clear_caption_jobs(conn: &Connection, folder_id: Option<i64>) -> Result<u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // caption worker disabled (lib.rs)
|
||||||
pub fn is_caption_job_cancelled(conn: &Connection, image_id: i64) -> Result<bool> {
|
pub fn is_caption_job_cancelled(conn: &Connection, image_id: i64) -> Result<bool> {
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM caption_jobs WHERE image_id = ?1 AND status = 'cancelled'",
|
"SELECT COUNT(*) FROM caption_jobs WHERE image_id = ?1 AND status = 'cancelled'",
|
||||||
@@ -855,6 +867,7 @@ pub fn claim_embedding_jobs(
|
|||||||
Ok(claimed)
|
Ok(claimed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // caption worker disabled (lib.rs)
|
||||||
fn get_pending_caption_jobs_excluding(
|
fn get_pending_caption_jobs_excluding(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
excluded_folder_ids: &std::collections::HashSet<i64>,
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
@@ -883,6 +896,7 @@ fn get_pending_caption_jobs_excluding(
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // caption worker disabled (lib.rs)
|
||||||
pub fn claim_caption_jobs(
|
pub fn claim_caption_jobs(
|
||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
paused_folder_ids: &std::collections::HashSet<i64>,
|
paused_folder_ids: &std::collections::HashSet<i64>,
|
||||||
@@ -1118,6 +1132,7 @@ pub fn get_all_folder_job_progress(conn: &Connection) -> Result<Vec<FolderJobPro
|
|||||||
fn get_pending_thumbnail_jobs_excluding(
|
fn get_pending_thumbnail_jobs_excluding(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
excluded_folder_ids: &std::collections::HashSet<i64>,
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
include_videos: bool,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ThumbnailJob>> {
|
) -> Result<Vec<ThumbnailJob>> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
@@ -1126,8 +1141,10 @@ fn get_pending_thumbnail_jobs_excluding(
|
|||||||
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'
|
||||||
{}
|
{}
|
||||||
|
{}
|
||||||
ORDER BY j.updated_at, j.image_id
|
ORDER BY j.updated_at, j.image_id
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
|
media_kind_clause(include_videos),
|
||||||
folder_exclusion_clause("i", excluded_folder_ids)
|
folder_exclusion_clause("i", excluded_folder_ids)
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
@@ -1142,10 +1159,79 @@ 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
|
||||||
|
/// 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).
|
||||||
|
fn has_claimable_jobs(
|
||||||
|
conn: &Connection,
|
||||||
|
job_table: &str,
|
||||||
|
extra_predicate: &str,
|
||||||
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM {} j
|
||||||
|
JOIN images i ON i.id = j.image_id
|
||||||
|
WHERE j.status = 'pending'
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
)",
|
||||||
|
job_table,
|
||||||
|
extra_predicate,
|
||||||
|
folder_exclusion_clause("i", excluded_folder_ids)
|
||||||
|
);
|
||||||
|
Ok(conn.query_row(&sql, [], |row| row.get::<_, i64>(0))? != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_claimable_thumbnail_jobs(
|
||||||
|
conn: &Connection,
|
||||||
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
include_videos: bool,
|
||||||
|
) -> Result<bool> {
|
||||||
|
has_claimable_jobs(
|
||||||
|
conn,
|
||||||
|
"thumbnail_jobs",
|
||||||
|
media_kind_clause(include_videos),
|
||||||
|
excluded_folder_ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_claimable_metadata_jobs(
|
||||||
|
conn: &Connection,
|
||||||
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
has_claimable_jobs(
|
||||||
|
conn,
|
||||||
|
"metadata_jobs",
|
||||||
|
"AND i.media_kind = 'video'",
|
||||||
|
excluded_folder_ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_claimable_embedding_jobs(
|
||||||
|
conn: &Connection,
|
||||||
|
excluded_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
has_claimable_jobs(conn, "embedding_jobs", "", excluded_folder_ids)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn claim_thumbnail_jobs(
|
pub fn claim_thumbnail_jobs(
|
||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
active_folder_ids: &std::collections::HashSet<i64>,
|
active_folder_ids: &std::collections::HashSet<i64>,
|
||||||
paused_folder_ids: &std::collections::HashSet<i64>,
|
paused_folder_ids: &std::collections::HashSet<i64>,
|
||||||
|
include_videos: bool,
|
||||||
fetch_limit: usize,
|
fetch_limit: usize,
|
||||||
claim_limit: usize,
|
claim_limit: usize,
|
||||||
) -> Result<Vec<ThumbnailJob>> {
|
) -> Result<Vec<ThumbnailJob>> {
|
||||||
@@ -1154,7 +1240,12 @@ pub fn claim_thumbnail_jobs(
|
|||||||
.union(paused_folder_ids)
|
.union(paused_folder_ids)
|
||||||
.copied()
|
.copied()
|
||||||
.collect::<std::collections::HashSet<_>>();
|
.collect::<std::collections::HashSet<_>>();
|
||||||
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);
|
let mut claimed = Vec::with_capacity(claim_limit);
|
||||||
|
|
||||||
for job in candidates {
|
for job in candidates {
|
||||||
@@ -1334,7 +1425,7 @@ pub fn update_image_details(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
|
||||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
||||||
generated_caption, caption_model, caption_updated_at, caption_error,
|
generated_caption, caption_model, caption_updated_at, caption_error,
|
||||||
@@ -1347,9 +1438,49 @@ pub fn update_image_details(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Option<IndexedMediaEntry>> {
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1",
|
||||||
|
[path],
|
||||||
|
|row| {
|
||||||
|
Ok(IndexedMediaEntry {
|
||||||
|
id: row.get(0)?,
|
||||||
|
path: row.get(1)?,
|
||||||
|
modified_at: row.get(2)?,
|
||||||
|
file_size: row.get(3)?,
|
||||||
|
media_kind: row.get(4)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(entry) => Ok(Some(entry)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up just the image id for a path. Used by the filesystem watcher
|
||||||
|
/// to find the DB row to delete when a file is removed from disk.
|
||||||
|
#[allow(dead_code)] // only caller is the disabled caption worker path
|
||||||
|
pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
|
||||||
|
let result = conn.query_row("SELECT id FROM images WHERE path = ?1", [path], |row| {
|
||||||
|
row.get(0)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(id) => Ok(Some(id)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
|
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
|
||||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
||||||
generated_caption, caption_model, caption_updated_at, caption_error,
|
generated_caption, caption_model, caption_updated_at, caption_error,
|
||||||
@@ -1375,8 +1506,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||||
let mut stmt =
|
let mut stmt = conn.prepare(
|
||||||
conn.prepare("SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name")?;
|
"SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Folder {
|
Ok(Folder {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -1398,7 +1530,13 @@ pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> {
|
pub fn update_folder_path(
|
||||||
|
conn: &Connection,
|
||||||
|
folder_id: i64,
|
||||||
|
old_path: &str,
|
||||||
|
new_path: &str,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
// Both updates must be atomic: if the image path rewrite fails (e.g. a
|
// Both updates must be atomic: if the image path rewrite fails (e.g. a
|
||||||
// uniqueness collision) the folder row must not remain at the new location.
|
// uniqueness collision) the folder row must not remain at the new location.
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
@@ -1434,6 +1572,7 @@ pub fn clear_folder_scan_error(conn: &Connection, folder_id: i64) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)] // mirrors the gallery query surface; a params struct adds noise for one caller
|
||||||
pub fn get_images(
|
pub fn get_images(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
@@ -1457,14 +1596,16 @@ pub fn get_images(
|
|||||||
"rating_desc" => "rating DESC, modified_at DESC NULLS LAST",
|
"rating_desc" => "rating DESC, modified_at DESC NULLS LAST",
|
||||||
"duration_asc" => "duration_ms ASC NULLS LAST",
|
"duration_asc" => "duration_ms ASC NULLS LAST",
|
||||||
"duration_desc" => "duration_ms DESC NULLS LAST",
|
"duration_desc" => "duration_ms DESC NULLS LAST",
|
||||||
|
"taken_asc" => "COALESCE(taken_at, modified_at) ASC NULLS LAST",
|
||||||
|
"taken_desc" => "COALESCE(taken_at, modified_at) DESC NULLS LAST",
|
||||||
_ => "modified_at DESC NULLS LAST",
|
_ => "modified_at DESC NULLS LAST",
|
||||||
};
|
};
|
||||||
|
|
||||||
let search_pattern = search.map(|value| format!("%{}%", value));
|
let search_pattern = search.map(|value| format!("%{value}%"));
|
||||||
let favorites_flag = i64::from(favorites_only);
|
let favorites_flag = i64::from(favorites_only);
|
||||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
|
||||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
|
||||||
generated_caption, caption_model, caption_updated_at, caption_error,
|
generated_caption, caption_model, caption_updated_at, caption_error,
|
||||||
@@ -1476,9 +1617,8 @@ pub fn get_images(
|
|||||||
AND (?4 = 0 OR favorite = 1)
|
AND (?4 = 0 OR favorite = 1)
|
||||||
AND rating >= ?5
|
AND rating >= ?5
|
||||||
AND (?6 = 0 OR embedding_status = 'failed')
|
AND (?6 = 0 OR embedding_status = 'failed')
|
||||||
ORDER BY {}
|
ORDER BY {order}
|
||||||
LIMIT ?7 OFFSET ?8",
|
LIMIT ?7 OFFSET ?8"
|
||||||
order
|
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let rows = stmt.query_map(
|
let rows = stmt.query_map(
|
||||||
@@ -1506,7 +1646,7 @@ pub fn count_images(
|
|||||||
rating_min: i64,
|
rating_min: i64,
|
||||||
embedding_failed_only: bool,
|
embedding_failed_only: bool,
|
||||||
) -> Result<i64> {
|
) -> Result<i64> {
|
||||||
let search_pattern = search.map(|value| format!("%{}%", value));
|
let search_pattern = search.map(|value| format!("%{value}%"));
|
||||||
|
|
||||||
let favorites_flag = i64::from(favorites_only);
|
let favorites_flag = i64::from(favorites_only);
|
||||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
@@ -1532,6 +1672,7 @@ pub fn count_images(
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn search_images_by_tag(
|
pub fn search_images_by_tag(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -1558,7 +1699,13 @@ pub fn search_images_by_tag(
|
|||||||
AND (?3 = 0 OR i.favorite = 1)
|
AND (?3 = 0 OR i.favorite = 1)
|
||||||
AND i.rating >= ?4
|
AND i.rating >= ?4
|
||||||
AND LOWER(TRIM(t.tag)) = ?5",
|
AND LOWER(TRIM(t.tag)) = ?5",
|
||||||
params![folder_id, media_kind, favorites_flag, rating_min, normalized_query],
|
params![
|
||||||
|
folder_id,
|
||||||
|
media_kind,
|
||||||
|
favorites_flag,
|
||||||
|
rating_min,
|
||||||
|
normalized_query
|
||||||
|
],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)? as usize;
|
)? as usize;
|
||||||
|
|
||||||
@@ -1648,6 +1795,52 @@ pub fn get_all_image_paths(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns (image_id, thumbnail_path) for the given path. Used by the watcher
|
||||||
|
/// delete branch so it can clean up the thumbnail file in the same step.
|
||||||
|
pub fn get_image_id_and_thumbnail_by_path(
|
||||||
|
conn: &Connection,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<(i64, Option<String>)>> {
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT id, thumbnail_path FROM images WHERE path = ?1",
|
||||||
|
params![path],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all non-null thumbnail_path values for images in a folder.
|
||||||
|
/// Called before folder deletion so callers can remove the files from disk.
|
||||||
|
pub fn get_thumbnail_paths_for_folder(conn: &Connection, folder_id: i64) -> Result<Vec<String>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT thumbnail_path FROM images WHERE folder_id = ?1 AND thumbnail_path IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([folder_id], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a moved/renamed image's path and thumbnail_path in-place.
|
||||||
|
/// Pass `new_thumbnail_path = None` to clear it (triggers regeneration).
|
||||||
|
pub fn update_image_path(
|
||||||
|
conn: &Connection,
|
||||||
|
image_id: i64,
|
||||||
|
new_path: &str,
|
||||||
|
new_filename: &str,
|
||||||
|
new_thumbnail_path: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images SET path = ?2, filename = ?3, thumbnail_path = ?4 WHERE id = ?1",
|
||||||
|
params![image_id, new_path, new_filename, new_thumbnail_path],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_explore_tags(
|
pub fn get_explore_tags(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
@@ -1904,7 +2097,10 @@ pub fn clear_tagging_jobs(conn: &Connection, folder_id: Option<i64>) -> Result<u
|
|||||||
WHERE status = 'processing'",
|
WHERE status = 'processing'",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
let n = conn.execute("DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')", [])?;
|
let n = conn.execute(
|
||||||
|
"DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE images SET ai_tagger_error = NULL WHERE ai_tagged_at IS NULL",
|
"UPDATE images SET ai_tagger_error = NULL WHERE ai_tagged_at IS NULL",
|
||||||
[],
|
[],
|
||||||
@@ -2012,18 +2208,6 @@ pub fn requeue_processing_tagging_jobs_for_folder(conn: &Connection, folder_id:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when the job row for `image_id` currently has status = 'cancelled'.
|
|
||||||
/// Used by the worker to discard inference results for jobs that were cancelled
|
|
||||||
/// while inference was running.
|
|
||||||
pub fn is_tagging_job_cancelled(conn: &Connection, image_id: i64) -> Result<bool> {
|
|
||||||
let count: i64 = conn.query_row(
|
|
||||||
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'",
|
|
||||||
[image_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` when the job row for `image_id` is still `processing`.
|
/// Returns `true` when the job row for `image_id` is still `processing`.
|
||||||
/// A `false` result means the row was reset to `pending` (pause) or `cancelled`
|
/// A `false` result means the row was reset to `pending` (pause) or `cancelled`
|
||||||
/// while inference was running — either way the result must be discarded.
|
/// while inference was running — either way the result must be discarded.
|
||||||
@@ -2128,27 +2312,28 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
|
|||||||
file_size: row.get(7)?,
|
file_size: row.get(7)?,
|
||||||
created_at: row.get(8)?,
|
created_at: row.get(8)?,
|
||||||
modified_at: row.get(9)?,
|
modified_at: row.get(9)?,
|
||||||
mime_type: row.get(10)?,
|
taken_at: row.get(10)?,
|
||||||
media_kind: row.get(11)?,
|
mime_type: row.get(11)?,
|
||||||
duration_ms: row.get(12)?,
|
media_kind: row.get(12)?,
|
||||||
video_codec: row.get(13)?,
|
duration_ms: row.get(13)?,
|
||||||
audio_codec: row.get(14)?,
|
video_codec: row.get(14)?,
|
||||||
metadata_updated_at: row.get(15)?,
|
audio_codec: row.get(15)?,
|
||||||
metadata_error: row.get(16)?,
|
metadata_updated_at: row.get(16)?,
|
||||||
favorite: row.get::<_, i64>(17)? != 0,
|
metadata_error: row.get(17)?,
|
||||||
rating: row.get(18)?,
|
favorite: row.get::<_, i64>(18)? != 0,
|
||||||
embedding_status: row.get(19)?,
|
rating: row.get(19)?,
|
||||||
embedding_model: row.get(20)?,
|
embedding_status: row.get(20)?,
|
||||||
embedding_updated_at: row.get(21)?,
|
embedding_model: row.get(21)?,
|
||||||
embedding_error: row.get(22)?,
|
embedding_updated_at: row.get(22)?,
|
||||||
generated_caption: row.get(23)?,
|
embedding_error: row.get(23)?,
|
||||||
caption_model: row.get(24)?,
|
generated_caption: row.get(24)?,
|
||||||
caption_updated_at: row.get(25)?,
|
caption_model: row.get(25)?,
|
||||||
caption_error: row.get(26)?,
|
caption_updated_at: row.get(26)?,
|
||||||
ai_rating: row.get(27)?,
|
caption_error: row.get(27)?,
|
||||||
ai_tagger_model: row.get(28)?,
|
ai_rating: row.get(28)?,
|
||||||
ai_tagged_at: row.get(29)?,
|
ai_tagger_model: row.get(29)?,
|
||||||
ai_tagger_error: row.get(30)?,
|
ai_tagged_at: row.get(30)?,
|
||||||
|
ai_tagger_error: row.get(31)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2237,7 +2422,7 @@ pub fn set_duplicate_scan_cache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
|
fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
|
||||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
|
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||||
let mut rows = stmt.query([])?;
|
let mut rows = stmt.query([])?;
|
||||||
while let Some(row) = rows.next()? {
|
while let Some(row) = rows.next()? {
|
||||||
let existing_name: String = row.get(1)?;
|
let existing_name: String = row.get(1)?;
|
||||||
@@ -2247,7 +2432,7 @@ fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str)
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
&format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, definition),
|
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2274,5 +2459,5 @@ fn folder_exclusion_clause(
|
|||||||
.map(|id| id.to_string())
|
.map(|id| id.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
format!("AND {}.folder_id NOT IN ({})", image_alias, id_list)
|
format!("AND {image_alias}.folder_id NOT IN ({id_list})")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Res
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
|
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
|
||||||
if guard.is_none() {
|
if guard.is_none() {
|
||||||
println!("Initializing CLIP text embedder...");
|
log::info!("Initializing CLIP text embedder...");
|
||||||
*guard = Some(ClipImageEmbedder::new()?);
|
*guard = Some(ClipImageEmbedder::new()?);
|
||||||
}
|
}
|
||||||
f(guard.as_ref().unwrap())
|
f(guard.as_ref().unwrap())
|
||||||
@@ -35,13 +35,13 @@ pub struct ClipImageEmbedder {
|
|||||||
|
|
||||||
impl ClipImageEmbedder {
|
impl ClipImageEmbedder {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
println!("Initializing CLIP image embedder...");
|
log::info!("Initializing CLIP image embedder...");
|
||||||
let api = Api::new()?;
|
let api = Api::new()?;
|
||||||
let repo = api.repo(Repo::new(
|
let repo = api.repo(Repo::new(
|
||||||
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(),
|
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(),
|
||||||
RepoType::Model,
|
RepoType::Model,
|
||||||
));
|
));
|
||||||
println!("Resolving CLIP model weights from Hugging Face cache...");
|
log::info!("Resolving CLIP model weights from Hugging Face cache...");
|
||||||
let model_path = repo.get("model.safetensors")?;
|
let model_path = repo.get("model.safetensors")?;
|
||||||
let tokenizer_repo = api.repo(Repo::new(
|
let tokenizer_repo = api.repo(Repo::new(
|
||||||
"openai/clip-vit-base-patch32".to_string(),
|
"openai/clip-vit-base-patch32".to_string(),
|
||||||
@@ -60,7 +60,7 @@ impl ClipImageEmbedder {
|
|||||||
};
|
};
|
||||||
let model = ClipModel::new(vb, &config)?;
|
let model = ClipModel::new(vb, &config)?;
|
||||||
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
|
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
|
||||||
println!("CLIP image embedder ready.");
|
log::info!("CLIP image embedder ready.");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
model,
|
model,
|
||||||
@@ -160,7 +160,7 @@ impl ClipImageEmbedder {
|
|||||||
let ids = enc.get_ids();
|
let ids = enc.get_ids();
|
||||||
let len = ids.len().min(max_len);
|
let len = ids.len().min(max_len);
|
||||||
for j in 0..len {
|
for j in 0..len {
|
||||||
flat[i * max_len + j] = ids[j] as u32;
|
flat[i * max_len + j] = ids[j];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,23 +177,25 @@ impl ClipImageEmbedder {
|
|||||||
fn resolve_device() -> Result<Device> {
|
fn resolve_device() -> Result<Device> {
|
||||||
match Device::cuda_if_available(0) {
|
match Device::cuda_if_available(0) {
|
||||||
Ok(device) if device.is_cuda() => {
|
Ok(device) if device.is_cuda() => {
|
||||||
println!("CLIP embedder: using CUDA GPU (device 0).");
|
log::info!("CLIP embedder: using CUDA GPU (device 0).");
|
||||||
return Ok(device);
|
return Ok(device);
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
println!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
|
log::info!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
|
log::info!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Device::Cpu)
|
Ok(Device::Cpu)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
|
fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
|
||||||
let image = image::ImageReader::open(path)?
|
// Scaled decode: CLIP only needs image_size² pixels, so decoding a large
|
||||||
.with_guessed_format()?
|
// JPEG at full resolution is wasted work. Cover mode keeps the shortest
|
||||||
.decode()?;
|
// side at or above image_size for the fill-crop below. Also applies EXIF
|
||||||
|
// orientation, so rotated photos embed the way they are displayed.
|
||||||
|
let image = crate::thumbnail::decode_image_scaled(path, image_size as u32, true)?;
|
||||||
let image = image.resize_to_fill(
|
let image = image.resize_to_fill(
|
||||||
image_size as u32,
|
image_size as u32,
|
||||||
image_size as u32,
|
image_size as u32,
|
||||||
@@ -208,10 +210,11 @@ fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
|
fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
|
||||||
let mut images = Vec::with_capacity(paths.len());
|
use rayon::prelude::*;
|
||||||
for path in paths {
|
let images = paths
|
||||||
images.push(load_image(path, image_size)?);
|
.par_iter()
|
||||||
}
|
.map(|path| load_image(path, image_size))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
Ok(Tensor::stack(&images, 0)?)
|
Ok(Tensor::stack(&images, 0)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ use tauri::Manager;
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
// Must be the first plugin: a second launch hands its args to the
|
||||||
|
// running instance and exits before anything else initializes.
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_log::Builder::new()
|
||||||
|
.targets([
|
||||||
|
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||||
|
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
|
||||||
|
file_name: Some("phokus".into()),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.max_file_size(5 * 1024 * 1024)
|
||||||
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
@@ -28,7 +52,10 @@ 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");
|
||||||
|
|
||||||
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar");
|
// FFmpeg provisioning happens in the background so the window
|
||||||
|
// appears immediately; workers gate video jobs on readiness and
|
||||||
|
// the onboarding/Settings UI shows progress and retry.
|
||||||
|
media::spawn_ffmpeg_provision(app.handle().clone());
|
||||||
|
|
||||||
let db_path = app_dir.join("gallery.db");
|
let db_path = app_dir.join("gallery.db");
|
||||||
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
|
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
|
||||||
@@ -41,14 +68,13 @@ pub fn run() {
|
|||||||
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");
|
||||||
if backfilled > 0 {
|
if backfilled > 0 {
|
||||||
println!("Backfilled {} embedding jobs.", backfilled);
|
log::info!("Backfilled {backfilled} embedding jobs.");
|
||||||
}
|
}
|
||||||
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
|
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
|
||||||
.expect("Failed to repair embedding consistency");
|
.expect("Failed to repair embedding consistency");
|
||||||
if orphaned_vectors > 0 || missing_vectors > 0 {
|
if orphaned_vectors > 0 || missing_vectors > 0 {
|
||||||
println!(
|
log::info!(
|
||||||
"Repaired embedding consistency: removed {} orphaned vectors, requeued {} missing vectors.",
|
"Repaired embedding consistency: removed {orphaned_vectors} orphaned vectors, requeued {missing_vectors} missing vectors."
|
||||||
orphaned_vectors, missing_vectors
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +82,19 @@ pub fn run() {
|
|||||||
let thumb_dir = app_dir.join("thumbnails");
|
let thumb_dir = app_dir.join("thumbnails");
|
||||||
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
|
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
|
||||||
|
|
||||||
|
// The asset protocol scope is no longer a blanket "**": thumbnails
|
||||||
|
// are allowed statically in tauri.conf.json, and each indexed
|
||||||
|
// folder is allowed here (and in add_folder/update_folder_path).
|
||||||
|
{
|
||||||
|
let scope = app.asset_protocol_scope();
|
||||||
|
let conn = pool.get().expect("Failed to get connection for asset scope");
|
||||||
|
for folder in db::get_folders(&conn).unwrap_or_default() {
|
||||||
|
if let Err(error) = scope.allow_directory(&folder.path, true) {
|
||||||
|
log::error!("Failed to allow asset scope for {}: {}", folder.path, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let thumbnail_worker_count = std::thread::available_parallelism()
|
let thumbnail_worker_count = std::thread::available_parallelism()
|
||||||
.map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get()))
|
.map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get()))
|
||||||
.unwrap_or(2);
|
.unwrap_or(2);
|
||||||
@@ -74,8 +113,11 @@ pub fn run() {
|
|||||||
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
|
|
||||||
|
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
|
||||||
|
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
app.manage(media_tools);
|
app.manage(media_tools);
|
||||||
|
app.manage(watcher_handle);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -136,6 +178,23 @@ pub fn run() {
|
|||||||
commands::delete_images_from_disk,
|
commands::delete_images_from_disk,
|
||||||
commands::rename_folder,
|
commands::rename_folder,
|
||||||
commands::update_folder_path,
|
commands::update_folder_path,
|
||||||
|
commands::get_tagging_queue_scope,
|
||||||
|
commands::set_tagging_queue_scope,
|
||||||
|
commands::get_tagging_queue_folder_ids,
|
||||||
|
commands::set_tagging_queue_folder_ids,
|
||||||
|
commands::open_app_data_folder,
|
||||||
|
commands::get_database_info,
|
||||||
|
commands::vacuum_database,
|
||||||
|
commands::get_orphaned_thumbnails_info,
|
||||||
|
commands::cleanup_orphaned_thumbnails,
|
||||||
|
commands::get_muted_folder_ids,
|
||||||
|
commands::set_muted_folder_ids,
|
||||||
|
commands::get_ffmpeg_status,
|
||||||
|
commands::retry_ffmpeg_download,
|
||||||
|
commands::get_onboarding_completed,
|
||||||
|
commands::set_onboarding_completed,
|
||||||
|
commands::get_notifications_paused,
|
||||||
|
commands::set_notifications_paused,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -2,9 +2,136 @@ use anyhow::{anyhow, Result};
|
|||||||
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
|
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
|
||||||
use ffmpeg_sidecar::ffprobe::ffprobe_path;
|
use ffmpeg_sidecar::ffprobe::ffprobe_path;
|
||||||
use ffmpeg_sidecar::paths::ffmpeg_path;
|
use ffmpeg_sidecar::paths::ffmpeg_path;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
static FFMPEG_READY: AtomicBool = AtomicBool::new(false);
|
||||||
|
static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false);
|
||||||
|
// Set when a provision attempt fails, so the frontend can distinguish
|
||||||
|
// "failed before the event listener attached" from "about to start".
|
||||||
|
static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// True once both ffmpeg and ffprobe binaries are present on disk. Workers
|
||||||
|
/// gate video jobs on this so a missing/in-flight download never fails jobs.
|
||||||
|
pub fn ffmpeg_ready() -> bool {
|
||||||
|
FFMPEG_READY.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ffmpeg_downloading() -> bool {
|
||||||
|
FFMPEG_DOWNLOADING.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ffmpeg_failed() -> bool {
|
||||||
|
FFMPEG_FAILED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FfmpegProgressPayload {
|
||||||
|
pub phase: String,
|
||||||
|
pub downloaded_bytes: Option<u64>,
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FfmpegProgressPayload {
|
||||||
|
fn phase(phase: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
phase: phase.to_string(),
|
||||||
|
downloaded_bytes: None,
|
||||||
|
total_bytes: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress";
|
||||||
|
|
||||||
|
/// Provision FFmpeg in the background, streaming progress to the frontend as
|
||||||
|
/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in
|
||||||
|
/// flight is a no-op, and re-invoking after success only re-emits `done`.
|
||||||
|
pub fn spawn_ffmpeg_provision(app: AppHandle) {
|
||||||
|
// Fast path: binaries already on disk (previous run, or a manual install).
|
||||||
|
if ffmpeg_path().exists() && ffprobe_path().exists() {
|
||||||
|
FFMPEG_READY.store(true, Ordering::Relaxed);
|
||||||
|
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if FFMPEG_DOWNLOADING
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return; // a download is already running
|
||||||
|
}
|
||||||
|
FFMPEG_FAILED.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// The Downloading callback fires very frequently; throttle emissions.
|
||||||
|
// Cell because the download callback is Fn, not FnMut.
|
||||||
|
let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1));
|
||||||
|
let result = auto_download_with_progress(|event| match event {
|
||||||
|
FfmpegDownloadProgressEvent::Starting => {
|
||||||
|
log::info!("Downloading bundled FFmpeg...");
|
||||||
|
let _ = app.emit(
|
||||||
|
FFMPEG_PROGRESS_EVENT,
|
||||||
|
FfmpegProgressPayload::phase("starting"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
FfmpegDownloadProgressEvent::Downloading {
|
||||||
|
total_bytes,
|
||||||
|
downloaded_bytes,
|
||||||
|
} => {
|
||||||
|
if last_emit.get().elapsed() >= Duration::from_millis(250) {
|
||||||
|
last_emit.set(Instant::now());
|
||||||
|
let _ = app.emit(
|
||||||
|
FFMPEG_PROGRESS_EVENT,
|
||||||
|
FfmpegProgressPayload {
|
||||||
|
phase: "downloading".to_string(),
|
||||||
|
downloaded_bytes: Some(downloaded_bytes),
|
||||||
|
total_bytes: Some(total_bytes),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FfmpegDownloadProgressEvent::UnpackingArchive => {
|
||||||
|
log::info!("Unpacking bundled FFmpeg...");
|
||||||
|
let _ = app.emit(
|
||||||
|
FFMPEG_PROGRESS_EVENT,
|
||||||
|
FfmpegProgressPayload::phase("unpacking"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
FfmpegDownloadProgressEvent::Done => {
|
||||||
|
log::info!("Bundled FFmpeg ready.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
FFMPEG_READY.store(true, Ordering::Relaxed);
|
||||||
|
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
FFMPEG_FAILED.store(true, Ordering::SeqCst);
|
||||||
|
log::error!("FFmpeg provisioning failed: {error}");
|
||||||
|
let _ = app.emit(
|
||||||
|
FFMPEG_PROGRESS_EVENT,
|
||||||
|
FfmpegProgressPayload {
|
||||||
|
phase: "error".to_string(),
|
||||||
|
downloaded_bytes: None,
|
||||||
|
total_bytes: None,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
|
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
|
||||||
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
|
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
|
||||||
@@ -36,33 +163,6 @@ impl MediaTools {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_installed() -> Result<()> {
|
|
||||||
// Skip download entirely if both binaries are already present.
|
|
||||||
if ffmpeg_path().exists() && ffprobe_path().exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
auto_download_with_progress(|event| match event {
|
|
||||||
FfmpegDownloadProgressEvent::Starting => {
|
|
||||||
println!("Downloading bundled FFmpeg...");
|
|
||||||
}
|
|
||||||
FfmpegDownloadProgressEvent::Downloading {
|
|
||||||
total_bytes,
|
|
||||||
downloaded_bytes,
|
|
||||||
} => {
|
|
||||||
println!(
|
|
||||||
"Downloading bundled FFmpeg: {}/{} bytes",
|
|
||||||
downloaded_bytes, total_bytes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
FfmpegDownloadProgressEvent::UnpackingArchive => {
|
|
||||||
println!("Unpacking bundled FFmpeg...");
|
|
||||||
}
|
|
||||||
FfmpegDownloadProgressEvent::Done => {
|
|
||||||
println!("Bundled FFmpeg ready.");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ffmpeg_command(&self) -> Command {
|
pub fn ffmpeg_command(&self) -> Command {
|
||||||
let mut cmd = Command::new(&self.ffmpeg_path);
|
let mut cmd = Command::new(&self.ffmpeg_path);
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use sysinfo::{DiskKind, Disks};
|
use sysinfo::{DiskKind, Disks};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -107,7 +107,7 @@ pub fn detect_storage_profile(path: &Path) -> StorageProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fallback_profile_for_path(path: &PathBuf) -> StorageProfile {
|
fn fallback_profile_for_path(path: &Path) -> StorageProfile {
|
||||||
let path_str = path.to_string_lossy().to_lowercase();
|
let path_str = path.to_string_lossy().to_lowercase();
|
||||||
if path_str.starts_with("\\\\") {
|
if path_str.starts_with("\\\\") {
|
||||||
return StorageProfile::Conservative;
|
return StorageProfile::Conservative;
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ pub struct TaggerModelProgress {
|
|||||||
pub total_files: usize,
|
pub total_files: usize,
|
||||||
pub completed_files: usize,
|
pub completed_files: usize,
|
||||||
pub current_file: Option<String>,
|
pub current_file: Option<String>,
|
||||||
|
// Byte progress for the file currently downloading. None for files whose
|
||||||
|
// size isn't known up front (or between files).
|
||||||
|
pub downloaded_bytes: Option<u64>,
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
pub done: bool,
|
pub done: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +204,7 @@ pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
|
|||||||
let Ok(value) = std::fs::read_to_string(path) else {
|
let Ok(value) = std::fs::read_to_string(path) else {
|
||||||
return 8;
|
return 8;
|
||||||
};
|
};
|
||||||
value
|
value.trim().parse::<usize>().unwrap_or(8).clamp(1, 100)
|
||||||
.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.unwrap_or(8)
|
|
||||||
.clamp(1, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
||||||
@@ -254,58 +254,93 @@ pub fn prepare_tagger_model_with_progress(
|
|||||||
let local_dir = model_dir(app_data_dir);
|
let local_dir = model_dir(app_data_dir);
|
||||||
std::fs::create_dir_all(&local_dir)?;
|
std::fs::create_dir_all(&local_dir)?;
|
||||||
|
|
||||||
// Ensure the shared ONNX runtime DLLs are present. The tagger shares
|
// The tagger shares the ONNX runtime DLLs with the captioner; download
|
||||||
// them with the captioner; install them here so the tagger is fully
|
// them here so the tagger works even on a clean install where the caption
|
||||||
// functional even on a clean install where the caption model has never
|
// model has never been fetched (ensure_onnx_runtime only initializes).
|
||||||
// been downloaded.
|
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"];
|
||||||
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
|
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
|
||||||
std::fs::create_dir_all(&caption_model_dir)?;
|
std::fs::create_dir_all(&caption_model_dir)?;
|
||||||
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
|
|
||||||
|
|
||||||
// Download the two tagger-specific files.
|
// Unified step count across DLLs and tagger files so the bar is coherent.
|
||||||
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"];
|
let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir);
|
||||||
|
let model_pending = DOWNLOAD_FILES
|
||||||
let api = Api::new()?;
|
|
||||||
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model));
|
|
||||||
|
|
||||||
let mut completed_files = DOWNLOAD_FILES
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|file| local_dir.join(file).exists())
|
.filter(|file| !local_dir.join(file).exists())
|
||||||
.count();
|
.count();
|
||||||
|
let total_files = dll_count + model_pending;
|
||||||
|
let mut completed_files = 0usize;
|
||||||
|
|
||||||
emit_progress(TaggerModelProgress {
|
emit_progress(TaggerModelProgress {
|
||||||
total_files: DOWNLOAD_FILES.len(),
|
total_files,
|
||||||
completed_files,
|
completed_files,
|
||||||
current_file: None,
|
current_file: None,
|
||||||
done: completed_files == DOWNLOAD_FILES.len(),
|
downloaded_bytes: None,
|
||||||
|
total_bytes: None,
|
||||||
|
done: total_files == 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ONNX runtime DLLs (small, but non-trivial on a slow link) ──
|
||||||
|
{
|
||||||
|
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
|
||||||
|
crate::captioner::provision_onnx_runtime_with_progress(
|
||||||
|
&caption_model_dir,
|
||||||
|
|label, downloaded, total| {
|
||||||
|
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
|
||||||
|
last_emit = Instant::now();
|
||||||
|
emit_progress(TaggerModelProgress {
|
||||||
|
total_files,
|
||||||
|
completed_files,
|
||||||
|
current_file: Some(format!("ONNX Runtime: {label}")),
|
||||||
|
downloaded_bytes: Some(downloaded),
|
||||||
|
total_bytes: total,
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
completed_files += dll_count;
|
||||||
|
}
|
||||||
|
log::info!("Tagger: ONNX runtime DLLs ready; initializing runtime");
|
||||||
|
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
|
||||||
|
log::info!("Tagger: runtime initialized; downloading model files");
|
||||||
|
|
||||||
|
// ── Tagger model files (model.onnx is ~446 MB) ──
|
||||||
|
// Download directly from the resolved URL with our resilient downloader
|
||||||
|
// (timeout + resume), rather than hf-hub's download_with_progress, whose
|
||||||
|
// agent has no read timeout and would hang on a stalled connection.
|
||||||
|
let api = Api::new()?;
|
||||||
|
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model));
|
||||||
|
|
||||||
for file in DOWNLOAD_FILES {
|
for file in DOWNLOAD_FILES {
|
||||||
let destination = local_dir.join(file);
|
let destination = local_dir.join(file);
|
||||||
if destination.exists() {
|
if destination.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
emit_progress(TaggerModelProgress {
|
let url = repo.url(file);
|
||||||
total_files: DOWNLOAD_FILES.len(),
|
let label = (*file).to_string();
|
||||||
completed_files,
|
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
|
||||||
current_file: Some((*file).to_string()),
|
crate::captioner::download_file_resilient(&url, &destination, |downloaded, total| {
|
||||||
done: false,
|
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
|
||||||
});
|
last_emit = Instant::now();
|
||||||
let cached = repo.get(file)?;
|
emit_progress(TaggerModelProgress {
|
||||||
std::fs::copy(cached, destination)?;
|
total_files,
|
||||||
|
completed_files,
|
||||||
|
current_file: Some(label.clone()),
|
||||||
|
downloaded_bytes: Some(downloaded),
|
||||||
|
total_bytes: total,
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})?;
|
||||||
completed_files += 1;
|
completed_files += 1;
|
||||||
emit_progress(TaggerModelProgress {
|
|
||||||
total_files: DOWNLOAD_FILES.len(),
|
|
||||||
completed_files,
|
|
||||||
current_file: Some((*file).to_string()),
|
|
||||||
done: completed_files == DOWNLOAD_FILES.len(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_progress(TaggerModelProgress {
|
emit_progress(TaggerModelProgress {
|
||||||
total_files: DOWNLOAD_FILES.len(),
|
total_files,
|
||||||
completed_files,
|
completed_files,
|
||||||
current_file: None,
|
current_file: None,
|
||||||
|
downloaded_bytes: None,
|
||||||
|
total_bytes: None,
|
||||||
done: true,
|
done: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +482,7 @@ impl WdTagger {
|
|||||||
|
|
||||||
let labels = load_labels(&labels_path)?;
|
let labels = load_labels(&labels_path)?;
|
||||||
|
|
||||||
println!(
|
log::info!(
|
||||||
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
|
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
|
||||||
started_at.elapsed(),
|
started_at.elapsed(),
|
||||||
labels.len(),
|
labels.len(),
|
||||||
@@ -485,7 +520,7 @@ impl WdTagger {
|
|||||||
.try_extract_tensor::<f32>()
|
.try_extract_tensor::<f32>()
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
let probs: &[f32] = &probabilities;
|
let probs: &[f32] = probabilities;
|
||||||
|
|
||||||
if probs.len() != self.labels.len() {
|
if probs.len() != self.labels.len() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -523,7 +558,7 @@ impl WdTagger {
|
|||||||
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
|
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
|
||||||
tags.truncate(max_tags);
|
tags.truncate(max_tags);
|
||||||
|
|
||||||
println!(
|
log::info!(
|
||||||
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
|
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
|
||||||
tags.len(),
|
tags.len(),
|
||||||
self.threshold,
|
self.threshold,
|
||||||
@@ -563,13 +598,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
|
|||||||
|
|
||||||
let mut builder = match acceleration {
|
let mut builder = match acceleration {
|
||||||
TaggerAcceleration::Cpu => {
|
TaggerAcceleration::Cpu => {
|
||||||
println!("WD tagger: using CPU execution provider");
|
log::info!("WD tagger: using CPU execution provider");
|
||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
TaggerAcceleration::Auto => builder
|
TaggerAcceleration::Auto => builder
|
||||||
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
|
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
|
||||||
.unwrap_or_else(|error| {
|
.unwrap_or_else(|error| {
|
||||||
println!("WD tagger: DirectML unavailable, falling back to CPU");
|
log::info!("WD tagger: DirectML unavailable, falling back to CPU");
|
||||||
error.recover()
|
error.recover()
|
||||||
}),
|
}),
|
||||||
TaggerAcceleration::Directml => builder
|
TaggerAcceleration::Directml => builder
|
||||||
|
|||||||
@@ -31,29 +31,26 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
|
let img = decode_for_thumbnail(image_path)?;
|
||||||
let mut decoder = reader.into_decoder()?;
|
|
||||||
let orientation = decoder.orientation()?;
|
|
||||||
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
|
||||||
img.apply_orientation(orientation);
|
|
||||||
|
|
||||||
let src = image::DynamicImage::ImageRgba8(img.into_rgba8());
|
// RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA
|
||||||
|
// only to flatten at encode time wastes a third of the bandwidth.
|
||||||
|
let src = image::DynamicImage::ImageRgb8(img.into_rgb8());
|
||||||
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
|
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
|
||||||
|
|
||||||
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
|
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
|
||||||
let mut resizer = fir::Resizer::new();
|
let mut resizer = fir::Resizer::new();
|
||||||
let options = fir::ResizeOptions::new()
|
let options = fir::ResizeOptions::new()
|
||||||
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
|
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom));
|
||||||
resizer.resize(&src, &mut dst, Some(&options))?;
|
resizer.resize(&src, &mut dst, Some(&options))?;
|
||||||
|
|
||||||
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
|
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
|
||||||
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
|
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
|
||||||
|
|
||||||
if let Some(parent) = out_path.parent() {
|
if let Some(parent) = out_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumb = image::DynamicImage::ImageRgba8(thumb).into_rgb8();
|
|
||||||
let mut output_file = std::fs::File::create(&out_path)?;
|
let mut output_file = std::fs::File::create(&out_path)?;
|
||||||
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
|
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
|
||||||
encoder.encode_image(&thumb)?;
|
encoder.encode_image(&thumb)?;
|
||||||
@@ -64,6 +61,98 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes an image for thumbnailing, with EXIF orientation already applied.
|
||||||
|
fn decode_for_thumbnail(image_path: &Path) -> Result<image::DynamicImage> {
|
||||||
|
decode_image_scaled(image_path, THUMB_SIZE, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes an image at reduced resolution, with EXIF orientation applied.
|
||||||
|
///
|
||||||
|
/// JPEGs take a fast path that decodes at reduced resolution (DCT scaling),
|
||||||
|
/// which skips most of the IDCT/upsampling work for large photos. Anything
|
||||||
|
/// that path can't handle falls back to the `image` crate at full size.
|
||||||
|
///
|
||||||
|
/// `cover` selects which side must stay at or above `target`: `false` keeps
|
||||||
|
/// the longest side (for aspect-fit consumers), `true` keeps the shortest
|
||||||
|
/// side (for fill-crop consumers like the CLIP preprocessor).
|
||||||
|
pub fn decode_image_scaled(
|
||||||
|
image_path: &Path,
|
||||||
|
target: u32,
|
||||||
|
cover: bool,
|
||||||
|
) -> Result<image::DynamicImage> {
|
||||||
|
if is_jpeg(image_path) {
|
||||||
|
if let Some(img) = decode_jpeg_scaled(image_path, target, cover) {
|
||||||
|
return Ok(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
|
||||||
|
let mut decoder = reader.into_decoder()?;
|
||||||
|
let orientation = decoder.orientation()?;
|
||||||
|
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
||||||
|
img.apply_orientation(orientation);
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_jpeg(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a JPEG at the smallest DCT scale that still covers `target`.
|
||||||
|
/// Returns `None` on any failure (corrupt file, CMYK without conversion
|
||||||
|
/// support, etc.) so the caller can fall back to the generic decoder.
|
||||||
|
/// mozjpeg reports errors by panicking, hence the `catch_unwind`.
|
||||||
|
fn decode_jpeg_scaled(image_path: &Path, target: u32, cover: bool) -> Option<image::DynamicImage> {
|
||||||
|
let path = image_path.to_path_buf();
|
||||||
|
let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec<u8>, u32, u32)> {
|
||||||
|
let mut decompress = mozjpeg::Decompress::new_path(&path)?;
|
||||||
|
let (width, height) = decompress.size();
|
||||||
|
let reference = if cover {
|
||||||
|
width.min(height)
|
||||||
|
} else {
|
||||||
|
width.max(height)
|
||||||
|
};
|
||||||
|
decompress.scale(scale_numerator(reference, target));
|
||||||
|
let mut started = decompress.rgb()?;
|
||||||
|
let (out_width, out_height) = (started.width() as u32, started.height() as u32);
|
||||||
|
let pixels = started.read_scanlines::<u8>()?;
|
||||||
|
started.finish()?;
|
||||||
|
Ok((pixels, out_width, out_height))
|
||||||
|
})
|
||||||
|
.ok()?
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let (pixels, width, height) = decoded;
|
||||||
|
let buffer = image::RgbImage::from_raw(width, height, pixels)?;
|
||||||
|
let mut img = image::DynamicImage::ImageRgb8(buffer);
|
||||||
|
if let Some(orientation) = exif_orientation(image_path) {
|
||||||
|
img.apply_orientation(orientation);
|
||||||
|
}
|
||||||
|
Some(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smallest numerator (of /8) that keeps `reference` at or above `target`,
|
||||||
|
/// so the subsequent resize never upscales.
|
||||||
|
fn scale_numerator(reference: usize, target: u32) -> u8 {
|
||||||
|
for numerator in 1..=8u8 {
|
||||||
|
if reference * numerator as usize / 8 >= target as usize {
|
||||||
|
return numerator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exif_orientation(path: &Path) -> Option<image::metadata::Orientation> {
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut reader = std::io::BufReader::new(file);
|
||||||
|
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
|
||||||
|
let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
|
||||||
|
let value = field.value.get_uint(0)?;
|
||||||
|
image::metadata::Orientation::from_exif(value as u8)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_video_thumbnail(
|
pub fn generate_video_thumbnail(
|
||||||
tools: &MediaTools,
|
tools: &MediaTools,
|
||||||
video_path: &Path,
|
video_path: &Path,
|
||||||
@@ -88,6 +177,8 @@ pub fn generate_video_thumbnail(
|
|||||||
let attempts: [&[&str]; 3] = [
|
let attempts: [&[&str]; 3] = [
|
||||||
&[
|
&[
|
||||||
"-y",
|
"-y",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
"-ss",
|
"-ss",
|
||||||
"00:00:00.000",
|
"00:00:00.000",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -95,13 +186,15 @@ pub fn generate_video_thumbnail(
|
|||||||
"-frames:v",
|
"-frames:v",
|
||||||
"1",
|
"1",
|
||||||
"-vf",
|
"-vf",
|
||||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
"-q:v",
|
"-q:v",
|
||||||
"4",
|
"4",
|
||||||
&output_path,
|
&output_path,
|
||||||
],
|
],
|
||||||
&[
|
&[
|
||||||
"-y",
|
"-y",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
"-ss",
|
"-ss",
|
||||||
"00:00:00.250",
|
"00:00:00.250",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -109,19 +202,21 @@ pub fn generate_video_thumbnail(
|
|||||||
"-frames:v",
|
"-frames:v",
|
||||||
"1",
|
"1",
|
||||||
"-vf",
|
"-vf",
|
||||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
"-q:v",
|
"-q:v",
|
||||||
"4",
|
"4",
|
||||||
&output_path,
|
&output_path,
|
||||||
],
|
],
|
||||||
&[
|
&[
|
||||||
"-y",
|
"-y",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
"-i",
|
"-i",
|
||||||
path_str.as_ref(),
|
path_str.as_ref(),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
"1",
|
"1",
|
||||||
"-vf",
|
"-vf",
|
||||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
"-q:v",
|
"-q:v",
|
||||||
"4",
|
"4",
|
||||||
&output_path,
|
&output_path,
|
||||||
@@ -142,9 +237,7 @@ pub fn generate_video_thumbnail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"ffmpeg failed generating poster for {}: {}",
|
"ffmpeg failed generating poster for {path_str}: {last_error}"
|
||||||
path_str,
|
|
||||||
last_error
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +251,7 @@ pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
|
|||||||
|
|
||||||
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
|
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
|
||||||
let hash = hash_path(input_path);
|
let hash = hash_path(input_path);
|
||||||
cache_dir.join(format!("{}.{}", hash, extension))
|
cache_dir.join(format!("{hash}.{extension}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_path(s: &str) -> String {
|
fn hash_path(s: &str) -> String {
|
||||||
@@ -178,3 +271,46 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
|
|||||||
(scaled_width.max(1), max_size)
|
(scaled_width.max(1), max_size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_numerator_picks_smallest_sufficient() {
|
||||||
|
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
|
||||||
|
assert_eq!(scale_numerator(640, THUMB_SIZE), 4);
|
||||||
|
assert_eq!(scale_numerator(320, THUMB_SIZE), 8);
|
||||||
|
assert_eq!(scale_numerator(100, THUMB_SIZE), 8);
|
||||||
|
// Cover mode reference: shortest side must reach 224 for CLIP.
|
||||||
|
assert_eq!(scale_numerator(1200, 224), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jpeg_fast_path_decodes_at_reduced_resolution() {
|
||||||
|
let dir = std::env::temp_dir().join("phokus-thumb-test");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let src_path = dir.join("large.jpg");
|
||||||
|
let img =
|
||||||
|
image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128]));
|
||||||
|
img.save(&src_path).unwrap();
|
||||||
|
|
||||||
|
// 1600 max dim -> numerator 2 -> 400x300 decode output
|
||||||
|
let decoded = decode_jpeg_scaled(&src_path, THUMB_SIZE, false)
|
||||||
|
.expect("fast path should handle plain JPEG");
|
||||||
|
assert_eq!((decoded.width(), decoded.height()), (400, 300));
|
||||||
|
|
||||||
|
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
|
||||||
|
let covered =
|
||||||
|
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
|
||||||
|
assert_eq!((covered.width(), covered.height()), (400, 300));
|
||||||
|
|
||||||
|
let cache = dir.join("cache");
|
||||||
|
let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy()));
|
||||||
|
let result = generate_image_thumbnail(&src_path, &cache).unwrap();
|
||||||
|
assert_eq!(result.width, Some(1600));
|
||||||
|
assert_eq!(result.height, Some(1200));
|
||||||
|
let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap();
|
||||||
|
assert_eq!((thumb_w, thumb_h), (320, 240));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ static SQLITE_VEC_INIT: Once = Once::new();
|
|||||||
|
|
||||||
pub fn register_sqlite_vec() {
|
pub fn register_sqlite_vec() {
|
||||||
SQLITE_VEC_INIT.call_once(|| unsafe {
|
SQLITE_VEC_INIT.call_once(|| unsafe {
|
||||||
sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
|
sqlite3_auto_extension(Some(std::mem::transmute::<
|
||||||
|
*const (),
|
||||||
|
unsafe extern "C" fn(
|
||||||
|
*mut rusqlite::ffi::sqlite3,
|
||||||
|
*mut *mut std::os::raw::c_char,
|
||||||
|
*const rusqlite::ffi::sqlite3_api_routines,
|
||||||
|
) -> i32,
|
||||||
|
>(sqlite3_vec_init as *const ())));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +25,13 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
conn.execute_batch(&format!(
|
conn.execute_batch(&format!(
|
||||||
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
|
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
|
||||||
image_id INTEGER PRIMARY KEY,
|
image_id INTEGER PRIMARY KEY,
|
||||||
embedding FLOAT[{}] distance_metric=cosine
|
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
|
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
|
||||||
image_id INTEGER PRIMARY KEY,
|
image_id INTEGER PRIMARY KEY,
|
||||||
embedding FLOAT[{}] distance_metric=cosine
|
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
|
||||||
);",
|
);"
|
||||||
CLIP_VECTOR_DIM, CLIP_VECTOR_DIM
|
|
||||||
))?;
|
))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -465,7 +471,7 @@ pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result<bool> {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn pack_f32(values: &[f32]) -> Vec<u8> {
|
fn pack_f32(values: &[f32]) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
|
let mut out = Vec::with_capacity(std::mem::size_of_val(values));
|
||||||
for value in values {
|
for value in values {
|
||||||
out.extend_from_slice(&value.to_le_bytes());
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Phokus",
|
"productName": "Phokus",
|
||||||
"version": "0.1.0",
|
"version": null,
|
||||||
"identifier": "wtf.jezz.phokus",
|
"identifier": "wtf.jezz.phokus",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev:vite",
|
"beforeDevCommand": "pnpm dev:vite",
|
||||||
@@ -22,18 +22,34 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null,
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost; font-src 'self' data:; object-src 'none'",
|
||||||
|
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420; font-src 'self' data:; object-src 'none'",
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
"**"
|
"$APPDATA/thumbnails/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyMDVBQzkyOENENjYzOTUKUldTVlk5YU1rcXdGWW9JRklYdHpOVXA1MDNMVEpyell6cVlma0VGS3pYaUVBLzJydy9nNUtJdlUK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/JezzWTF/phokus/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["nsis"],
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"publisher": "JezzWTF",
|
||||||
|
"license": "MIT",
|
||||||
|
"copyright": "Copyright © 2026 JezzWTF",
|
||||||
|
"shortDescription": "Local-first desktop media library",
|
||||||
|
"longDescription": "A local-first desktop media library for browsing, filtering, and curating image and video folders, with AI tagging, semantic search, and duplicate detection — all processed on-device.",
|
||||||
|
"homepage": "https://git.jezz.wtf/jezzwtf/phokus",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/JezzWTF/phokus/releases/latest/download/latest-cuda.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerHooks": "./windows/cuda-hooks.nsh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
; NSIS hooks for the CUDA build variant.
|
||||||
|
;
|
||||||
|
; The CUDA exe load-time-imports the CUDA runtime DLLs, so they must sit next
|
||||||
|
; to phokus.exe ($INSTDIR) where the Windows loader searches. Rather than
|
||||||
|
; bundling them as Tauri "resources" (which land in $INSTDIR\resources\) and
|
||||||
|
; then relocating them — a move that proved unreliable in the elevated/silent
|
||||||
|
; installer (CopyFiles via SHFileOperation, and even Rename, failed) — we embed
|
||||||
|
; them directly into the installer with NSIS `File` and extract them straight
|
||||||
|
; to $INSTDIR. `File` is the standard, reliable install primitive.
|
||||||
|
;
|
||||||
|
; This hook is !included from its original location, and ${__FILEDIR__} in this
|
||||||
|
; top-level !define expands at *include* time to this file's own directory,
|
||||||
|
; src-tauri\windows. cuda-redist is its sibling under src-tauri, so one level
|
||||||
|
; up. (Caveat that bit us: ${__FILEDIR__} used inline *inside the macro* would
|
||||||
|
; instead expand at insertion time to Tauri's generated nsis dir — hence the
|
||||||
|
; !define here.) The 4 DLLs must be present in src-tauri\cuda-redist at build
|
||||||
|
; time (see RELEASE_PLAN.md).
|
||||||
|
|
||||||
|
!define CUDA_REDIST "${__FILEDIR__}\..\cuda-redist"
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
|
DetailPrint "Phokus: installing bundled CUDA runtime libraries..."
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
File "${CUDA_REDIST}\cudart64_12.dll"
|
||||||
|
File "${CUDA_REDIST}\cublas64_12.dll"
|
||||||
|
File "${CUDA_REDIST}\cublasLt64_12.dll"
|
||||||
|
File "${CUDA_REDIST}\curand64_10.dll"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_POSTUNINSTALL
|
||||||
|
Delete "$INSTDIR\cudart64_12.dll"
|
||||||
|
Delete "$INSTDIR\cublas64_12.dll"
|
||||||
|
Delete "$INSTDIR\cublasLt64_12.dll"
|
||||||
|
Delete "$INSTDIR\curand64_10.dll"
|
||||||
|
!macroend
|
||||||
@@ -7,8 +7,12 @@ import { Gallery } from "./components/Gallery";
|
|||||||
import { Lightbox } from "./components/Lightbox";
|
import { Lightbox } from "./components/Lightbox";
|
||||||
import { TagCloud } from "./components/TagCloud";
|
import { TagCloud } from "./components/TagCloud";
|
||||||
import { DuplicateFinder } from "./components/DuplicateFinder";
|
import { DuplicateFinder } from "./components/DuplicateFinder";
|
||||||
|
import { Timeline } from "./components/Timeline";
|
||||||
import { TitleBar } from "./components/TitleBar";
|
import { TitleBar } from "./components/TitleBar";
|
||||||
import { SettingsModal } from "./components/SettingsModal";
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
|
import { UpdateToast } from "./components/UpdateToast";
|
||||||
|
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
|
||||||
|
import { DemoPanel } from "./components/DemoPanel";
|
||||||
import { initializeNotifications } from "./notifications";
|
import { initializeNotifications } from "./notifications";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -17,11 +21,26 @@ export default function App() {
|
|||||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||||
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
||||||
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
||||||
|
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
|
||||||
|
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
|
||||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||||
|
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
|
||||||
|
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
|
||||||
|
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
|
||||||
|
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
|
||||||
const activeView = useGalleryStore((state) => state.activeView);
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void initializeNotifications();
|
void initializeNotifications();
|
||||||
|
void loadMutedFolderIds();
|
||||||
|
void loadNotificationsPaused();
|
||||||
|
void loadAppVersion();
|
||||||
|
void loadFfmpegStatus();
|
||||||
|
void loadOnboardingCompleted();
|
||||||
|
// Quiet launch check — dev builds have no signed artifacts to update to.
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
void checkForUpdates({ quiet: true });
|
||||||
|
}
|
||||||
loadFolders().then(() => {
|
loadFolders().then(() => {
|
||||||
void loadBackgroundJobProgress();
|
void loadBackgroundJobProgress();
|
||||||
void loadCaptionModelStatus();
|
void loadCaptionModelStatus();
|
||||||
@@ -46,7 +65,13 @@ export default function App() {
|
|||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
{activeView === "explore" ? (
|
{activeView === "timeline" ? (
|
||||||
|
<>
|
||||||
|
<Toolbar />
|
||||||
|
<BackgroundTasks />
|
||||||
|
<Timeline />
|
||||||
|
</>
|
||||||
|
) : activeView === "explore" ? (
|
||||||
<>
|
<>
|
||||||
<BackgroundTasks />
|
<BackgroundTasks />
|
||||||
<TagCloud />
|
<TagCloud />
|
||||||
@@ -68,6 +93,9 @@ export default function App() {
|
|||||||
|
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
|
<UpdateToast />
|
||||||
|
<OnboardingOverlay />
|
||||||
|
{import.meta.env.DEV && <DemoPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore, WorkerKey } from "../store";
|
||||||
|
|
||||||
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
|
|
||||||
|
|
||||||
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
||||||
Thumbnails: "thumbnail",
|
Thumbnails: "thumbnail",
|
||||||
@@ -38,21 +36,6 @@ interface FailedEmbeddingItem {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FolderWorkerStates {
|
|
||||||
folder_id: number;
|
|
||||||
thumbnail_paused: boolean;
|
|
||||||
metadata_paused: boolean;
|
|
||||||
embedding_paused: boolean;
|
|
||||||
tagging_paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
|
|
||||||
thumbnail: false,
|
|
||||||
metadata: false,
|
|
||||||
embedding: false,
|
|
||||||
tagging: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BackgroundTasks() {
|
export function BackgroundTasks() {
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
@@ -63,32 +46,15 @@ export function BackgroundTasks() {
|
|||||||
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
||||||
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
|
|
||||||
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
const workerPaused = useGalleryStore((state) => state.workerPaused);
|
||||||
const folderIds = folders.map((folder) => folder.id);
|
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
|
||||||
if (folderIds.length === 0) {
|
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
|
||||||
setPaused({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
invoke<FolderWorkerStates[]>("get_worker_states", { folderIds }).then((states) => {
|
useEffect(() => {
|
||||||
setPaused(
|
void loadWorkerStates();
|
||||||
Object.fromEntries(
|
}, [folders, loadWorkerStates]);
|
||||||
states.map((state) => [
|
|
||||||
state.folder_id,
|
|
||||||
{
|
|
||||||
thumbnail: state.thumbnail_paused,
|
|
||||||
metadata: state.metadata_paused,
|
|
||||||
embedding: state.embedding_paused,
|
|
||||||
tagging: state.tagging_paused,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
||||||
const failedCounts = useMemo(
|
const failedCounts = useMemo(
|
||||||
@@ -113,20 +79,11 @@ export function BackgroundTasks() {
|
|||||||
}, [expanded, failedCounts]);
|
}, [expanded, failedCounts]);
|
||||||
|
|
||||||
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
|
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
|
||||||
return paused[folderId]?.[worker] ?? DEFAULT_PAUSED_STATE[worker];
|
return workerPaused[folderId]?.[worker] ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleWorker = (folderId: number, worker: WorkerKey) => {
|
const toggleWorker = (folderId: number, worker: WorkerKey) => {
|
||||||
const next = !isWorkerPaused(folderId, worker);
|
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
|
||||||
setPaused((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[folderId]: {
|
|
||||||
...DEFAULT_PAUSED_STATE,
|
|
||||||
...prev[folderId],
|
|
||||||
[worker]: next,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
void invoke("set_worker_paused", { worker, folderId, paused: next });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dismissTask = (id: number, snapshot: string) => {
|
const dismissTask = (id: number, snapshot: string) => {
|
||||||
@@ -278,12 +235,16 @@ export function BackgroundTasks() {
|
|||||||
id: -1,
|
id: -1,
|
||||||
name: "Duplicate Scan",
|
name: "Duplicate Scan",
|
||||||
stages: [{
|
stages: [{
|
||||||
label: "Hashing",
|
label: duplicateScanProgress?.phase === "checking"
|
||||||
|
? "Checking"
|
||||||
|
: duplicateScanProgress?.phase === "confirming"
|
||||||
|
? "Confirming"
|
||||||
|
: "Hashing",
|
||||||
detail: duplicateScanProgress
|
detail: duplicateScanProgress
|
||||||
? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
|
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
|
||||||
: "Starting…",
|
: "Starting…",
|
||||||
progress: duplicateScanProgress && duplicateScanProgress.total > 0
|
progress: duplicateScanProgress && duplicateScanProgress.total > 0
|
||||||
? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100
|
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
|
||||||
: null,
|
: null,
|
||||||
failed: false,
|
failed: false,
|
||||||
}],
|
}],
|
||||||
@@ -310,7 +271,8 @@ export function BackgroundTasks() {
|
|||||||
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
||||||
const taggingStage = primary.stages.find((s) => s.label === "Tags");
|
const taggingStage = primary.stages.find((s) => s.label === "Tags");
|
||||||
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
||||||
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? null;
|
const duplicateStage = primary.id === -1 ? primary.stages[0] : null;
|
||||||
|
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-white/[0.06]">
|
<div className="shrink-0 border-b border-white/[0.06]">
|
||||||
@@ -434,7 +396,8 @@ export function BackgroundTasks() {
|
|||||||
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
||||||
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
|
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
|
||||||
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
||||||
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? null;
|
const taskDuplicateStage = task.id === -1 ? task.stages[0] : null;
|
||||||
|
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null;
|
||||||
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
|
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { FolderJobProgress, useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
|
||||||
|
// too transient (or unreachable) to capture: the background-worker pipeline,
|
||||||
|
// which drains in under a second, and the auto-updater flow, which never fires
|
||||||
|
// locally because there's no remote latest.json to check against.
|
||||||
|
// Gated by import.meta.env.DEV in App.tsx so the whole thing is tree-shaken out
|
||||||
|
// of production builds.
|
||||||
|
//
|
||||||
|
// Toggle the controls with Ctrl+Shift+D. Injected state persists in the store
|
||||||
|
// while the panel is hidden, so: open → inject → hide → screenshot.
|
||||||
|
|
||||||
|
function emptyProgress(folderId: number): FolderJobProgress {
|
||||||
|
return {
|
||||||
|
folder_id: folderId,
|
||||||
|
thumbnail_pending: 0,
|
||||||
|
metadata_pending: 0,
|
||||||
|
embedding_pending: 0,
|
||||||
|
embedding_ready: 0,
|
||||||
|
embedding_failed: 0,
|
||||||
|
caption_pending: 0,
|
||||||
|
caption_ready: 0,
|
||||||
|
caption_failed: 0,
|
||||||
|
tagging_pending: 0,
|
||||||
|
tagging_ready: 0,
|
||||||
|
tagging_failed: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A believable multi-folder "busy pipeline" — three folders at different stages.
|
||||||
|
const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
|
||||||
|
// Embeddings actively running, tagging queued behind it.
|
||||||
|
{ embedding_ready: 34, embedding_pending: 16, tagging_pending: 50 },
|
||||||
|
// Just started: thumbnails draining, everything else queued.
|
||||||
|
{ thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 },
|
||||||
|
// Late stage: embeddings done, tagging running.
|
||||||
|
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEMO_UPDATE_VERSION = "0.2.0";
|
||||||
|
|
||||||
|
export function DemoPanel() {
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const downloadTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen((value) => !value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (downloadTimer.current !== null) {
|
||||||
|
window.clearInterval(downloadTimer.current);
|
||||||
|
downloadTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop any running download animation when the panel unmounts.
|
||||||
|
useEffect(() => stopTimer, []);
|
||||||
|
|
||||||
|
const injectBusy = () => {
|
||||||
|
const progress: Record<number, FolderJobProgress> = {};
|
||||||
|
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
|
||||||
|
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] };
|
||||||
|
});
|
||||||
|
useGalleryStore.setState({ mediaJobProgress: progress });
|
||||||
|
};
|
||||||
|
|
||||||
|
const injectSingleEmbedding = () => {
|
||||||
|
const folder = folders[0];
|
||||||
|
if (!folder) return;
|
||||||
|
useGalleryStore.setState({
|
||||||
|
mediaJobProgress: {
|
||||||
|
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
|
||||||
|
|
||||||
|
// --- Updater flow ---------------------------------------------------------
|
||||||
|
// These drive the real UpdateToast + Settings "About" row. The Install button
|
||||||
|
// stays inert (there's no real pendingUpdate), so this validates the
|
||||||
|
// presentation only — see DemoPanel's header note for the real e2e path.
|
||||||
|
|
||||||
|
const updateAvailable = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "available",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
|
||||||
|
const simulateDownload = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "downloading",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
let progress = 0;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
downloadTimer.current = window.setInterval(() => {
|
||||||
|
progress = Math.min(progress + 0.07, 1);
|
||||||
|
useGalleryStore.setState({ updateProgress: progress });
|
||||||
|
if (progress >= 1) {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({ updateStatus: "installing" });
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInstalling = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "installing",
|
||||||
|
updateVersion: DEMO_UPDATE_VERSION,
|
||||||
|
updateProgress: 1,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateError = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "error",
|
||||||
|
updateError: "Could not reach the update server (connection timed out).",
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUpToDate = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUpdate = () => {
|
||||||
|
stopTimer();
|
||||||
|
useGalleryStore.setState({
|
||||||
|
updateStatus: "idle",
|
||||||
|
updateVersion: null,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const injectBtn =
|
||||||
|
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25";
|
||||||
|
const neutralBtn =
|
||||||
|
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p>
|
||||||
|
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
|
||||||
|
Inject a frozen worker-bar state, hide this panel, then screenshot.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<button className={injectBtn} onClick={injectBusy}>
|
||||||
|
Pipeline: busy (3 folders)
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={injectSingleEmbedding}>
|
||||||
|
Pipeline: embedding (1 folder)
|
||||||
|
</button>
|
||||||
|
<button className={neutralBtn} onClick={clear}>
|
||||||
|
Clear injected state
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-3 h-px bg-amber-400/20" />
|
||||||
|
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Update flow</p>
|
||||||
|
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
|
||||||
|
Drives the real toast (bottom-right) + Settings → About row. Install is inert here.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<button className={injectBtn} onClick={updateAvailable}>
|
||||||
|
Update available
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={simulateDownload}>
|
||||||
|
Simulate download → install
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateInstalling}>
|
||||||
|
Installing
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateError}>
|
||||||
|
Check failed (error)
|
||||||
|
</button>
|
||||||
|
<button className={injectBtn} onClick={updateUpToDate}>
|
||||||
|
Up to date
|
||||||
|
</button>
|
||||||
|
<button className={neutralBtn} onClick={resetUpdate}>
|
||||||
|
Reset updater state
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||||
@@ -117,6 +118,7 @@ export function DuplicateFinder() {
|
|||||||
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||||
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
|
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
|
||||||
|
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning);
|
||||||
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
|
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
|
||||||
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
|
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
@@ -152,8 +154,15 @@ export function DuplicateFinder() {
|
|||||||
|
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
duplicateScanProgress && duplicateScanProgress.total > 0
|
duplicateScanProgress && duplicateScanProgress.total > 0
|
||||||
? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100)
|
? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
const progressLabel = duplicateScanProgress
|
||||||
|
? duplicateScanProgress.phase === "checking"
|
||||||
|
? "Checking file sizes"
|
||||||
|
: duplicateScanProgress.phase === "hashing"
|
||||||
|
? "Hashing duplicate candidates"
|
||||||
|
: "Confirming exact matches"
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
|
||||||
@@ -165,7 +174,7 @@ export function DuplicateFinder() {
|
|||||||
<p className="mt-0.5 text-[11px] text-white/30">
|
<p className="mt-0.5 text-[11px] text-white/30">
|
||||||
{duplicateScanning
|
{duplicateScanning
|
||||||
? duplicateScanProgress
|
? duplicateScanProgress
|
||||||
? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
|
? `${progressLabel}… ${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
|
||||||
: "Starting scan…"
|
: "Starting scan…"
|
||||||
: hasResults
|
: hasResults
|
||||||
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
|
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
|
||||||
@@ -180,6 +189,7 @@ export function DuplicateFinder() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderScopeDropdown />
|
||||||
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
||||||
{hasResults && selectedCount === 0 && !deleting && (
|
{hasResults && selectedCount === 0 && !deleting && (
|
||||||
<button
|
<button
|
||||||
@@ -232,6 +242,9 @@ export function DuplicateFinder() {
|
|||||||
{duplicateScanError ? (
|
{duplicateScanError ? (
|
||||||
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
|
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{duplicateScanWarning ? (
|
||||||
|
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
|
||||||
|
) : null}
|
||||||
{deleteResult ? (
|
{deleteResult ? (
|
||||||
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
|
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -241,7 +254,7 @@ export function DuplicateFinder() {
|
|||||||
{duplicateScanning && !hasResults ? (
|
{duplicateScanning && !hasResults ? (
|
||||||
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
|
||||||
<span className="text-sm">Hashing files…</span>
|
<span className="text-sm">{progressLabel ? `${progressLabel}…` : "Preparing scan…"}</span>
|
||||||
</div>
|
</div>
|
||||||
) : !hasScanned ? (
|
) : !hasScanned ? (
|
||||||
<div className="flex flex-1 items-center justify-center px-8">
|
<div className="flex flex-1 items-center justify-center px-8">
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-view folder scope picker for feature views (Timeline / Explore /
|
||||||
|
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
|
||||||
|
* current view active — unlike sidebar folder clicks, which jump to Gallery.
|
||||||
|
*/
|
||||||
|
export function FolderScopeDropdown() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
|
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", close);
|
||||||
|
return () => window.removeEventListener("pointerdown", close);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentLabel =
|
||||||
|
selectedFolderId === null
|
||||||
|
? "All Media"
|
||||||
|
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
|
||||||
|
|
||||||
|
const select = (folderId: number | null) => {
|
||||||
|
setViewFolderScope(folderId);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
open
|
||||||
|
? "border-white/15 bg-white/8 text-white"
|
||||||
|
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
title="Change folder scope"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{currentLabel}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => select(null)}
|
||||||
|
>
|
||||||
|
All Media
|
||||||
|
{selectedFolderId === null ? (
|
||||||
|
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{folders.map((folder) => {
|
||||||
|
const active = selectedFolderId === folder.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => select(folder.id)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{folder.name}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
|
||||||
|
{active ? (
|
||||||
|
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextMenu({
|
export function ContextMenu({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
image,
|
image,
|
||||||
@@ -104,7 +104,7 @@ function ContextMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageTile({
|
export function ImageTile({
|
||||||
image,
|
image,
|
||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from "react";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
||||||
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -114,6 +115,29 @@ function rectToNormalisedCrop(
|
|||||||
/** Minimum selection size as a fraction of the viewport container dimension. */
|
/** Minimum selection size as a fraction of the viewport container dimension. */
|
||||||
const MIN_SELECTION_FRACTION = 0.02;
|
const MIN_SELECTION_FRACTION = 0.02;
|
||||||
|
|
||||||
|
const MIN_ZOOM = 0.5;
|
||||||
|
const MAX_ZOOM = 4;
|
||||||
|
|
||||||
|
interface ViewTransform {
|
||||||
|
zoom: number;
|
||||||
|
panX: number;
|
||||||
|
panY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
|
||||||
|
|
||||||
|
/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the
|
||||||
|
* viewport centre — stays under the cursor across a zoom change. */
|
||||||
|
function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform {
|
||||||
|
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom };
|
||||||
|
const ratio = newZoom / view.zoom;
|
||||||
|
return {
|
||||||
|
zoom: newZoom,
|
||||||
|
panX: anchorX - (anchorX - view.panX) * ratio,
|
||||||
|
panY: anchorY - (anchorY - view.panY) * ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function Lightbox() {
|
export function Lightbox() {
|
||||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||||
@@ -134,7 +158,10 @@ export function Lightbox() {
|
|||||||
const currentImageIdRef = useRef<number | null>(null);
|
const currentImageIdRef = useRef<number | null>(null);
|
||||||
currentImageIdRef.current = selectedImage?.id ?? null;
|
currentImageIdRef.current = selectedImage?.id ?? null;
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
|
||||||
|
const zoom = view.zoom;
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const lastPanPointRef = useRef({ x: 0, y: 0 });
|
||||||
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
const [tagAdding, setTagAdding] = useState(false);
|
const [tagAdding, setTagAdding] = useState(false);
|
||||||
@@ -168,8 +195,24 @@ export function Lightbox() {
|
|||||||
setDragRect(null);
|
setDragRect(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keep the pan within bounds: when the scaled image overflows the viewport
|
||||||
|
// the image edge may not be dragged past the viewport edge; when it fits,
|
||||||
|
// the pan snaps back to centre on that axis.
|
||||||
|
const clampPan = useCallback((v: ViewTransform): ViewTransform => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
const vp = imageViewportRef.current;
|
||||||
|
if (!img || !vp) return v;
|
||||||
|
const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2);
|
||||||
|
const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2);
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
panX: Math.min(maxX, Math.max(-maxX, v.panX)),
|
||||||
|
panY: Math.min(maxY, Math.max(-maxY, v.panY)),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setView(IDENTITY_VIEW);
|
||||||
setImageTags([]);
|
setImageTags([]);
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
setTagsExpanded(false);
|
setTagsExpanded(false);
|
||||||
@@ -202,15 +245,19 @@ export function Lightbox() {
|
|||||||
if (regionSelectMode) return; // don't zoom during selection
|
if (regionSelectMode) return; // don't zoom during selection
|
||||||
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setZoom((value) => {
|
const bounds = viewport.getBoundingClientRect();
|
||||||
|
const anchorX = event.clientX - (bounds.left + bounds.width / 2);
|
||||||
|
const anchorY = event.clientY - (bounds.top + bounds.height / 2);
|
||||||
|
setView((v) => {
|
||||||
const delta = event.deltaY < 0 ? 0.15 : -0.15;
|
const delta = event.deltaY < 0 ? 0.15 : -0.15;
|
||||||
return Math.min(4, Math.max(0.5, value + delta));
|
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta));
|
||||||
|
return clampPan(zoomViewAt(v, next, anchorX, anchorY));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
return () => viewport.removeEventListener("wheel", handleWheel);
|
return () => viewport.removeEventListener("wheel", handleWheel);
|
||||||
}, [selectedImage, regionSelectMode]);
|
}, [selectedImage, regionSelectMode, clampPan]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
@@ -223,20 +270,32 @@ export function Lightbox() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (regionSelectMode) return; // block nav keys during selection
|
if (regionSelectMode) return; // block nav keys during selection
|
||||||
if (event.key === "ArrowLeft") goPrev();
|
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
|
||||||
if (event.key === "ArrowRight") goNext();
|
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
|
||||||
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
|
||||||
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
|
if (event.key === "+" || event.key === "=")
|
||||||
|
setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0)));
|
||||||
|
if (event.key === "-")
|
||||||
|
setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0)));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]);
|
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]);
|
||||||
|
|
||||||
// ── Region selection pointer handlers ───────────────────────────────────────
|
// ── Region selection / pan pointer handlers ─────────────────────────────────
|
||||||
const handleRegionPointerDown = useCallback(
|
const handleRegionPointerDown = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!regionSelectMode) return;
|
if (!regionSelectMode) {
|
||||||
|
// Drag-to-pan when the image is zoomed in
|
||||||
|
if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
setIsPanning(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
@@ -247,21 +306,33 @@ export function Lightbox() {
|
|||||||
endY: event.clientY,
|
endY: event.clientY,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[regionSelectMode],
|
[regionSelectMode, zoom, selectedImage?.media_kind],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRegionPointerMove = useCallback(
|
const handleRegionPointerMove = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (isPanning) {
|
||||||
|
const dx = event.clientX - lastPanPointRef.current.x;
|
||||||
|
const dy = event.clientY - lastPanPointRef.current.y;
|
||||||
|
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
setDragRect((prev) =>
|
setDragRect((prev) =>
|
||||||
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
|
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[isDragging],
|
[isDragging, isPanning, clampPan],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRegionPointerUp = useCallback(
|
const handleRegionPointerUp = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (isPanning) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
setIsPanning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
|
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
return;
|
return;
|
||||||
@@ -294,7 +365,7 @@ export function Lightbox() {
|
|||||||
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
|
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
|
||||||
.finally(() => setRegionSearching(false));
|
.finally(() => setRegionSearching(false));
|
||||||
},
|
},
|
||||||
[isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
|
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build the CSS rect for the selection overlay (viewport-relative)
|
// Build the CSS rect for the selection overlay (viewport-relative)
|
||||||
@@ -330,8 +401,14 @@ export function Lightbox() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={imageViewportRef}
|
ref={imageViewportRef}
|
||||||
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${
|
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
|
||||||
regionSelectMode ? "cursor-crosshair select-none" : ""
|
regionSelectMode
|
||||||
|
? "cursor-crosshair select-none"
|
||||||
|
: isPanning
|
||||||
|
? "cursor-grabbing select-none"
|
||||||
|
: zoom > 1 && selectedImage.media_kind === "image"
|
||||||
|
? "cursor-grab"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onPointerDown={handleRegionPointerDown}
|
onPointerDown={handleRegionPointerDown}
|
||||||
onPointerMove={handleRegionPointerMove}
|
onPointerMove={handleRegionPointerMove}
|
||||||
@@ -365,19 +442,18 @@ export function Lightbox() {
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={selectedImage.id}
|
key={selectedImage.id}
|
||||||
className="flex items-center justify-center"
|
className={
|
||||||
|
selectedImage.media_kind === "video"
|
||||||
|
? "absolute inset-0"
|
||||||
|
: "flex items-center justify-center"
|
||||||
|
}
|
||||||
initial={{ opacity: 0.3, scale: 0.985 }}
|
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0.3, scale: 0.985 }}
|
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||||
transition={{ duration: 0.12 }}
|
transition={{ duration: 0.12 }}
|
||||||
>
|
>
|
||||||
{selectedImage.media_kind === "video" ? (
|
{selectedImage.media_kind === "video" ? (
|
||||||
<video
|
<VideoPlayer src={convertFileSrc(selectedImage.path)} />
|
||||||
src={convertFileSrc(selectedImage.path)}
|
|
||||||
controls
|
|
||||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
|
||||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
@@ -385,9 +461,10 @@ export function Lightbox() {
|
|||||||
src={convertFileSrc(selectedImage.path)}
|
src={convertFileSrc(selectedImage.path)}
|
||||||
alt={selectedImage.filename}
|
alt={selectedImage.filename}
|
||||||
className="max-w-full rounded-2xl shadow-2xl"
|
className="max-w-full rounded-2xl shadow-2xl"
|
||||||
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "calc(100vh - 10rem)",
|
maxHeight: "calc(100vh - 10rem)",
|
||||||
transform: `scale(${zoom})`,
|
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
// Slightly dim the image while in region select mode
|
// Slightly dim the image while in region select mode
|
||||||
...(regionSelectMode ? { opacity: 0.85 } : {}),
|
...(regionSelectMode ? { opacity: 0.85 } : {}),
|
||||||
@@ -398,14 +475,14 @@ export function Lightbox() {
|
|||||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||||
<button
|
<button
|
||||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||||
<button
|
<button
|
||||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Phokus brand mark — a camera iris, rendered inline so it inherits `currentColor`
|
||||||
|
// and scales to any size. Drive color + dimensions from the className, e.g.
|
||||||
|
// <PhokusMark className="h-4 w-4 text-gray-300" />
|
||||||
|
// The full app-icon tile (filled iris on a dark rounded square) lives in
|
||||||
|
// branding/phokus-aperture.svg and feeds `pnpm tauri icon`.
|
||||||
|
//
|
||||||
|
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
|
||||||
|
// used in the titlebar as the "update available" indicator.
|
||||||
|
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
|
||||||
|
|
||||||
|
export function PhokusMark({
|
||||||
|
className,
|
||||||
|
dotClassName,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
dotClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||||
|
<g
|
||||||
|
transform="translate(12 12)"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle r="10" />
|
||||||
|
<path d="M0,-4.18 A4.73,4.73 0 0 0 3.62,-2.09 A4.73,4.73 0 0 0 3.62,2.09 A4.73,4.73 0 0 0 0,4.18 A4.73,4.73 0 0 0 -3.62,2.09 A4.73,4.73 0 0 0 -3.62,-2.09 A4.73,4.73 0 0 0 0,-4.18 Z" />
|
||||||
|
<path d={BLADE} />
|
||||||
|
<path d={BLADE} transform="rotate(60)" />
|
||||||
|
<path d={BLADE} transform="rotate(120)" />
|
||||||
|
<path d={BLADE} transform="rotate(180)" />
|
||||||
|
<path d={BLADE} transform="rotate(240)" />
|
||||||
|
<path d={BLADE} transform="rotate(300)" />
|
||||||
|
</g>
|
||||||
|
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,18 +11,26 @@ interface ContextMenuState {
|
|||||||
function FolderContextMenu({
|
function FolderContextMenu({
|
||||||
menu,
|
menu,
|
||||||
folder,
|
folder,
|
||||||
|
isMuted,
|
||||||
|
isPausedAll,
|
||||||
onClose,
|
onClose,
|
||||||
onRename,
|
onRename,
|
||||||
onReindex,
|
onReindex,
|
||||||
onLocate,
|
onLocate,
|
||||||
|
onToggleMute,
|
||||||
|
onTogglePauseAll,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
menu: ContextMenuState;
|
menu: ContextMenuState;
|
||||||
folder: Folder;
|
folder: Folder;
|
||||||
|
isMuted: boolean;
|
||||||
|
isPausedAll: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
onReindex: () => void;
|
onReindex: () => void;
|
||||||
onLocate: () => void;
|
onLocate: () => void;
|
||||||
|
onToggleMute: () => void;
|
||||||
|
onTogglePauseAll: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -61,6 +69,8 @@ function FolderContextMenu({
|
|||||||
>
|
>
|
||||||
{item("Reindex", onReindex)}
|
{item("Reindex", onReindex)}
|
||||||
{item("Rename", onRename)}
|
{item("Rename", onRename)}
|
||||||
|
{item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)}
|
||||||
|
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
|
||||||
{folder.scan_error && item("Locate Folder", onLocate)}
|
{folder.scan_error && item("Locate Folder", onLocate)}
|
||||||
<div className="my-1 border-t border-white/[0.06]" />
|
<div className="my-1 border-t border-white/[0.06]" />
|
||||||
{item("Remove from app", onRemove, true)}
|
{item("Remove from app", onRemove, true)}
|
||||||
@@ -77,7 +87,14 @@ function FolderItem({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
progress: IndexProgress | undefined;
|
progress: IndexProgress | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath } = useGalleryStore();
|
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
|
||||||
|
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
|
||||||
|
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
|
||||||
|
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]);
|
||||||
|
const isMuted = mutedFolderIds.includes(folder.id);
|
||||||
|
// "Fully paused" only when every worker for this folder is paused.
|
||||||
|
const isPausedAll = !!folderWorkers &&
|
||||||
|
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging;
|
||||||
const isIndexing = progress && !progress.done;
|
const isIndexing = progress && !progress.done;
|
||||||
const isMissing = !!folder.scan_error && !isIndexing;
|
const isMissing = !!folder.scan_error && !isIndexing;
|
||||||
|
|
||||||
@@ -250,10 +267,14 @@ function FolderItem({
|
|||||||
<FolderContextMenu
|
<FolderContextMenu
|
||||||
menu={contextMenu}
|
menu={contextMenu}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
|
isMuted={isMuted}
|
||||||
|
isPausedAll={isPausedAll}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
onRename={() => setRenaming(true)}
|
onRename={() => setRenaming(true)}
|
||||||
onReindex={() => void reindexFolder(folder.id)}
|
onReindex={() => void reindexFolder(folder.id)}
|
||||||
onLocate={() => void handleLocateFolder()}
|
onLocate={() => void handleLocateFolder()}
|
||||||
|
onToggleMute={() => toggleMutedFolder(folder.id)}
|
||||||
|
onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)}
|
||||||
onRemove={() => setConfirmingRemoval(true)}
|
onRemove={() => setConfirmingRemoval(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -329,6 +350,23 @@ export function Sidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
|
activeView === "timeline"
|
||||||
|
? "bg-white/8 text-white"
|
||||||
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
onClick={() => setView("timeline")}
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
|
||||||
|
Timeline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
activeView === "duplicates"
|
activeView === "duplicates"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
const ACCENTS = [
|
const ACCENTS = [
|
||||||
"#60a5fa",
|
"#60a5fa",
|
||||||
@@ -317,23 +318,26 @@ export function TagCloud() {
|
|||||||
: "No tags — run the AI tagger or add tags manually"}
|
: "No tags — run the AI tagger or add tags manually"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<button
|
<FolderScopeDropdown />
|
||||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||||
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
<button
|
||||||
}`}
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
onClick={() => setExploreMode("visual")}
|
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
>
|
}`}
|
||||||
Clusters
|
onClick={() => setExploreMode("visual")}
|
||||||
</button>
|
>
|
||||||
<button
|
Clusters
|
||||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
</button>
|
||||||
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
<button
|
||||||
}`}
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
onClick={() => setExploreMode("tags")}
|
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
>
|
}`}
|
||||||
Tag Cloud
|
onClick={() => setExploreMode("tags")}
|
||||||
</button>
|
>
|
||||||
|
Tag Cloud
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
||||||
|
import { ContextMenu, ImageTile } from "./Gallery";
|
||||||
|
|
||||||
|
const GAP = 6;
|
||||||
|
const HEADER_HEIGHT = 52;
|
||||||
|
|
||||||
|
interface TimelineGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
images: ImageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabel(key: string): string {
|
||||||
|
if (key === "unknown") return "Unknown Date";
|
||||||
|
const [yearStr, monthStr] = key.split("-");
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date";
|
||||||
|
const date = new Date(year, month - 1);
|
||||||
|
if (isNaN(date.getTime())) return "Unknown Date";
|
||||||
|
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupImages(images: ImageRecord[]): TimelineGroup[] {
|
||||||
|
const map = new Map<string, ImageRecord[]>();
|
||||||
|
for (const img of images) {
|
||||||
|
const ds = img.taken_at ?? img.modified_at;
|
||||||
|
const key = ds ? ds.substring(0, 7) : "unknown";
|
||||||
|
let bucket = map.get(key);
|
||||||
|
if (bucket === undefined) {
|
||||||
|
bucket = [];
|
||||||
|
map.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.push(img);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === "unknown") return 1;
|
||||||
|
if (b === "unknown") return -1;
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
})
|
||||||
|
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline() {
|
||||||
|
const images = useGalleryStore((s) => s.images);
|
||||||
|
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
|
||||||
|
const openImage = useGalleryStore((s) => s.openImage);
|
||||||
|
const totalImages = useGalleryStore((s) => s.totalImages);
|
||||||
|
const loadingImages = useGalleryStore((s) => s.loadingImages);
|
||||||
|
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
|
||||||
|
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
|
||||||
|
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
image: ImageRecord;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Measure container width before first paint to avoid a single-column flash.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setContainerWidth(el.clientWidth);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
setContainerWidth(entries[0].contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tileSize = tileSizeForZoom(zoomPreset);
|
||||||
|
const cols = useMemo(
|
||||||
|
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
|
||||||
|
[containerWidth, tileSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupImages(images), [images]);
|
||||||
|
|
||||||
|
// estimateSize must be exact so virtualizer positions groups correctly.
|
||||||
|
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
|
||||||
|
// GAP acts as spacing between this group and the next header.
|
||||||
|
const estimateSize = useCallback(
|
||||||
|
(index: number): number => {
|
||||||
|
const group = groups[index];
|
||||||
|
if (!group) return HEADER_HEIGHT;
|
||||||
|
const rowCount = Math.ceil(group.images.length / cols);
|
||||||
|
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
|
||||||
|
},
|
||||||
|
[groups, cols, tileSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: groups.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize,
|
||||||
|
overscan: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure all items when cols changes so virtualizer positions stay accurate
|
||||||
|
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
|
||||||
|
useEffect(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
}, [cols, virtualizer]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollTop < 24) return;
|
||||||
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
|
||||||
|
if (nearBottom && !loadingImages && images.length < totalImages) {
|
||||||
|
void loadMoreImages();
|
||||||
|
}
|
||||||
|
}, [images.length, loadMoreImages, loadingImages, totalImages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e: PointerEvent) => {
|
||||||
|
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setContextMenu(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", close);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", close);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
|
||||||
|
>
|
||||||
|
{images.length === 0 && loadingImages ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
||||||
|
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
|
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Fetching results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 mx-auto text-white/10 mb-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={0.75}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-white/30 font-medium">
|
||||||
|
{imageLoadError ? "Could not load timeline" : "No media found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/15 mt-1">
|
||||||
|
{imageLoadError ?? "Add a folder to see your timeline"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const group = groups[virtualItem.index];
|
||||||
|
if (!group) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: virtualItem.start,
|
||||||
|
width: "100%",
|
||||||
|
height: virtualItem.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Group header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-4"
|
||||||
|
style={{ height: HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-white/80 shrink-0">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/25 shrink-0 tabular-nums">
|
||||||
|
{group.images.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image grid — paddingBottom:GAP gives the gap below the last row,
|
||||||
|
matching the row-to-row gap and making estimateSize exact. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
||||||
|
gap: GAP,
|
||||||
|
paddingLeft: GAP,
|
||||||
|
paddingRight: GAP,
|
||||||
|
paddingBottom: GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.images.map((image) => (
|
||||||
|
<ImageTile
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
onClick={() => openImage(image)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length > 0 && loadingImages ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{contextMenu ? (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
image={contextMenu.image}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore } from "../store";
|
||||||
|
import { PhokusMark } from "./PhokusMark";
|
||||||
|
|
||||||
// SVG icons for window controls
|
// SVG icons for window controls
|
||||||
function MinimizeIcon() {
|
function MinimizeIcon() {
|
||||||
@@ -39,6 +40,9 @@ function CloseIcon() {
|
|||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||||
|
const updateStatus = useGalleryStore((state) => state.updateStatus);
|
||||||
|
const updateVersion = useGalleryStore((state) => state.updateVersion);
|
||||||
|
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +63,10 @@ export function TitleBar() {
|
|||||||
const handleMaximize = () => appWindow.toggleMaximize();
|
const handleMaximize = () => appWindow.toggleMaximize();
|
||||||
const handleClose = () => appWindow.close();
|
const handleClose = () => appWindow.close();
|
||||||
|
|
||||||
|
// An update is waiting for the user to act. Covers the "clicked Later" case too,
|
||||||
|
// since dismissing the toast doesn't change updateStatus.
|
||||||
|
const updatePending = updateStatus === "available";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
|
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
|
||||||
// WebkitAppRegion is kept as a CSS fallback for compatibility.
|
// WebkitAppRegion is kept as a CSS fallback for compatibility.
|
||||||
@@ -67,15 +75,32 @@ export function TitleBar() {
|
|||||||
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
|
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
|
||||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{/* App icon + name — left side */}
|
{/* App icon + name — left side. When an update is waiting, the iris lights
|
||||||
|
up its focal point and the chip becomes a button that re-opens the prompt. */}
|
||||||
<div className="flex items-center gap-2 pl-3 pr-4">
|
<div className="flex items-center gap-2 pl-3 pr-4">
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden">
|
{updatePending ? (
|
||||||
{/* Phokus logo placeholder — replace with <img src={logo} /> if you have an SVG */}
|
<div
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
className="group relative"
|
||||||
<circle cx="6" cy="6" r="4" stroke="#a78bfa" strokeWidth="1.5" />
|
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||||
<circle cx="6" cy="6" r="1.5" fill="#a78bfa" />
|
>
|
||||||
</svg>
|
<button
|
||||||
</div>
|
onClick={() => void installUpdate()}
|
||||||
|
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
|
||||||
|
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
|
||||||
|
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
|
||||||
|
</button>
|
||||||
|
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
|
||||||
|
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
|
||||||
|
Click to update — v{updateVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
|
||||||
|
<PhokusMark className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
|
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||||
{ value: "date_desc", label: "Newest first" },
|
{ value: "date_desc", label: "Newest first" },
|
||||||
{ value: "date_asc", label: "Oldest first" },
|
{ value: "date_asc", label: "Oldest first" },
|
||||||
|
{ value: "taken_desc", label: "Taken: newest" },
|
||||||
|
{ value: "taken_asc", label: "Taken: oldest" },
|
||||||
{ value: "name_asc", label: "Name A–Z" },
|
{ value: "name_asc", label: "Name A–Z" },
|
||||||
{ value: "name_desc", label: "Name Z–A" },
|
{ value: "name_desc", label: "Name Z–A" },
|
||||||
{ value: "rating_desc", label: "Highest rated" },
|
{ value: "rating_desc", label: "Highest rated" },
|
||||||
@@ -160,6 +163,7 @@ export function Toolbar() {
|
|||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||||
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||||
|
|
||||||
@@ -267,6 +271,8 @@ export function Toolbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
export function UpdateToast() {
|
||||||
|
const updateStatus = useGalleryStore((s) => s.updateStatus);
|
||||||
|
const updateVersion = useGalleryStore((s) => s.updateVersion);
|
||||||
|
const updateProgress = useGalleryStore((s) => s.updateProgress);
|
||||||
|
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
|
||||||
|
const installUpdate = useGalleryStore((s) => s.installUpdate);
|
||||||
|
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
|
||||||
|
|
||||||
|
const visible =
|
||||||
|
!updateDismissed &&
|
||||||
|
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 12 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
|
||||||
|
>
|
||||||
|
{updateStatus === "available" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-white">Update available</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Phokus v{updateVersion} is ready to download and install.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
||||||
|
onClick={() => void installUpdate()}
|
||||||
|
>
|
||||||
|
Install & restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
|
||||||
|
updateProgress === null ? "w-full animate-pulse" : ""
|
||||||
|
}`}
|
||||||
|
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
|
const CONTROLS_HIDE_DELAY_MS = 2500;
|
||||||
|
const SEEK_STEP_SECONDS = 5;
|
||||||
|
const VOLUME_STEP = 0.1;
|
||||||
|
|
||||||
|
// Session-wide playback preferences shared across player instances.
|
||||||
|
let persistedVolume = 1;
|
||||||
|
let persistedMuted = false;
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
|
const total = Math.floor(seconds);
|
||||||
|
const s = total % 60;
|
||||||
|
const m = Math.floor(total / 60) % 60;
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BufferedRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlButton({ onClick, title, active = false, children }: {
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPlayer({ src }: { src: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const scrubbingRef = useRef(false);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
||||||
|
const [volume, setVolume] = useState(persistedVolume);
|
||||||
|
const [muted, setMuted] = useState(persistedMuted);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
const [loop, setLoop] = useState(false);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const speedMenuOpenRef = useRef(speedMenuOpen);
|
||||||
|
speedMenuOpenRef.current = speedMenuOpen;
|
||||||
|
|
||||||
|
// ── Controls visibility ────────────────────────────────────────────────────
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
hideTimerRef.current = setTimeout(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}
|
||||||
|
}, CONTROLS_HIDE_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Video element wiring ───────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.volume = persistedVolume;
|
||||||
|
video.muted = persistedMuted;
|
||||||
|
// Autoplay; if the webview blocks it the catch leaves us paused with
|
||||||
|
// controls visible, which is a fine fallback.
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const readBuffered = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||||
|
const ranges: BufferedRange[] = [];
|
||||||
|
for (let i = 0; i < video.buffered.length; i++) {
|
||||||
|
ranges.push({
|
||||||
|
start: video.buffered.start(i) / video.duration,
|
||||||
|
end: video.buffered.end(i) / video.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBuffered(ranges);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
showControls();
|
||||||
|
}, [showControls]);
|
||||||
|
|
||||||
|
const seekBy = useCallback(
|
||||||
|
(deltaSeconds: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !Number.isFinite(video.duration)) return;
|
||||||
|
video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
|
||||||
|
showControls();
|
||||||
|
},
|
||||||
|
[showControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyVolume = useCallback(
|
||||||
|
(nextVolume: number, nextMuted?: boolean) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const clamped = Math.min(1, Math.max(0, nextVolume));
|
||||||
|
setVolume(clamped);
|
||||||
|
persistedVolume = clamped;
|
||||||
|
if (nextMuted !== undefined) {
|
||||||
|
setMuted(nextMuted);
|
||||||
|
persistedMuted = nextMuted;
|
||||||
|
if (video) video.muted = nextMuted;
|
||||||
|
}
|
||||||
|
if (video) video.volume = clamped;
|
||||||
|
showControls();
|
||||||
|
},
|
||||||
|
[showControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
const next = !muted;
|
||||||
|
setMuted(next);
|
||||||
|
persistedMuted = next;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) video.muted = next;
|
||||||
|
showControls();
|
||||||
|
}, [muted, showControls]);
|
||||||
|
|
||||||
|
const toggleLoop = useCallback(() => {
|
||||||
|
setLoop((value) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) video.loop = !value;
|
||||||
|
return !value;
|
||||||
|
});
|
||||||
|
showControls();
|
||||||
|
}, [showControls]);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
void containerRef.current?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
showControls();
|
||||||
|
}, [showControls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setFullscreen(document.fullscreenElement !== null);
|
||||||
|
document.addEventListener("fullscreenchange", onChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSpeed = useCallback(
|
||||||
|
(rate: number) => {
|
||||||
|
setPlaybackRate(rate);
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) video.playbackRate = rate;
|
||||||
|
setSpeedMenuOpen(false);
|
||||||
|
showControls();
|
||||||
|
},
|
||||||
|
[showControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Scrubber ───────────────────────────────────────────────────────────────
|
||||||
|
const seekToPointer = useCallback((clientX: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||||
|
const bounds = track.getBoundingClientRect();
|
||||||
|
const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
|
||||||
|
video.currentTime = fraction * video.duration;
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTrackPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
scrubbingRef.current = true;
|
||||||
|
seekToPointer(event.clientX);
|
||||||
|
},
|
||||||
|
[seekToPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrackPointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!scrubbingRef.current) return;
|
||||||
|
seekToPointer(event.clientX);
|
||||||
|
},
|
||||||
|
[seekToPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
scrubbingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard ───────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (event.key) {
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
case "M":
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
case "F":
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
case "l":
|
||||||
|
case "L":
|
||||||
|
toggleLoop();
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
seekBy(-SEEK_STEP_SECONDS);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
seekBy(SEEK_STEP_SECONDS);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
// Adjusting volume always unmutes, matching the slider behavior.
|
||||||
|
applyVolume(persistedVolume + VOLUME_STEP, false);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
applyVolume(persistedVolume - VOLUME_STEP, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
|
||||||
|
|
||||||
|
const playedFraction = duration > 0 ? currentTime / duration : 0;
|
||||||
|
const effectiveVolume = muted ? 0 : volume;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
|
||||||
|
onPointerMove={showControls}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
onClick={togglePlay}
|
||||||
|
onDoubleClick={toggleFullscreen}
|
||||||
|
onPlay={() => {
|
||||||
|
setPlaying(true);
|
||||||
|
showControls();
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setControlsVisible(true);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||||
|
onLoadedMetadata={(event) => {
|
||||||
|
setDuration(event.currentTarget.duration);
|
||||||
|
readBuffered();
|
||||||
|
}}
|
||||||
|
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||||
|
onProgress={readBuffered}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{controlsVisible ? (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{/* Scrubber */}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="group/track relative flex h-4 cursor-pointer items-center"
|
||||||
|
onPointerDown={handleTrackPointerDown}
|
||||||
|
onPointerMove={handleTrackPointerMove}
|
||||||
|
onPointerUp={handleTrackPointerUp}
|
||||||
|
>
|
||||||
|
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
|
||||||
|
{buffered.map((range, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="absolute inset-y-0 bg-white/20"
|
||||||
|
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
|
||||||
|
style={{ left: `${playedFraction * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control row */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}>
|
||||||
|
{playing ? (
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5.14v13.72L19 12 8 5.14z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</ControlButton>
|
||||||
|
|
||||||
|
<span className="ml-1 text-xs tabular-nums text-gray-300">
|
||||||
|
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}>
|
||||||
|
{effectiveVolume === 0 ? (
|
||||||
|
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</ControlButton>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.02}
|
||||||
|
value={effectiveVolume}
|
||||||
|
className="h-1 w-20 cursor-pointer accent-white"
|
||||||
|
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
title="Volume (↑/↓)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playback speed */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSpeedMenuOpen((value) => !value);
|
||||||
|
}}
|
||||||
|
title="Playback speed"
|
||||||
|
>
|
||||||
|
{playbackRate}×
|
||||||
|
</button>
|
||||||
|
{speedMenuOpen ? (
|
||||||
|
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
|
||||||
|
{SPEED_OPTIONS.map((rate) => (
|
||||||
|
<button
|
||||||
|
key={rate}
|
||||||
|
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
|
||||||
|
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSpeed(rate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rate}×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loop */}
|
||||||
|
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
|
||||||
|
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
|
||||||
|
</svg>
|
||||||
|
</ControlButton>
|
||||||
|
|
||||||
|
{/* Fullscreen */}
|
||||||
|
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
|
||||||
|
{fullscreen ? (
|
||||||
|
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</ControlButton>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useGalleryStore } from "../../store";
|
||||||
|
import { StepWelcome } from "./StepWelcome";
|
||||||
|
import { StepAddLibrary } from "./StepAddLibrary";
|
||||||
|
import { StepPipeline } from "./StepPipeline";
|
||||||
|
import { StepGalleryPreview } from "./StepGalleryPreview";
|
||||||
|
import { StepSearchDemo } from "./StepSearchDemo";
|
||||||
|
import { StepViews } from "./StepViews";
|
||||||
|
import { StepAiFeatures } from "./StepAiFeatures";
|
||||||
|
import { StepUpdates } from "./StepUpdates";
|
||||||
|
|
||||||
|
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
|
||||||
|
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
|
||||||
|
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
|
||||||
|
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
|
||||||
|
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
|
||||||
|
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
|
||||||
|
{ id: "views", title: "Views", component: () => <StepViews /> },
|
||||||
|
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
|
||||||
|
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OnboardingOverlay() {
|
||||||
|
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
|
||||||
|
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
|
||||||
|
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
|
||||||
|
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onboardingOpen) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") completeOnboarding();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onboardingOpen, completeOnboarding]);
|
||||||
|
|
||||||
|
if (!onboardingOpen) return null;
|
||||||
|
|
||||||
|
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
|
||||||
|
const isFirst = onboardingStep === 0;
|
||||||
|
const isLast = onboardingStep >= STEPS.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
|
||||||
|
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
|
||||||
|
Step {onboardingStep + 1} of {STEPS.length}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
aria-label={s.title}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
i === onboardingStep ? "w-5 bg-white/70" : i < onboardingStep ? "w-1.5 bg-white/35" : "w-1.5 bg-white/15"
|
||||||
|
}`}
|
||||||
|
onClick={() => setOnboardingStep(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step body */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-7 py-6">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={step.id}
|
||||||
|
initial={{ opacity: 0, x: 14 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -14 }}
|
||||||
|
transition={{ duration: 0.16 }}
|
||||||
|
>
|
||||||
|
{step.component()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
|
||||||
|
onClick={completeOnboarding}
|
||||||
|
>
|
||||||
|
Skip tour
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => setOnboardingStep(onboardingStep - 1)}
|
||||||
|
disabled={isFirst}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
||||||
|
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
|
||||||
|
>
|
||||||
|
{isLast ? "Finish" : "Next"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useGalleryStore } from "../../store";
|
||||||
|
import { FakeTile } from "./fakes";
|
||||||
|
|
||||||
|
export function StepAddLibrary() {
|
||||||
|
const folders = useGalleryStore((s) => s.folders);
|
||||||
|
const addFolder = useGalleryStore((s) => s.addFolder);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePick = async () => {
|
||||||
|
setAddError(null);
|
||||||
|
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
|
||||||
|
if (typeof selected !== "string") return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await addFolder(selected);
|
||||||
|
} catch (error) {
|
||||||
|
setAddError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
A library is just a folder on disk — Phokus watches it and keeps itself in sync as files are
|
||||||
|
added, renamed, or removed. Nothing is moved or copied.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{folders.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{folders.length === 1 ? `“${folders[0].name}” added` : `${folders.length} folders in your library`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Indexing runs in the background — you can add more folders from the sidebar any time.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-white">Add your first folder</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Pick somewhere with photos or videos. You can add more later from the sidebar.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{addError ? <p className="mt-1.5 text-xs text-amber-300/90">{addError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => void handlePick()}
|
||||||
|
disabled={adding}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||||
|
As indexing runs, the gallery fills in roughly like this — tiles appear immediately and sharpen
|
||||||
|
as thumbnails are generated:
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid grid-cols-6 gap-1.5">
|
||||||
|
<FakeTile index={0} />
|
||||||
|
<FakeTile index={1} favorite />
|
||||||
|
<FakeTile index={2} duration="0:42" />
|
||||||
|
<FakeTile index={3} rating={4} />
|
||||||
|
<FakeTile index={4} loaded={false} />
|
||||||
|
<FakeTile index={5} loaded={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { TaggerModelProgress, useGalleryStore } from "../../store";
|
||||||
|
import { FakeProgressBar, formatBytes } from "./fakes";
|
||||||
|
|
||||||
|
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
|
||||||
|
|
||||||
|
// Prefer the current file's byte fraction (the 1.3 GB model dominates); fall
|
||||||
|
// back to the coarse step count; null renders an indeterminate bar.
|
||||||
|
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
|
||||||
|
if (!progress) return null;
|
||||||
|
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) {
|
||||||
|
return progress.downloaded_bytes / progress.total_bytes;
|
||||||
|
}
|
||||||
|
if (progress.total_files > 0) return progress.completed_files / progress.total_files;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepAiFeatures() {
|
||||||
|
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
|
||||||
|
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
|
||||||
|
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
|
||||||
|
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
|
||||||
|
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
|
||||||
|
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTaggerModelStatus();
|
||||||
|
}, [loadTaggerModelStatus]);
|
||||||
|
|
||||||
|
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
Phokus's AI runs entirely on this machine — nothing is sent anywhere. Semantic search sets
|
||||||
|
itself up automatically; AI tagging is optional and only downloads if you want it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging — optional</h4>
|
||||||
|
<div className="mt-1 divide-y divide-white/[0.05]">
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-white">Automatic tags for every image</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||||
|
The WD tagger model (~1.3 GB download) labels images so you can search with{" "}
|
||||||
|
<code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> — tags look like:
|
||||||
|
</p>
|
||||||
|
<span className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{FAKE_TAGS.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{taggerReady ? (
|
||||||
|
<span className="inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => void prepareTaggerModel()}
|
||||||
|
disabled={taggerModelPreparing}
|
||||||
|
>
|
||||||
|
{taggerModelPreparing ? "Downloading..." : "Download now"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{taggerModelPreparing ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
|
||||||
|
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600">
|
||||||
|
<span className="truncate">{taggerModelProgress?.current_file ?? "Preparing..."}</span>
|
||||||
|
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? (
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!taggerModelPreparing && taggerModelError ? (
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-amber-300/90">
|
||||||
|
Download failed: {taggerModelError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity — built in</h4>
|
||||||
|
<div className="mt-1 divide-y divide-white/[0.05]">
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||||
|
Powers <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> search,
|
||||||
|
"find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model
|
||||||
|
(~580 MB) downloads automatically the first time embeddings run. Nothing to do — you'll see it
|
||||||
|
in the background-tasks bar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||||
|
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional and
|
||||||
|
can be downloaded any time from Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FakeTile, ReplayButton } from "./fakes";
|
||||||
|
|
||||||
|
const REVEAL_MS = 280;
|
||||||
|
|
||||||
|
// Two rows (cols-4) keeps the explainer text visible without scrolling.
|
||||||
|
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
|
||||||
|
{},
|
||||||
|
{ favorite: true },
|
||||||
|
{},
|
||||||
|
{ duration: "1:24" },
|
||||||
|
{ rating: 5 },
|
||||||
|
{ favorite: true, rating: 3 },
|
||||||
|
{ duration: "0:09" },
|
||||||
|
{},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TILE_COUNT = TILE_PROPS.length;
|
||||||
|
|
||||||
|
/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully
|
||||||
|
/// revealed with a replay control.
|
||||||
|
export function StepGalleryPreview() {
|
||||||
|
const [revealed, setRevealed] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (revealed >= TILE_COUNT) return;
|
||||||
|
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [revealed]);
|
||||||
|
|
||||||
|
const finished = revealed >= TILE_COUNT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
The gallery is a virtualized grid — it stays fast with hundreds of thousands of items. Tiles
|
||||||
|
carry your favorites, star ratings, and video durations; hover for filename and quick actions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-4 gap-1.5">
|
||||||
|
{TILE_PROPS.map((props, i) => (
|
||||||
|
<FakeTile key={i} index={i} loaded={i < revealed} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-start justify-between gap-4">
|
||||||
|
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
|
||||||
|
<p className="pb-2.5">
|
||||||
|
<span className="text-gray-300">Click any tile</span> to open the lightbox — keyboard navigation,
|
||||||
|
zoom, inline tag editing, ratings, and a full video player.
|
||||||
|
</p>
|
||||||
|
<p className="pt-2.5">
|
||||||
|
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
|
||||||
|
date/name/size, and switches grid density.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes";
|
||||||
|
|
||||||
|
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const;
|
||||||
|
const STAGE_TOTAL = 128;
|
||||||
|
const TICK_MS = 80;
|
||||||
|
const STEP_PER_TICK = 8;
|
||||||
|
|
||||||
|
/// A one-shot fake of the background-tasks bar: each stage drains in order,
|
||||||
|
/// then it stops at "all done" with a replay control.
|
||||||
|
export function StepPipeline() {
|
||||||
|
const [stageIndex, setStageIndex] = useState(0);
|
||||||
|
const [filled, setFilled] = useState(0);
|
||||||
|
|
||||||
|
const finished = stageIndex >= STAGES.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Read from the closure and call setters directly — nesting one setter
|
||||||
|
// inside another's updater double-advances under React StrictMode.
|
||||||
|
if (filled + STEP_PER_TICK < STAGE_TOTAL) {
|
||||||
|
setFilled(filled + STEP_PER_TICK);
|
||||||
|
} else {
|
||||||
|
setStageIndex(stageIndex + 1);
|
||||||
|
setFilled(0);
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [filled, stageIndex, finished]);
|
||||||
|
|
||||||
|
const replay = () => {
|
||||||
|
setStageIndex(0);
|
||||||
|
setFilled(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remaining = STAGE_TOTAL - filled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata,
|
||||||
|
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time,
|
||||||
|
so each runs at full speed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-5 text-xs text-gray-500">
|
||||||
|
While it's working you'll see this bar above the gallery — here's a preview:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fake BackgroundTasks slim bar */}
|
||||||
|
<div className="mt-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||||
|
{!finished ? (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
|
||||||
|
) : null}
|
||||||
|
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-medium text-white/60">Holiday Photos</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{STAGES.map((stage, i) => (
|
||||||
|
<FakeStageTag
|
||||||
|
key={stage}
|
||||||
|
label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage}
|
||||||
|
state={finished || i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FakeProgressBar fraction={finished ? 1 : filled / STAGE_TOTAL} className="ml-auto w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-start justify-between gap-4">
|
||||||
|
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
|
||||||
|
<p className="pb-2.5">
|
||||||
|
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like —
|
||||||
|
the queue picks up where it left off next launch.
|
||||||
|
</p>
|
||||||
|
<p className="pt-2.5">
|
||||||
|
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
|
||||||
|
pause its background work, and click the bar itself to expand a detailed per-folder view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{finished ? <ReplayButton onClick={replay} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ReplayButton, SEARCH_RESULTS } from "./fakes";
|
||||||
|
|
||||||
|
const DEMOS = [
|
||||||
|
{
|
||||||
|
query: "beach-day_042.jpg",
|
||||||
|
mode: "Filename",
|
||||||
|
description: "Plain text matches file names — the default, instant search. One name, one file.",
|
||||||
|
results: SEARCH_RESULTS.filename,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "/s golden sunset over water",
|
||||||
|
mode: "Semantic",
|
||||||
|
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
|
||||||
|
results: SEARCH_RESULTS.semantic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: "/t landscape",
|
||||||
|
mode: "Tags",
|
||||||
|
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.",
|
||||||
|
results: SEARCH_RESULTS.tags,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TYPE_MS = 55;
|
||||||
|
const HOLD_MS = 2600;
|
||||||
|
|
||||||
|
/// Types each demo query, shows matching results, advances through all three
|
||||||
|
/// modes once, then stops on the last with a replay control.
|
||||||
|
export function StepSearchDemo() {
|
||||||
|
const [demoIndex, setDemoIndex] = useState(0);
|
||||||
|
const [typed, setTyped] = useState(0);
|
||||||
|
const [finished, setFinished] = useState(false);
|
||||||
|
|
||||||
|
const demo = DEMOS[demoIndex];
|
||||||
|
const isLastDemo = demoIndex === DEMOS.length - 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished) return;
|
||||||
|
if (typed < demo.query.length) {
|
||||||
|
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
// Fully typed: hold, then advance — or finish on the last demo.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (isLastDemo) {
|
||||||
|
setFinished(true);
|
||||||
|
} else {
|
||||||
|
setDemoIndex((i) => i + 1);
|
||||||
|
setTyped(0);
|
||||||
|
}
|
||||||
|
}, HOLD_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [typed, demo.query.length, isLastDemo, finished]);
|
||||||
|
|
||||||
|
const replay = () => {
|
||||||
|
setDemoIndex(0);
|
||||||
|
setTyped(0);
|
||||||
|
setFinished(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullyTyped = typed >= demo.query.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
One search bar, three modes — picked by prefix. No prefix searches filenames, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> searches
|
||||||
|
by meaning, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> searches tags.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mock search bar */}
|
||||||
|
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-white/[0.04] px-3.5 py-2.5">
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-white">
|
||||||
|
{demo.query.slice(0, typed)}
|
||||||
|
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300">
|
||||||
|
{demo.mode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results — hidden (not greyed) until the query finishes typing, so it
|
||||||
|
doesn't look like the app is reading the user's mind. Space is held
|
||||||
|
by the invisible tiles so the layout doesn't jump when they appear.
|
||||||
|
Keyed by demoIndex so switching demos remounts the row fresh at
|
||||||
|
opacity-0 instead of fading the new images out from the previous
|
||||||
|
demo's visible state. */}
|
||||||
|
<div
|
||||||
|
key={demoIndex}
|
||||||
|
className={`mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`}
|
||||||
|
>
|
||||||
|
{demo.results.map((src, i) => (
|
||||||
|
<div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]">
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex min-h-10 items-start justify-between gap-4">
|
||||||
|
<p className="text-xs leading-relaxed text-gray-500">{demo.description}</p>
|
||||||
|
{finished ? <ReplayButton onClick={replay} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { PhokusMark } from "../PhokusMark";
|
||||||
|
|
||||||
|
// Closing step. Introduces the app mark (the aperture in the title bar) and,
|
||||||
|
// since that same mark doubles as the update indicator, explains how updates
|
||||||
|
// work in one place. The mini app-window mockup shows the lit mark exactly
|
||||||
|
// where it appears, so there's nothing abstract to decode.
|
||||||
|
export function StepUpdates() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
Phokus keeps itself up to date — it quietly checks for new versions on startup, so you don't
|
||||||
|
have to go looking for one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
|
||||||
|
When an update is ready
|
||||||
|
</h4>
|
||||||
|
<div className="mt-1 py-4">
|
||||||
|
<p className="text-sm text-white">The mark in the title bar lights up</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||||
|
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point
|
||||||
|
glows amber — click it to update and relaunch. Nothing installs on its own; you're always in
|
||||||
|
control.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mini app-window mockup: the lit mark shown in a real title bar. */}
|
||||||
|
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
|
||||||
|
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-white/[0.08] text-gray-300">
|
||||||
|
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/30 blur-[3px]" />
|
||||||
|
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/55 animate-ping" />
|
||||||
|
<PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-3.5 text-gray-600">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
|
||||||
|
<rect y="4.5" width="10" height="1" rx="0.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
|
||||||
|
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ghosted window body, just enough to read as the app. */}
|
||||||
|
<div className="flex h-[72px] bg-gray-900/30">
|
||||||
|
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5">
|
||||||
|
<div className="h-1.5 w-full rounded bg-white/[0.07]" />
|
||||||
|
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
|
||||||
|
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-2.5">
|
||||||
|
<div className="h-2 w-20 rounded bg-white/[0.06]" />
|
||||||
|
<div className="mt-2.5 grid grid-cols-5 gap-1.5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-7 rounded bg-white/[0.04]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
|
||||||
|
Prefer to manage it yourself?
|
||||||
|
</h4>
|
||||||
|
<div className="mt-1 py-4">
|
||||||
|
<p className="text-sm text-white">Check any time from Settings</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||||
|
Settings → General shows your current version with a manual{" "}
|
||||||
|
<span className="text-gray-300">Check for updates</span> button, so you can update on your own
|
||||||
|
schedule.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||||
|
That's the tour. Add folders from the sidebar, and revisit any of this from Settings — including
|
||||||
|
re-running this tour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { FakeTile, tileGradient } from "./fakes";
|
||||||
|
|
||||||
|
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-white">{title}</p>
|
||||||
|
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{preview}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExplorePreview() {
|
||||||
|
// Cluster blobs of varying size, like the tag cloud / cluster map.
|
||||||
|
const blobs = [
|
||||||
|
{ size: 34, x: 4, y: 10, i: 0 },
|
||||||
|
{ size: 24, x: 46, y: 0, i: 2 },
|
||||||
|
{ size: 18, x: 86, y: 22, i: 4 },
|
||||||
|
{ size: 26, x: 30, y: 36, i: 1 },
|
||||||
|
{ size: 16, x: 70, y: 44, i: 6 },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
|
||||||
|
{blobs.map((blob, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`absolute rounded-full bg-gradient-to-br opacity-80 ${tileGradient(blob.i)}`}
|
||||||
|
style={{ width: blob.size, height: blob.size, left: blob.x, top: blob.y }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelinePreview() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3">
|
||||||
|
{[2024, 2023].map((year, row) => (
|
||||||
|
<div key={year} className="flex items-center gap-1.5">
|
||||||
|
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DuplicatesPreview() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[72px] w-32 items-center justify-center gap-1.5 rounded-lg border border-white/[0.07] bg-white/[0.02]">
|
||||||
|
<div className="w-10">
|
||||||
|
<FakeTile index={3} className="rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="relative w-10">
|
||||||
|
<FakeTile index={3} className="rounded-md" />
|
||||||
|
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepViews() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
Beyond the main grid, the sidebar switches between three more ways to look at your library:
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 divide-y divide-white/[0.05]">
|
||||||
|
<ViewRow
|
||||||
|
title="Explore"
|
||||||
|
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
|
||||||
|
preview={<ExplorePreview />}
|
||||||
|
/>
|
||||||
|
<ViewRow
|
||||||
|
title="Timeline"
|
||||||
|
description="Everything grouped chronologically by capture date (EXIF), from years down to days."
|
||||||
|
preview={<TimelinePreview />}
|
||||||
|
/>
|
||||||
|
<ViewRow
|
||||||
|
title="Duplicates"
|
||||||
|
description="A three-phase exact-duplicate scan with bulk delete — reclaim space from copies safely."
|
||||||
|
preview={<DuplicatesPreview />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-gray-500">
|
||||||
|
Each view can be scoped to a single folder from its header — no need to bounce through the sidebar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useGalleryStore } from "../../store";
|
||||||
|
import { FakeProgressBar, formatBytes } from "./fakes";
|
||||||
|
|
||||||
|
export function FfmpegStatusRow() {
|
||||||
|
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
|
||||||
|
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
|
||||||
|
const ffmpegError = useGalleryStore((s) => s.ffmpegError);
|
||||||
|
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload);
|
||||||
|
|
||||||
|
if (ffmpegStatus === "installed") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 py-3">
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white">Media engine ready</p>
|
||||||
|
<p className="text-xs text-gray-500">FFmpeg is installed — video thumbnails and metadata are available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpegStatus === "error") {
|
||||||
|
return (
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-white">Media engine download failed</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-amber-300/90">{ffmpegError}</p>
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-gray-500">
|
||||||
|
You can keep going — photos work without it. Video thumbnails and metadata will start
|
||||||
|
automatically once the download succeeds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={() => void retryFfmpegDownload()}
|
||||||
|
>
|
||||||
|
Retry download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fraction =
|
||||||
|
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
|
||||||
|
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-6">
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."}
|
||||||
|
</p>
|
||||||
|
{ffmpegProgress ? (
|
||||||
|
<span className="text-[11px] tabular-nums text-gray-500">
|
||||||
|
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" />
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-gray-500">
|
||||||
|
FFmpeg powers video thumbnails and metadata. It downloads once in the background — you can keep
|
||||||
|
using the tour and the app while it finishes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepWelcome() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">
|
||||||
|
Phokus is a local-first media library: point it at your folders and it builds a fast, searchable
|
||||||
|
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine —
|
||||||
|
your photos and videos never leave it.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2.5 text-sm leading-relaxed text-gray-500">
|
||||||
|
This short tour shows what to expect. Every step is skippable, and you can re-run it any time
|
||||||
|
from Settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4>
|
||||||
|
<div className="mt-1 divide-y divide-white/[0.05]">
|
||||||
|
<FfmpegStatusRow />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Shared placeholder primitives for the onboarding tour. Everything here is
|
||||||
|
// deliberately fake — rights-clean demo stills (generated, owned), looping
|
||||||
|
// demo progress — so new users see the real UI's shapes before their own
|
||||||
|
// library exists.
|
||||||
|
import sunset from "../../assets/onboarding/sunset.webp";
|
||||||
|
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp";
|
||||||
|
import sunsetlake from "../../assets/onboarding/sunsetlake.webp";
|
||||||
|
import beach from "../../assets/onboarding/beach.webp";
|
||||||
|
import landscape1 from "../../assets/onboarding/landscape1.webp";
|
||||||
|
import landscape2 from "../../assets/onboarding/landscape2.webp";
|
||||||
|
import forest from "../../assets/onboarding/forest.webp";
|
||||||
|
import citynight from "../../assets/onboarding/citynight.webp";
|
||||||
|
import flower from "../../assets/onboarding/flower.webp";
|
||||||
|
import alpinelake from "../../assets/onboarding/alpinelake.webp";
|
||||||
|
import dunes from "../../assets/onboarding/dunes.webp";
|
||||||
|
import cozy from "../../assets/onboarding/cozy.webp";
|
||||||
|
import cat from "../../assets/onboarding/cat.webp";
|
||||||
|
import balloon from "../../assets/onboarding/balloon.webp";
|
||||||
|
import architecture from "../../assets/onboarding/architecture.webp";
|
||||||
|
|
||||||
|
// Ordered for grid variety: adjacent indices are visually distinct.
|
||||||
|
const FAKE_IMAGES = [
|
||||||
|
sunset,
|
||||||
|
cozy,
|
||||||
|
landscape1,
|
||||||
|
citynight,
|
||||||
|
flower,
|
||||||
|
dunes,
|
||||||
|
alpinelake,
|
||||||
|
cat,
|
||||||
|
forest,
|
||||||
|
balloon,
|
||||||
|
landscape2,
|
||||||
|
beach,
|
||||||
|
architecture,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fakeImage(index: number): string {
|
||||||
|
return FAKE_IMAGES[index % FAKE_IMAGES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result sets matched to what each query would genuinely return.
|
||||||
|
// - filename: one exact file (a filename search hits a specific name)
|
||||||
|
// - semantic "golden sunset over water": only the ocean-sunset stills
|
||||||
|
// - tags "landscape": mountain valleys + the alpine lake (all landscapes)
|
||||||
|
export const SEARCH_RESULTS = {
|
||||||
|
filename: [beach],
|
||||||
|
semantic: [sunsetcoast, sunset, sunsetlake],
|
||||||
|
tags: [landscape1, landscape2, alpinelake],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
|
||||||
|
// where small non-photographic shapes read more clearly than tiny stills.
|
||||||
|
const TILE_GRADIENTS = [
|
||||||
|
"from-sky-900/70 via-slate-800 to-slate-900",
|
||||||
|
"from-amber-900/60 via-stone-800 to-stone-900",
|
||||||
|
"from-emerald-900/60 via-slate-800 to-gray-900",
|
||||||
|
"from-rose-900/50 via-slate-800 to-slate-900",
|
||||||
|
"from-indigo-900/60 via-slate-800 to-gray-900",
|
||||||
|
"from-cyan-900/60 via-slate-800 to-slate-900",
|
||||||
|
"from-fuchsia-900/40 via-slate-800 to-gray-900",
|
||||||
|
"from-orange-900/50 via-stone-800 to-stone-900",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function tileGradient(index: number): string {
|
||||||
|
return TILE_GRADIENTS[index % TILE_GRADIENTS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FakeTile({
|
||||||
|
index,
|
||||||
|
loaded = true,
|
||||||
|
favorite = false,
|
||||||
|
rating = 0,
|
||||||
|
duration,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
loaded?: boolean;
|
||||||
|
favorite?: boolean;
|
||||||
|
rating?: number;
|
||||||
|
duration?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
|
||||||
|
{loaded ? (
|
||||||
|
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
|
||||||
|
)}
|
||||||
|
{loaded && favorite ? (
|
||||||
|
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{loaded && rating > 0 ? (
|
||||||
|
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300">
|
||||||
|
{Array.from({ length: rating }).map((_, i) => (
|
||||||
|
<svg key={i} className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.563.563 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{loaded && duration ? (
|
||||||
|
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
|
||||||
|
{duration}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
|
||||||
|
const className =
|
||||||
|
state === "active"
|
||||||
|
? "bg-white/5 text-gray-300"
|
||||||
|
: state === "done"
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "bg-white/4 text-gray-600";
|
||||||
|
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`h-px overflow-hidden rounded-full bg-white/8 ${className}`}>
|
||||||
|
{fraction === null ? (
|
||||||
|
<div className="h-full w-full animate-pulse bg-blue-500/40" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.round(Math.min(fraction, 1) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3" />
|
||||||
|
</svg>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
@@ -2,8 +2,16 @@ import { create } from "zustand";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { appDataDir, join } from "@tauri-apps/api/path";
|
import { appDataDir, join } from "@tauri-apps/api/path";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { check, Update } from "@tauri-apps/plugin-updater";
|
||||||
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
import { notifyTaskComplete } from "./notifications";
|
import { notifyTaskComplete } from "./notifications";
|
||||||
|
|
||||||
|
// Per-folder debounce timers for batching notifications.
|
||||||
|
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
|
||||||
|
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -37,6 +45,7 @@ export interface ImageRecord {
|
|||||||
file_size: number;
|
file_size: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
modified_at: string | null;
|
modified_at: string | null;
|
||||||
|
taken_at: string | null;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
media_kind: MediaKind;
|
media_kind: MediaKind;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
@@ -70,6 +79,27 @@ export interface ImageTag {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DatabaseInfo {
|
||||||
|
size_mb: number;
|
||||||
|
reclaimable_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VacuumResult {
|
||||||
|
before_mb: number;
|
||||||
|
after_mb: number;
|
||||||
|
freed_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrphanedThumbnailsInfo {
|
||||||
|
count: number;
|
||||||
|
size_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanupOrphanedThumbnailsResult {
|
||||||
|
deleted_count: number;
|
||||||
|
freed_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaggerModelStatus {
|
export interface TaggerModelStatus {
|
||||||
model_id: string;
|
model_id: string;
|
||||||
model_name: string;
|
model_name: string;
|
||||||
@@ -82,6 +112,8 @@ export interface TaggerModelProgress {
|
|||||||
total_files: number;
|
total_files: number;
|
||||||
completed_files: number;
|
completed_files: number;
|
||||||
current_file: string | null;
|
current_file: string | null;
|
||||||
|
downloaded_bytes: number | null;
|
||||||
|
total_bytes: number | null;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +153,7 @@ export interface ThumbnailBatch {
|
|||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActiveView = "gallery" | "explore" | "duplicates";
|
export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline";
|
||||||
|
|
||||||
export interface TagCloudEntry {
|
export interface TagCloudEntry {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -143,6 +175,20 @@ export interface DuplicateGroup {
|
|||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DuplicateScanProgress {
|
||||||
|
phase: "checking" | "hashing" | "confirming";
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DuplicateScanResult {
|
||||||
|
groups: DuplicateGroup[];
|
||||||
|
scanned_files: number;
|
||||||
|
candidate_files: number;
|
||||||
|
skipped_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SimilarImagesPage {
|
export interface SimilarImagesPage {
|
||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -214,7 +260,36 @@ export type SortOrder =
|
|||||||
| "rating_desc"
|
| "rating_desc"
|
||||||
| "rating_asc"
|
| "rating_asc"
|
||||||
| "duration_desc"
|
| "duration_desc"
|
||||||
| "duration_asc";
|
| "duration_asc"
|
||||||
|
| "taken_desc"
|
||||||
|
| "taken_asc";
|
||||||
|
|
||||||
|
export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error";
|
||||||
|
|
||||||
|
export type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
|
||||||
|
|
||||||
|
const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"];
|
||||||
|
|
||||||
|
interface FolderWorkerStates {
|
||||||
|
folder_id: number;
|
||||||
|
thumbnail_paused: boolean;
|
||||||
|
metadata_paused: boolean;
|
||||||
|
embedding_paused: boolean;
|
||||||
|
tagging_paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error";
|
||||||
|
|
||||||
|
interface FfmpegProgressEvent {
|
||||||
|
phase: string;
|
||||||
|
downloaded_bytes: number | null;
|
||||||
|
total_bytes: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Update handle from the plugin carries the download method; it's not
|
||||||
|
// serializable state, so it lives outside the store.
|
||||||
|
let pendingUpdate: Update | null = null;
|
||||||
|
|
||||||
interface GalleryState {
|
interface GalleryState {
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
@@ -264,6 +339,25 @@ interface GalleryState {
|
|||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
taggingQueueScope: TaggingQueueScope;
|
taggingQueueScope: TaggingQueueScope;
|
||||||
taggingQueueFolderIds: number[];
|
taggingQueueFolderIds: number[];
|
||||||
|
mutedFolderIds: number[];
|
||||||
|
notificationsPaused: boolean;
|
||||||
|
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
||||||
|
// bar and the sidebar folder context menu.
|
||||||
|
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
||||||
|
|
||||||
|
appVersion: string | null;
|
||||||
|
updateStatus: UpdateStatus;
|
||||||
|
updateVersion: string | null;
|
||||||
|
updateProgress: number | null; // 0..1 download progress, null while size unknown
|
||||||
|
updateError: string | null;
|
||||||
|
updateDismissed: boolean;
|
||||||
|
|
||||||
|
ffmpegStatus: FfmpegStatus;
|
||||||
|
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
|
||||||
|
ffmpegError: string | null;
|
||||||
|
onboardingCompleted: boolean | null; // null = not loaded yet
|
||||||
|
onboardingOpen: boolean;
|
||||||
|
onboardingStep: number;
|
||||||
|
|
||||||
taggerModelStatus: TaggerModelStatus | null;
|
taggerModelStatus: TaggerModelStatus | null;
|
||||||
taggerModelPreparing: boolean;
|
taggerModelPreparing: boolean;
|
||||||
@@ -277,8 +371,9 @@ interface GalleryState {
|
|||||||
|
|
||||||
duplicateGroups: DuplicateGroup[];
|
duplicateGroups: DuplicateGroup[];
|
||||||
duplicateScanning: boolean;
|
duplicateScanning: boolean;
|
||||||
duplicateScanProgress: { scanned: number; total: number } | null;
|
duplicateScanProgress: DuplicateScanProgress | null;
|
||||||
duplicateScanError: string | null;
|
duplicateScanError: string | null;
|
||||||
|
duplicateScanWarning: string | null;
|
||||||
duplicateSelectedIds: Set<number>;
|
duplicateSelectedIds: Set<number>;
|
||||||
duplicateLastScanned: number | null; // Unix timestamp (seconds)
|
duplicateLastScanned: number | null; // Unix timestamp (seconds)
|
||||||
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
|
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
|
||||||
@@ -291,6 +386,7 @@ interface GalleryState {
|
|||||||
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||||
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||||
selectFolder: (folderId: number | null) => void;
|
selectFolder: (folderId: number | null) => void;
|
||||||
|
setViewFolderScope: (folderId: number | null) => void;
|
||||||
loadImages: (reset?: boolean) => Promise<void>;
|
loadImages: (reset?: boolean) => Promise<void>;
|
||||||
loadMoreImages: () => Promise<void>;
|
loadMoreImages: () => Promise<void>;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
@@ -331,9 +427,33 @@ interface GalleryState {
|
|||||||
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
||||||
setAiCaptionsEnabled: (enabled: boolean) => void;
|
setAiCaptionsEnabled: (enabled: boolean) => void;
|
||||||
setSettingsOpen: (open: boolean) => void;
|
setSettingsOpen: (open: boolean) => void;
|
||||||
|
loadTaggingQueueScope: () => Promise<void>;
|
||||||
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
||||||
|
loadTaggingQueueFolderIds: () => Promise<void>;
|
||||||
toggleTaggingQueueFolder: (folderId: number) => void;
|
toggleTaggingQueueFolder: (folderId: number) => void;
|
||||||
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
||||||
|
loadMutedFolderIds: () => Promise<void>;
|
||||||
|
toggleMutedFolder: (folderId: number) => void;
|
||||||
|
loadNotificationsPaused: () => Promise<void>;
|
||||||
|
setNotificationsPaused: (paused: boolean) => void;
|
||||||
|
loadWorkerStates: () => Promise<void>;
|
||||||
|
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
||||||
|
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
||||||
|
loadAppVersion: () => Promise<void>;
|
||||||
|
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
|
||||||
|
installUpdate: () => Promise<void>;
|
||||||
|
dismissUpdate: () => void;
|
||||||
|
loadFfmpegStatus: () => Promise<void>;
|
||||||
|
retryFfmpegDownload: () => Promise<void>;
|
||||||
|
loadOnboardingCompleted: () => Promise<void>;
|
||||||
|
completeOnboarding: () => void;
|
||||||
|
openOnboarding: () => void;
|
||||||
|
setOnboardingStep: (step: number) => void;
|
||||||
|
openAppDataFolder: () => Promise<void>;
|
||||||
|
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
||||||
|
vacuumDatabase: () => Promise<VacuumResult>;
|
||||||
|
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
||||||
|
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
||||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||||
setCacheDir: (dir: string) => void;
|
setCacheDir: (dir: string) => void;
|
||||||
@@ -494,6 +614,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
|
|||||||
return compareNullableNumber(a.duration_ms, b.duration_ms);
|
return compareNullableNumber(a.duration_ms, b.duration_ms);
|
||||||
case "duration_desc":
|
case "duration_desc":
|
||||||
return compareNullableNumber(b.duration_ms, a.duration_ms);
|
return compareNullableNumber(b.duration_ms, a.duration_ms);
|
||||||
|
case "taken_asc":
|
||||||
|
return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at);
|
||||||
|
case "taken_desc":
|
||||||
|
return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at);
|
||||||
default:
|
default:
|
||||||
return compareNullableDate(b.modified_at, a.modified_at);
|
return compareNullableDate(b.modified_at, a.modified_at);
|
||||||
}
|
}
|
||||||
@@ -600,6 +724,23 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
taggingQueueScope: "all",
|
taggingQueueScope: "all",
|
||||||
taggingQueueFolderIds: [],
|
taggingQueueFolderIds: [],
|
||||||
|
mutedFolderIds: [],
|
||||||
|
notificationsPaused: false,
|
||||||
|
workerPaused: {},
|
||||||
|
|
||||||
|
appVersion: null,
|
||||||
|
updateStatus: "idle",
|
||||||
|
updateVersion: null,
|
||||||
|
updateProgress: null,
|
||||||
|
updateError: null,
|
||||||
|
updateDismissed: false,
|
||||||
|
|
||||||
|
ffmpegStatus: "unknown",
|
||||||
|
ffmpegProgress: null,
|
||||||
|
ffmpegError: null,
|
||||||
|
onboardingCompleted: null,
|
||||||
|
onboardingOpen: false,
|
||||||
|
onboardingStep: 0,
|
||||||
|
|
||||||
taggerModelStatus: null,
|
taggerModelStatus: null,
|
||||||
taggerModelPreparing: false,
|
taggerModelPreparing: false,
|
||||||
@@ -615,6 +756,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
duplicateScanning: false,
|
duplicateScanning: false,
|
||||||
duplicateScanProgress: null,
|
duplicateScanProgress: null,
|
||||||
duplicateScanError: null,
|
duplicateScanError: null,
|
||||||
|
duplicateScanWarning: null,
|
||||||
duplicateSelectedIds: new Set(),
|
duplicateSelectedIds: new Set(),
|
||||||
duplicateLastScanned: null,
|
duplicateLastScanned: null,
|
||||||
duplicateScanFolderId: undefined,
|
duplicateScanFolderId: undefined,
|
||||||
@@ -653,16 +795,28 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeFolder: async (folderId) => {
|
removeFolder: async (folderId) => {
|
||||||
await invoke("remove_folder", { folderId });
|
|
||||||
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
||||||
|
// Optimistically drop it from the sidebar for instant feedback (the backend
|
||||||
|
// delete of its images/thumbnails can take a moment), clearing the active
|
||||||
|
// selection if it was this folder.
|
||||||
|
set((state) => {
|
||||||
|
const folders = state.folders.filter((folder) => folder.id !== folderId);
|
||||||
|
return selectedFolderId === folderId ? { folders, selectedFolderId: null } : { folders };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await invoke("remove_folder", { folderId });
|
||||||
|
} catch (error) {
|
||||||
|
// Removal failed — resync the authoritative list and surface the error.
|
||||||
|
await loadFolders();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await loadFolders();
|
await loadFolders();
|
||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
// Invalidate tag cloud and explore-tags cache since library content changed
|
// Invalidate tag cloud and explore-tags cache since library content changed.
|
||||||
set({ tagCloudFolderId: undefined, tagCloudEntries: [], exploreTagsFolderId: undefined });
|
set({ tagCloudFolderId: undefined, tagCloudEntries: [], exploreTagsFolderId: undefined });
|
||||||
if (selectedFolderId === folderId) {
|
// Always refresh the gallery: the removed folder's images may be on screen
|
||||||
set({ selectedFolderId: null });
|
// (e.g. in All Media), not only when that folder was the active selection.
|
||||||
await loadImages(true);
|
await loadImages(true);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reindexFolder: async (folderId) => {
|
reindexFolder: async (folderId) => {
|
||||||
@@ -691,6 +845,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
void get().loadImages(true);
|
void get().loadImages(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Change folder scope from inside a feature view (Timeline/Explore/Duplicates)
|
||||||
|
// without leaving it — unlike selectFolder, activeView is preserved.
|
||||||
|
setViewFolderScope: (folderId) => {
|
||||||
|
const { activeView, selectedFolderId } = get();
|
||||||
|
if (folderId === selectedFolderId) return;
|
||||||
|
|
||||||
|
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||||
|
|
||||||
|
if (activeView === "duplicates") {
|
||||||
|
const { duplicateScanFolderId } = get();
|
||||||
|
if (duplicateScanFolderId !== folderId) {
|
||||||
|
set({
|
||||||
|
duplicateGroups: [],
|
||||||
|
duplicateLastScanned: null,
|
||||||
|
duplicateScanFolderId: undefined,
|
||||||
|
duplicateScanWarning: null,
|
||||||
|
});
|
||||||
|
void get().loadDuplicateScanCache(folderId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore reloads itself via TagCloud's useEffect on selectedFolderId.
|
||||||
|
if (activeView === "explore") return;
|
||||||
|
|
||||||
|
void get().loadImages(true);
|
||||||
|
},
|
||||||
|
|
||||||
loadImages: async (reset = false) => {
|
loadImages: async (reset = false) => {
|
||||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
||||||
const parsedSearch = parseSearchValue(search);
|
const parsedSearch = parseSearchValue(search);
|
||||||
@@ -898,10 +1080,21 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
closeImage: () => set({ selectedImage: null }),
|
closeImage: () => set({ selectedImage: null }),
|
||||||
|
|
||||||
setView: (activeView) => {
|
setView: (activeView) => {
|
||||||
|
if (activeView === "timeline") {
|
||||||
|
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
|
||||||
|
void get().loadImages(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeView === "duplicates") {
|
if (activeView === "duplicates") {
|
||||||
const { selectedFolderId, duplicateScanFolderId } = get();
|
const { selectedFolderId, duplicateScanFolderId } = get();
|
||||||
if (duplicateScanFolderId !== selectedFolderId) {
|
if (duplicateScanFolderId !== selectedFolderId) {
|
||||||
set({ activeView, duplicateGroups: [], duplicateLastScanned: null, duplicateScanFolderId: undefined });
|
set({
|
||||||
|
activeView,
|
||||||
|
duplicateGroups: [],
|
||||||
|
duplicateLastScanned: null,
|
||||||
|
duplicateScanFolderId: undefined,
|
||||||
|
duplicateScanWarning: null,
|
||||||
|
});
|
||||||
void get().loadDuplicateScanCache(selectedFolderId);
|
void get().loadDuplicateScanCache(selectedFolderId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1287,6 +1480,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
||||||
|
|
||||||
|
loadTaggingQueueScope: async () => {
|
||||||
|
try {
|
||||||
|
const scope = await invoke<TaggingQueueScope>("get_tagging_queue_scope");
|
||||||
|
set({ taggingQueueScope: scope });
|
||||||
|
} catch {
|
||||||
|
// silently fall back to in-memory default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setTaggingQueueScope: (taggingQueueScope) => {
|
setTaggingQueueScope: (taggingQueueScope) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
taggingQueueScope,
|
taggingQueueScope,
|
||||||
@@ -1295,6 +1497,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
? [state.folders[0].id]
|
? [state.folders[0].id]
|
||||||
: state.taggingQueueFolderIds,
|
: state.taggingQueueFolderIds,
|
||||||
}));
|
}));
|
||||||
|
void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTaggingQueueFolderIds: async () => {
|
||||||
|
try {
|
||||||
|
const folderIds = await invoke<number[]>("get_tagging_queue_folder_ids");
|
||||||
|
set({ taggingQueueFolderIds: folderIds });
|
||||||
|
} catch {
|
||||||
|
// silently fall back to in-memory default
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTaggingQueueFolder: (folderId) => {
|
toggleTaggingQueueFolder: (folderId) => {
|
||||||
@@ -1302,11 +1514,237 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const next = state.taggingQueueFolderIds.includes(folderId)
|
const next = state.taggingQueueFolderIds.includes(folderId)
|
||||||
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
|
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
|
||||||
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
|
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
|
||||||
|
void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {});
|
||||||
return { taggingQueueFolderIds: next };
|
return { taggingQueueFolderIds: next };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }),
|
setTaggingQueueFolderIds: (taggingQueueFolderIds) => {
|
||||||
|
set({ taggingQueueFolderIds });
|
||||||
|
void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMutedFolderIds: async () => {
|
||||||
|
try {
|
||||||
|
const folderIds = await invoke<number[]>("get_muted_folder_ids");
|
||||||
|
set({ mutedFolderIds: folderIds });
|
||||||
|
} catch {
|
||||||
|
// fall back to in-memory default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMutedFolder: (folderId) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.mutedFolderIds.includes(folderId)
|
||||||
|
? state.mutedFolderIds.filter((id) => id !== folderId)
|
||||||
|
: [...state.mutedFolderIds, folderId];
|
||||||
|
void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {});
|
||||||
|
return { mutedFolderIds: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadNotificationsPaused: async () => {
|
||||||
|
try {
|
||||||
|
const paused = await invoke<boolean>("get_notifications_paused");
|
||||||
|
set({ notificationsPaused: paused });
|
||||||
|
} catch {
|
||||||
|
// fall back to in-memory default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setNotificationsPaused: (paused) => {
|
||||||
|
set({ notificationsPaused: paused });
|
||||||
|
void invoke("set_notifications_paused", { paused }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadWorkerStates: async () => {
|
||||||
|
const folderIds = get().folders.map((folder) => folder.id);
|
||||||
|
if (folderIds.length === 0) {
|
||||||
|
set({ workerPaused: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const states = await invoke<FolderWorkerStates[]>("get_worker_states", { folderIds });
|
||||||
|
set({
|
||||||
|
workerPaused: Object.fromEntries(
|
||||||
|
states.map((state) => [
|
||||||
|
state.folder_id,
|
||||||
|
{
|
||||||
|
thumbnail: state.thumbnail_paused,
|
||||||
|
metadata: state.metadata_paused,
|
||||||
|
embedding: state.embedding_paused,
|
||||||
|
tagging: state.tagging_paused,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// leave the existing snapshot in place
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setWorkerPaused: (folderId, worker, paused) => {
|
||||||
|
set((state) => {
|
||||||
|
const current = state.workerPaused[folderId] ?? {
|
||||||
|
thumbnail: false,
|
||||||
|
metadata: false,
|
||||||
|
embedding: false,
|
||||||
|
tagging: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
workerPaused: {
|
||||||
|
...state.workerPaused,
|
||||||
|
[folderId]: { ...current, [worker]: paused },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
setAllWorkersPaused: (folderId, paused) => {
|
||||||
|
set((state) => ({
|
||||||
|
workerPaused: {
|
||||||
|
...state.workerPaused,
|
||||||
|
[folderId]: { thumbnail: paused, metadata: paused, embedding: paused, tagging: paused },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
for (const worker of WORKER_KEYS) {
|
||||||
|
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAppVersion: async () => {
|
||||||
|
try {
|
||||||
|
set({ appVersion: await getVersion() });
|
||||||
|
} catch {
|
||||||
|
// leave null; the UI falls back to a dash
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkForUpdates: async (options) => {
|
||||||
|
const quiet = options?.quiet ?? false;
|
||||||
|
const { updateStatus } = get();
|
||||||
|
if (updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing") return;
|
||||||
|
|
||||||
|
set({ updateStatus: "checking", updateError: null });
|
||||||
|
try {
|
||||||
|
const update = await check();
|
||||||
|
if (update) {
|
||||||
|
pendingUpdate = update;
|
||||||
|
set({ updateStatus: "available", updateVersion: update.version, updateDismissed: false });
|
||||||
|
} else {
|
||||||
|
pendingUpdate = null;
|
||||||
|
set({ updateStatus: "upToDate", updateVersion: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
pendingUpdate = null;
|
||||||
|
if (quiet) {
|
||||||
|
// Launch-time check: stay silent on network/endpoint failures.
|
||||||
|
set({ updateStatus: "idle" });
|
||||||
|
} else {
|
||||||
|
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
installUpdate: async () => {
|
||||||
|
const update = pendingUpdate;
|
||||||
|
if (!update || get().updateStatus !== "available") return;
|
||||||
|
|
||||||
|
set({ updateStatus: "downloading", updateProgress: null, updateError: null });
|
||||||
|
try {
|
||||||
|
let contentLength: number | null = null;
|
||||||
|
let downloaded = 0;
|
||||||
|
await update.downloadAndInstall((event) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case "Started":
|
||||||
|
contentLength = event.data.contentLength ?? null;
|
||||||
|
set({ updateProgress: contentLength ? 0 : null });
|
||||||
|
break;
|
||||||
|
case "Progress":
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
if (contentLength) {
|
||||||
|
set({ updateProgress: Math.min(downloaded / contentLength, 1) });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Finished":
|
||||||
|
set({ updateStatus: "installing", updateProgress: 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await relaunch();
|
||||||
|
} catch (error) {
|
||||||
|
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissUpdate: () => set({ updateDismissed: true }),
|
||||||
|
|
||||||
|
loadFfmpegStatus: async () => {
|
||||||
|
try {
|
||||||
|
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(
|
||||||
|
"get_ffmpeg_status",
|
||||||
|
);
|
||||||
|
if (status.installed) {
|
||||||
|
set({ ffmpegStatus: "installed" });
|
||||||
|
} else if (status.failed) {
|
||||||
|
// The download failed before our event listener attached — surface
|
||||||
|
// the error state so the retry button is reachable.
|
||||||
|
set({ ffmpegStatus: "error", ffmpegError: "The download could not be completed. Check your connection and retry." });
|
||||||
|
} else {
|
||||||
|
// Not installed and possibly not downloading yet — the provision
|
||||||
|
// thread starts with the app, so treat the gap as "starting" and let
|
||||||
|
// the first ffmpeg-progress event settle the real state.
|
||||||
|
set({ ffmpegStatus: "starting" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave "unknown"; events will correct it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
retryFfmpegDownload: async () => {
|
||||||
|
set({ ffmpegStatus: "starting", ffmpegError: null, ffmpegProgress: null });
|
||||||
|
try {
|
||||||
|
await invoke("retry_ffmpeg_download");
|
||||||
|
} catch (error) {
|
||||||
|
set({ ffmpegStatus: "error", ffmpegError: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadOnboardingCompleted: async () => {
|
||||||
|
try {
|
||||||
|
const completed = await invoke<boolean>("get_onboarding_completed");
|
||||||
|
set(
|
||||||
|
completed
|
||||||
|
? { onboardingCompleted: true }
|
||||||
|
: { onboardingCompleted: false, onboardingOpen: true, onboardingStep: 0 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// If the flag can't be read, don't trap the user in onboarding.
|
||||||
|
set({ onboardingCompleted: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
completeOnboarding: () => {
|
||||||
|
set({ onboardingOpen: false, onboardingCompleted: true });
|
||||||
|
void invoke("set_onboarding_completed", { completed: true }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
openOnboarding: () => set({ onboardingOpen: true, onboardingStep: 0 }),
|
||||||
|
|
||||||
|
setOnboardingStep: (step) => set({ onboardingStep: step }),
|
||||||
|
|
||||||
|
openAppDataFolder: async () => {
|
||||||
|
await invoke("open_app_data_folder");
|
||||||
|
},
|
||||||
|
|
||||||
|
getDatabaseInfo: () => invoke<DatabaseInfo>("get_database_info"),
|
||||||
|
|
||||||
|
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
||||||
|
|
||||||
|
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
||||||
|
|
||||||
|
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
||||||
|
|
||||||
loadTaggerModelStatus: async () => {
|
loadTaggerModelStatus: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1462,23 +1900,42 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
|
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
|
||||||
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
|
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
|
||||||
if (cached) {
|
if (cached) {
|
||||||
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at, duplicateScanFolderId: folderId });
|
set({
|
||||||
|
duplicateGroups: cached.groups,
|
||||||
|
duplicateLastScanned: cached.scanned_at,
|
||||||
|
duplicateScanFolderId: folderId,
|
||||||
|
duplicateScanWarning: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scanDuplicates: async (folderId = null) => {
|
scanDuplicates: async (folderId = null) => {
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
set({ duplicateScanning: true, duplicateGroups: [], duplicateScanProgress: null, duplicateScanError: null, duplicateSelectedIds: new Set() });
|
set({
|
||||||
const unlisten = await listen<[number, number]>("duplicate_scan_progress", (event) => {
|
duplicateScanning: true,
|
||||||
const [scanned, total] = event.payload;
|
duplicateGroups: [],
|
||||||
set({ duplicateScanProgress: { scanned, total } });
|
duplicateScanProgress: null,
|
||||||
|
duplicateScanError: null,
|
||||||
|
duplicateScanWarning: null,
|
||||||
|
duplicateSelectedIds: new Set(),
|
||||||
|
});
|
||||||
|
const unlisten = await listen<DuplicateScanProgress>("duplicate_scan_progress", (event) => {
|
||||||
|
set({ duplicateScanProgress: event.payload });
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
|
const result = await invoke<DuplicateScanResult>("find_duplicates", { folderId: folderId ?? null });
|
||||||
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000), duplicateScanFolderId: folderId });
|
const warning = result.skipped_files > 0
|
||||||
|
? `${result.skipped_files.toLocaleString()} file${result.skipped_files === 1 ? "" : "s"} could not be read and were skipped.`
|
||||||
|
: null;
|
||||||
|
set({
|
||||||
|
duplicateGroups: result.groups,
|
||||||
|
duplicateLastScanned: Math.floor(Date.now() / 1000),
|
||||||
|
duplicateScanFolderId: folderId,
|
||||||
|
duplicateScanWarning: warning,
|
||||||
|
});
|
||||||
void notifyTaskComplete(
|
void notifyTaskComplete(
|
||||||
"Duplicate scan complete",
|
"Duplicate scan complete",
|
||||||
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,
|
`${result.groups.length === 1 ? "Found 1 duplicate group." : `Found ${result.groups.length.toLocaleString()} duplicate groups.`}${warning ? ` ${warning}` : ""}`,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ duplicateScanError: String(e) });
|
set({ duplicateScanError: String(e) });
|
||||||
@@ -1584,11 +2041,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
progress.total > 0 &&
|
progress.total > 0 &&
|
||||||
progress.indexed >= progress.total
|
progress.indexed >= progress.total
|
||||||
) {
|
) {
|
||||||
const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name;
|
const { notificationsPaused, mutedFolderIds } = get();
|
||||||
void notifyTaskComplete(
|
if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) {
|
||||||
"Folder scan complete",
|
const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name;
|
||||||
folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.",
|
void notifyTaskComplete(
|
||||||
);
|
"Folder scan complete",
|
||||||
|
folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void get().loadFolders();
|
void get().loadFolders();
|
||||||
void get().loadBackgroundJobProgress();
|
void get().loadBackgroundJobProgress();
|
||||||
@@ -1613,32 +2073,52 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const previous = previousProgress[progress.folder_id];
|
const previous = previousProgress[progress.folder_id];
|
||||||
if (!previous) continue;
|
if (!previous) continue;
|
||||||
|
|
||||||
|
const { notificationsPaused, mutedFolderIds } = get();
|
||||||
|
const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id);
|
||||||
const folderName =
|
const folderName =
|
||||||
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
|
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
|
||||||
|
|
||||||
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
|
// Embeddings — debounced so rapid file additions don't fire per-file.
|
||||||
const failureDetail =
|
const embeddingKey = `${progress.folder_id}:embedding`;
|
||||||
progress.embedding_failed > 0
|
if (!suppressed) {
|
||||||
? ` ${progress.embedding_failed.toLocaleString()} failed.`
|
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
|
||||||
: "";
|
clearTimeout(notificationTimers.get(embeddingKey));
|
||||||
void notifyTaskComplete(
|
const failureDetail =
|
||||||
"Embeddings complete",
|
progress.embedding_failed > 0
|
||||||
`${folderName} finished generating embeddings.${failureDetail}`,
|
? ` ${progress.embedding_failed.toLocaleString()} failed.`
|
||||||
);
|
: "";
|
||||||
|
const body = `${folderName} finished generating embeddings.${failureDetail}`;
|
||||||
|
notificationTimers.set(embeddingKey, setTimeout(() => {
|
||||||
|
notificationTimers.delete(embeddingKey);
|
||||||
|
void notifyTaskComplete("Embeddings complete", body);
|
||||||
|
}, NOTIFICATION_DEBOUNCE_MS));
|
||||||
|
} else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) {
|
||||||
|
// More jobs queued — cancel the pending notification.
|
||||||
|
clearTimeout(notificationTimers.get(embeddingKey));
|
||||||
|
notificationTimers.delete(embeddingKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
|
// Tagging — same debounce pattern.
|
||||||
const failureDetail =
|
const taggingKey = `${progress.folder_id}:tagging`;
|
||||||
progress.tagging_failed > 0
|
if (!suppressed) {
|
||||||
? ` ${progress.tagging_failed.toLocaleString()} failed.`
|
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
|
||||||
: "";
|
clearTimeout(notificationTimers.get(taggingKey));
|
||||||
void notifyTaskComplete(
|
const failureDetail =
|
||||||
"AI tagging complete",
|
progress.tagging_failed > 0
|
||||||
`${folderName} finished generating tags.${failureDetail}`,
|
? ` ${progress.tagging_failed.toLocaleString()} failed.`
|
||||||
);
|
: "";
|
||||||
// New tags are now in the DB — invalidate the Explore tag cache so
|
const body = `${folderName} finished generating tags.${failureDetail}`;
|
||||||
// reopening Explore reflects the updated tag distribution.
|
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||||
set({ exploreTagsFolderId: undefined });
|
notificationTimers.delete(taggingKey);
|
||||||
|
void notifyTaskComplete("AI tagging complete", body);
|
||||||
|
// New tags landed — invalidate Explore tag cache.
|
||||||
|
set({ exploreTagsFolderId: undefined });
|
||||||
|
}, NOTIFICATION_DEBOUNCE_MS));
|
||||||
|
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||||
|
clearTimeout(notificationTimers.get(taggingKey));
|
||||||
|
notificationTimers.delete(taggingKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,6 +2220,55 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlistenWatcherDeleted = await listen<number[]>("watcher-deleted", (event) => {
|
||||||
|
const deletedIds = new Set(event.payload);
|
||||||
|
set((state) => {
|
||||||
|
const removed = state.images.filter((img) => deletedIds.has(img.id)).length;
|
||||||
|
const images = state.images.filter((img) => !deletedIds.has(img.id));
|
||||||
|
const selectedImage =
|
||||||
|
state.selectedImage && deletedIds.has(state.selectedImage.id)
|
||||||
|
? null
|
||||||
|
: state.selectedImage;
|
||||||
|
return {
|
||||||
|
images,
|
||||||
|
totalImages: Math.max(0, state.totalImages - removed),
|
||||||
|
loadedCount: Math.max(0, state.loadedCount - removed),
|
||||||
|
selectedImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenFolderCounts = await listen("folder-counts-changed", () => {
|
||||||
|
void get().loadFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenFfmpegProgress = await listen<FfmpegProgressEvent>("ffmpeg-progress", (event) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
switch (payload.phase) {
|
||||||
|
case "starting":
|
||||||
|
set({ ffmpegStatus: "starting", ffmpegError: null });
|
||||||
|
break;
|
||||||
|
case "downloading":
|
||||||
|
set({
|
||||||
|
ffmpegStatus: "downloading",
|
||||||
|
ffmpegProgress:
|
||||||
|
payload.downloaded_bytes !== null && payload.total_bytes !== null
|
||||||
|
? { downloaded_bytes: payload.downloaded_bytes, total_bytes: payload.total_bytes }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "unpacking":
|
||||||
|
set({ ffmpegStatus: "unpacking" });
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
set({ ffmpegStatus: "installed", ffmpegProgress: null, ffmpegError: null });
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
set({ ffmpegStatus: "error", ffmpegError: payload.error ?? "Download failed" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenProgress();
|
unlistenProgress();
|
||||||
unlistenMediaJobs();
|
unlistenMediaJobs();
|
||||||
@@ -1747,6 +2276,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
unlistenTaggerModelProgress();
|
unlistenTaggerModelProgress();
|
||||||
unlistenImages();
|
unlistenImages();
|
||||||
unlistenThumbnails();
|
unlistenThumbnails();
|
||||||
|
unlistenWatcherDeleted();
|
||||||
|
unlistenFolderCounts();
|
||||||
|
unlistenFfmpegProgress();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||