diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..93bf04c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Working on OpenCull + +OpenCull is an offline photo-culling desktop application: React/TypeScript in `src/`, Rust/Tauri in `src-tauri/src/`. Read `README.md` and `docs/HOW-IT-WORKS.md` before changing behavior. + +## Branches and review + +- Implement one feature per short-lived `feature/` branch. Use `fix/`, `docs/`, or `chore/` for other work. Never implement directly on `main`. +- Independent features start from current `upstream/main` so the original maintainer can select them independently. Default PR target is `wispdevon/OpenCull:main`; submit upstream only when requested. +- Fetch before choosing a base. Keep upstream synchronization in a separate PR. Do not bundle other feature commits into a standalone feature branch. +- Combine features only on a separate `integration/` branch. Document integration-only fixes there, including conflict resolutions. +- Inspect Git status first. Preserve unrelated edits and use separate worktrees when another branch is dirty or work runs concurrently. Never reset or stash someone else's work. +- Keep commits and PRs focused; separate unrelated refactoring, formatting, dependency changes, and lockfile changes. Review staged diffs before committing. +- Do not force-push shared branches. Resolve conflicts deliberately and rerun affected checks. Merge only when requested; remove merged feature branches after completion. + +## Verification + +- `npm ci` installs the locked dependencies. Use Node 22.12+ (CI uses Node 22). +- `npm run test:web` runs frontend tests; `npm run test:rust` runs Rust library tests; `npm test` runs both. +- `npm run build` checks TypeScript and builds the frontend. There is no lint script: do not claim one passed. +- For desktop changes, also run `npm run tauri build -- --debug --no-bundle` and relevant runtime checks. Linux requires GTK/WebKitGTK and the Tauri prerequisites; see the README and CI. +- Add focused regression tests for behavior, using existing Vitest and inline Rust test conventions. Do not weaken checks to make a branch pass. +- Documentation-only work needs diff/link review, not an application rebuild. +- PR descriptions explain the problem, resulting behavior, validation, platform limitations, and any prerequisites. Report blocked checks honestly. + +## Product boundaries + +- Keep photos private: processing stays local; no remote font or media requests. +- Preserve original files. Card review, rejecting, and copying must never delete sources. Source deletion must be a distinct explicitly confirmed action. +- Preserve sidecar compatibility, RAW/JPEG associations, keyboard navigation, and existing normal-folder behavior. +- Settings belong behind the shared preferences interface. Retain existing defaults in upstream-facing features; personal defaults belong in local configuration. +- Support Linux without introducing macOS/Windows regressions. Test on available platforms and state what was not tested. +- Keep browser demo and native Tauri paths working. Use semantic theme tokens, accessible labels, and the existing UI component system. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2cf0522 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,29 @@ +# OpenCull interface design guidelines + +These rules apply across Home, ingest, culling, settings, menus and dialogs. Wisp is the reference theme for readable interface contrast; other presets retain their visual character. + +## Readability and surfaces + +Use semantic tokens for background, panel, row, input, selected state, text and focus. Avoid a single grey for every level. Wisp separates an ink workspace (#111827), navy panels (#162033), raised rows (#1d293d), and controls (#2b3b55). Borders (#71839f) identify controls and setting boundaries. Primary text is #f3f6ff; secondary copy is #bac8df. Violet (#b7a6ff) marks primary actions and active controls. Light mode uses paper surfaces with dark ink and a deeper violet. + +Normal text must reach 4.5:1 against its actual background; large text, icons and necessary control boundaries must reach 3:1. Test computed colours in every colour mode, including hover and selected states. Do not lower descriptive copy opacity until it becomes difficult to read. Colour never carries state alone: use position, border, checkmark, text or icon as well. + +## Hierarchy and density + +Keep identity and supporting text above a horizontal action row. Use an 8px spacing rhythm: 8 within groups, 16 between controls or rows, 24 between sections, 32 before image collections. Setting rows have their own surface, 1px visible border, 8px radius and 16px padding; title and description belong together, control aligned at the start. Do not put borders around every text fragment. + +Titles use a clear weight and size hierarchy. Home headings can scale from 36–56px with bounded container sizing. Section headings use 22–32px. Setting labels use 14px semibold; explanatory text uses 13px with 1.6 line height. Retain the user's font preference and native interface scale. Avoid fixed heights on text blocks; allow wrapping and vertical scrolling. + +Controls need a 40px hit area where practical, with a visible keyboard focus outline separated from their edge. Selected tabs need both a border and stronger text; checked switches need a contrasting thumb position and track. Disabled controls retain labels and never look active. Menus, dialogs and popovers use opaque readable surfaces. Dialog content stays within the viewport and scrolls on smaller windows. + +## Photograph-first workspace + +Photo pixels, marks and rating colours are never recoloured by the theme. Keep the loupe surround neutral; 18% grey is #767676. Native-aspect strips use displayed rotation and crop proportions. Keep selection rings, rank labels and resize edges visible over mixed photographs. Theme accents belong on interface controls, not as a tint over previews. + +Recent folder contact sheets stay visible. Compact presets may reduce card dimensions without turning images into text-only entries. Bulk actions appear after the first selection. Removing recent history must clearly distinguish itself from deleting source photos. Errors use readable text, not just red borders. Loading retains useful previews where possible. + +## Verification + +Check Home, ingest, culling and all Settings tabs in dark, light and 18% grey, at small and large windows and at 100%, 150% and 200% native interface scale. Verify keyboard focus, scrolling, switch/tab state contrast and forced colours. Use screenshots plus measured contrast; do not infer accessibility from palette names. + +Wisp is supplied by feature/wisp-theme and depends on feature/rapidraw-theme's preset infrastructure. It changes no personal default automatically and loads no remote fonts or assets. diff --git a/README.md b/README.md index 526d509..65a2dad 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,70 @@

+## Run this fork with all features + +The `integration/linux-features` branch combines every feature in this fork. +No manual merging or cherry-picking is needed. Individual feature branches +remain available for selective upstream review; fork `main` contains general +tooling fixes rather than the combined application. + +Install [Rust stable](https://rustup.rs) and the +[native Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) first: + +- **macOS:** install Xcode Command Line Tools (`xcode-select --install`), then + `brew install jpeg-turbo pkgconf`. Tauri automatically uses the macOS + `turbo-system` configuration. +- **Linux:** install GTK/WebKitGTK prerequisites plus CMake and NASM (NASM is + needed on Intel/AMD). Tauri uses the Linux `turbo` configuration. +- **Windows:** install the Visual Studio C++ and WebView2 prerequisites plus + CMake and NASM. Tauri uses the Windows `turbo` configuration. + +Then, using nvm (or your platform's Node version manager): + +```sh +git clone --branch integration/linux-features --single-branch https://github.com/wispdevon/OpenCull.git +cd OpenCull +nvm install +nvm use +npm install --global npm@11.21.1 +npm ci +npm run dev:desktop +``` + +Without nvm, install Node 22.23.3 and npm 11.21.1 using your preferred version +manager, or run the last two commands through the pinned runtime: + +```sh +npx --yes --package=node@22.23.3 --package=npm@11.21.1 --call 'npm ci && npm run dev:desktop' +``` + +The desktop development command starts the frontend and native backend +together, with live frontend reloads and incremental Rust recompilation. +It does not create a production bundle or installers. The initial native +compile can take several minutes. On affected Linux/Wayland systems, use +`WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run dev:desktop`. + +All features are included: cull-before-import sessions, multiple card sources, +specific card subfolders, configurable panels, video inclusion/playback, +appearance modes, offline fonts, the optional RapidRAW inspired design, +larger UI headings, XDG configuration, remembered/default view modes, Wisp contrast, +saved interface scaling, recent session deletion, resizable stack strips, +experimental native-aspect strips and eased zoom, and optional NVIDIA rendering profiles. +Optional preferences are selectable in Settings; the RapidRAW design is under +**Culling → Design theme**, and view/panel controls are under **Layout**. +Linux settings are created in `$XDG_CONFIG_HOME/opencull/config.json`, normally +`~/.config/opencull/config.json`. No personal configuration file is required. +macOS and Windows use Tauri's native application-config directory instead. +The Linux video streaming workaround is compiled only on Linux; macOS keeps +native asset-protocol playback. `dev:desktop` sets no Linux environment flags. + +For a Mac tester, use the [macOS trial guide](docs/TRY-ON-MACOS.md). +NVIDIA rendering profiles are Linux-only; macOS uses its native webview automatically. + +Run `npm run check` for automated checks. See the +[feature and validation map](docs/features/VALIDATION.md) for individual PRs +and platform limitations. +
Table of Contents @@ -155,7 +219,7 @@ also need the system packages on 1. Clone the repo ```sh - git clone https://github.com/NatePhotos/OpenCull.git + git clone --branch integration/linux-features --single-branch https://github.com/wispdevon/OpenCull.git cd OpenCull ``` 2. Install the packages diff --git a/docs/TRY-ON-MACOS.md b/docs/TRY-ON-MACOS.md new file mode 100644 index 0000000..396023b --- /dev/null +++ b/docs/TRY-ON-MACOS.md @@ -0,0 +1,39 @@ +# Try every OpenCull feature on macOS + +Share this link: https://github.com/wispdevon/OpenCull/tree/integration/linux-features + +The integration branch already combines the feature branches. Do not merge branches by hand or use fork main for the combined preview. Experimental features are available in Settings and remain opt-in. The discarded mountain welcome-page demo is excluded. + +## Prerequisites + +Install Xcode Command Line Tools (`xcode-select --install`), Homebrew, Rust stable from https://rustup.rs, and Node 22.23.3 (for example with nvm). Then: + +```sh +brew install jpeg-turbo pkgconf +git clone --branch integration/linux-features --single-branch https://github.com/wispdevon/OpenCull.git +cd OpenCull +nvm install +nvm use +npm install --global npm@11.21.1 +npm ci +npm run dev:desktop +``` + +If Node 22.23.3 and npm 11.21.1 are already installed, omit the nvm and global npm steps. The first Rust compile can take several minutes. Keep the terminal open. This starts the Vite frontend and native Tauri backend together; it does not create an installer. A browser-only preview cannot ingest actual cards or exercise native image decoding. + +macOS automatically uses the Homebrew libjpeg-turbo configuration. Do not set Linux WebKit, DMA-BUF, CUDA or NVIDIA flags on a Mac. The NVIDIA profile commands intentionally apply only to Linux. Settings use the native macOS application-config directory; no personal Linux config is needed. + +## Things to try + +- Ingest from Card: navigate to a DCIM subfolder, add multiple cards to one review session, cull before copying, and resume through Recent Sessions. +- Settings → Culling: choose Wisp, OpenCull or RapidRAW inspired design; choose colour mode and font. Wisp separates setting rows and active controls with stronger contrast. +- Settings → Layout: interface scale (80–200%), default/remembered view, stack filmstrip placement. Drag each strip's top edge to resize; F hides both stack strips. Resize edges support arrow keys as well. +- Settings → Culling → Experimental Features: native-aspect strips, including the stack overview; decoded JPEG dimensions allow early sizing before complete metadata. +- Settings → Zoom → Experimental Features: eased click zoom. This affects motion curves, not the speed of CPU image decoding. +- Select one recent item to reveal bulk controls. Deleting a session removes saved review history, never source photos. + +Use a copy of a small shoot for an initial trial. `npm run check` runs frontend tests/build and Rust tests. Update with `git pull --ff-only`, `npm ci`, then `npm run dev:desktop`. + +## Verification boundaries + +The earlier combined snapshot passed the macOS Apple-silicon CI tests and build. The final branch's CI additionally starts `dev:desktop`, checks Vite and the native process, and observes them through startup. This is a startup smoke test, not a visual, card-reader or performance test on the recipient's Mac. Intel macOS is not covered by the Apple-silicon runner. See the latest integration Build workflow for the exact commit and result. diff --git a/docs/features/EXPERIMENTAL_FILMSTRIP.md b/docs/features/EXPERIMENTAL_FILMSTRIP.md new file mode 100644 index 0000000..b146a97 --- /dev/null +++ b/docs/features/EXPERIMENTAL_FILMSTRIP.md @@ -0,0 +1 @@ +Settings → Culling → Experimental Features → Native-aspect filmstrip. Disabled by default. Uses displayed rotation/crop proportions at a common height, preserving the existing virtualized horizontal strip. Unknown dimensions temporarily use 3:2 until metadata arrives. No masonry CSS dependency or photo changes. diff --git a/docs/features/EXPERIMENTAL_ZOOM.md b/docs/features/EXPERIMENTAL_ZOOM.md new file mode 100644 index 0000000..2eb1f92 --- /dev/null +++ b/docs/features/EXPERIMENTAL_ZOOM.md @@ -0,0 +1 @@ +Settings → Zoom → Experimental Features → Smooth click zoom. Disabled by default. Click/keyboard/face/marquee zoom uses a fast-start, slow-stop cubic curve with 280–340 ms durations. Reduced motion disables these transitions. Wheel zoom and dragging remain immediate. Existing sharp rerasterization after motion is retained. This is an experimental motion option, not a promise of native macOS performance. diff --git a/docs/features/INTERFACE_SCALE.md b/docs/features/INTERFACE_SCALE.md new file mode 100644 index 0000000..8a5a2e7 --- /dev/null +++ b/docs/features/INTERFACE_SCALE.md @@ -0,0 +1,7 @@ +# Interface scale + +Saved desktop preference under Settings → Culling in the standalone feature, and Layout in the combined app. Uses Tauri Webview.setZoom so pixels, rems, menus, controls, and pointer coordinate systems scale together. Supports 80–200%, default 100%, plus Reset. Uses shared preferences. Browser demos retain browser zoom controls rather than simulating page zoom with CSS transforms. + +macOS requires 11+ (Tauri API requirement). Linux and Windows use their native webview zoom implementation. A narrowly scoped core:webview:allow-set-webview-zoom permission is added. Errors remain visible and unsuccessful changes are not persisted. + +Settings stay within the viewport, tabs wrap, settings content scrolls on short windows, and the culling toolbar wraps rather than clipping controls as usable CSS space decreases. diff --git a/docs/features/NVIDIA_GRAPHICS.md b/docs/features/NVIDIA_GRAPHICS.md new file mode 100644 index 0000000..5dc2940 --- /dev/null +++ b/docs/features/NVIDIA_GRAPHICS.md @@ -0,0 +1,9 @@ +# NVIDIA graphics rendering + +Independent feature branch: feature/nvidia-gpu-acceleration. Uses the native graphics stack for webview compositing, photo transformations, WebGL and supported 2D canvas rendering. It does not implement CUDA image decoding or change ONNX inference. WebKit still decides which operations are accelerated. + +Linux: `npm run dev:desktop:nvidia` requests hardware compositing, enables WebGL and supported accelerated canvas, and removes inherited software/compositing-disable overrides before GTK initializes. It uses `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_DMABUF_RENDERER_DISABLE_GBM=1` rather than disabling the DMA-BUF renderer and prefers XWayland when an X display is available and no backend was explicitly requested, because direct DMA-BUF/GBM presentation failed with this test machine’s proprietary driver. This leaves accelerated compositing enabled while changing buffer transport. A native CSS-only animation probe on this RTX 3060 Ti allocated 53 MiB in the WebKit graphics process; no WebGL probe was needed to produce that allocation. `npm run dev:desktop:compatibility` retains the DMA-BUF workaround; `npm run dev:desktop:software` explicitly requests a software path for comparison. The ordinary desktop command remains automatic and changes no environment flags. Packaged binaries accept `--graphics=nvidia`, `--graphics=compatibility`, `--graphics=software`, or `OPENCULL_GRAPHICS`. Linux-only code and dependencies leave macOS/Windows native rendering unchanged. + +Settings → Storage → Graphics acceleration provides a renderer probe. An NVIDIA WebGL renderer is evidence for that context, not proof that every rendering operation is GPU accelerated. Verify native WebKit process GPU device use and frame timings as well. These profiles do not install drivers or edit system configuration. On hybrid systems, choose the discrete GPU through the system's normal offload launcher. + +Reference: https://docs.webkit.org/Ports/WebKitGTK%20and%20WPE%20WebKit/Graphics.html and the WebKitGTK HardwareAccelerationPolicy API. The NVIDIA profile requests Always; it does not claim a specific performance improvement. Compatibility remains available for driver/compositor issues. diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 0000000..d3f49cf --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,15 @@ +# Independent feature initiative + +Approved feature branches (each starts at upstream main): + +- `feature/cull-before-ingest`: reuse culling before copying; local source-safe marks; choose files at finish; copy or import in place. +- `feature/multi-card-session`: combine sources; conservatively pair RAW/JPEG across cards; one session. +- `feature/configurable-ui`: settings for panel visibility and sizes, preserving defaults. +- `feature/appearance-modes`: dark, light, 18% grey; preserve default dark. +- `feature/font-selection`: system, offline Space Grotesk, custom local family; preserve default system. +- `feature/video-culling`: ignore toggle, supported playback, import selection; default ignore. +- `feature/xdg-config`: atomic native settings, Linux XDG path, migration from webview preferences. + +No feature requires another. Combined behavior and overlap resolutions belong on `integration/linux-features`. No automatic merges or upstream submissions. + +Personal configuration: Space Grotesk, 18% grey, existing panel visibility/sizes, videos ignored. Card workflow reviews first, then chooses Copy (initially all non-rejects) or Import in place (only selected members). No card writes during review or implicit deletion. diff --git a/docs/features/RECENT_SESSIONS.md b/docs/features/RECENT_SESSIONS.md new file mode 100644 index 0000000..a2343b9 --- /dev/null +++ b/docs/features/RECENT_SESSIONS.md @@ -0,0 +1 @@ +Depends on feature/cull-before-ingest. Card sessions are retained individually and shown under Recent Sessions. Legacy latest.json remains readable. Resume Latest Session compares card review save time with the last successfully opened normal folder. Missing cards produce a reconnect message. Source photos are never modified by session history. diff --git a/docs/features/STACK_FILMSTRIP.md b/docs/features/STACK_FILMSTRIP.md new file mode 100644 index 0000000..7b1a111 --- /dev/null +++ b/docs/features/STACK_FILMSTRIP.md @@ -0,0 +1 @@ +Settings → Culling → Stack filmstrip placement. Classic is the default. Keep normal filmstrip placement puts active stack frames in the shared full-width bottom filmstrip; the scene overview remains above. The existing show-filmstrip preference applies. diff --git a/docs/features/VALIDATION.md b/docs/features/VALIDATION.md new file mode 100644 index 0000000..9d7d123 --- /dev/null +++ b/docs/features/VALIDATION.md @@ -0,0 +1,139 @@ +# Linux feature integration validation + +The seven feature branches start independently at upstream release 0.4.1. Each has its own fork PR; none requires the others. `integration/linux-features` combines them and resolves their overlapping settings, session, and ingest interfaces. General Node/toolchain fixes are committed directly to fork `main` and merged into integration. + +## Review units + +| Change | Branch | Fork PR | +|---|---|---| +| Agent workflow | `docs/agent-workflow` | [#1](https://github.com/wispdevon/OpenCull/pull/1) | +| Workspace controls | `feature/configurable-ui` | [#2](https://github.com/wispdevon/OpenCull/pull/2) | +| Offline fonts | `feature/font-selection` | [#3](https://github.com/wispdevon/OpenCull/pull/3) | +| Dark/light/18% grey | `feature/appearance-modes` | [#4](https://github.com/wispdevon/OpenCull/pull/4) | +| XDG configuration | `feature/xdg-config` | [#5](https://github.com/wispdevon/OpenCull/pull/5) | +| Cull before import | `feature/cull-before-ingest` | [#6](https://github.com/wispdevon/OpenCull/pull/6) | +| Multi-card sources | `feature/multi-card-session` | [#7](https://github.com/wispdevon/OpenCull/pull/7) | +| Video playback | `feature/video-culling` | [#8](https://github.com/wispdevon/OpenCull/pull/8) | +| RapidRAW inspired design | `feature/rapidraw-theme` | [#10](https://github.com/wispdevon/OpenCull/pull/10) | +| Precise card subfolders | `feature/ingest-subfolders` | [#11](https://github.com/wispdevon/OpenCull/pull/11) | +| Larger headings and review guidance | `feature/ui-changes` | [#12](https://github.com/wispdevon/OpenCull/pull/12) | +| Default and remembered views | `feature/view-mode-preferences` | [#13](https://github.com/wispdevon/OpenCull/pull/13) | + +The design and subfolder branches also start directly from upstream. The UI +branch starts from `feature/cull-before-ingest`, because its instruction text +describes that workflow; its PR targets that branch to isolate the UI diff. + +## Additional welcome, folder, and heading checks + +The latest integration passes 52 frontend tests and a Linux debug desktop +build. Standalone design/subfolder/UI branches pass their frontend tests and +TypeScript/Vite builds. No Rust logic changed in these additions. + +The in-app browser remained unavailable, so a separate headless Chromium +instance checked the rendered screens. No existing user browser was controlled. +Theme checks covered selection, reload persistence, offline font application, +font opt-out, restoration of the default design, and 1440px/600px layouts without +horizontal overflow or JavaScript errors. Combined light/dark/grey modes keep +the expected native colour scheme and grey's `#767676` photo canvas. + +Folder checks observed no photo scan when navigating CARD or DCIM, a scan of +only `DCIM/100CANON` after selecting it, and an explicit parent selection with +**Use this folder**. Combined multi-source selection retained earlier sources. +Heading checks verified a 24px ingest heading, 14px instruction immediately +below it, no duplicate sidebar instruction, and 20px shared dialog titles. + +View preferences add a default opening view and an enabled-by-default +**Remember last view** control under **Settings → Layout**. The standalone +branch passes 35 frontend tests and a TypeScript/Vite build. Combined browser +checks verify Filmstrip/Loupe, Stack/Scene, and Grid across folder changes and +reloads, explicit defaults with remembering disabled, and keyboard changes +written to preferences. The runtime check follows the UI's nonblocking folder +opening path and waits for the listing rather than for background metadata. + +The updated executable opened successfully in the user's Wayland session with +`WEBKIT_DISABLE_DMABUF_RENDERER=1`. An unmodified launch previously exited with +a Wayland protocol error. The previous app window was left running so its +pending card selection was preserved. + +## Verified locally + +- Clean-clone quickstart: a fresh remote clone of `integration/linux-features` + installed with pinned `npm ci` and passed `npm run check` (52 frontend tests, + production frontend build, and 283 Rust tests). The clone contained no local + patches or dependency symlinks. Cargo reused cached native dependencies. +- `npm run dev:desktop` passed its Node guard, started Vite on port 1420, and + opened the real desktop app in the user's Wayland session. This uses the + development URL and Rust watcher rather than a packaged frontend. The Linux + graphics workaround was supplied only to that local process's environment. +- Platform review: Linux video streaming and XDG path code/tests are guarded by + Linux target configuration. macOS uses its native app-config location, + asset-protocol video playback, and `turbo-system` JPEG feature. macOS setup/CI + now explicitly installs Homebrew `jpeg-turbo` and `pkgconf`. Native macOS + verification is tracked by [the cross-platform run](https://github.com/wispdevon/OpenCull/actions/runs/38796608300); + it was still in progress when the local desktop development session opened. +- Toolchain: Node 22.23.3, npm 11.21.1. Clean `npm ci`, then `npm run check`: 46 frontend tests, frontend production build, and 283 Rust library tests pass on the combined branch. Fork main separately passes its 31 frontend tests, production build, and 268 Rust tests. +- Desktop: `npm run build:desktop -- --debug --no-bundle` succeeds on Linux with libjpeg-turbo enabled. +- Native self-test: opens files, loads previews, transfers without changing originals, and exercises the new features through real Tauri IPC and the existing store. +- Separate synthetic RAW/JPEG cards pair into shared entries. Local marks survive save/load; reviewing creates no source sidecars. Selected-only copying includes companions, and import in place retains only selected members and their marks. +- MP4 metadata, playback, pause, and seeking work on Linux. WebKitGTK cannot play the custom `asset:` scheme directly, even with a working H.264 decoder. The Linux feature uses a bounded, token-protected loopback HTTP stream, with range/authorization tests. Other systems retain Tauri's scoped asset protocol. +- Offline Space Grotesk loads. Theme controls change the actual root theme; dark/light/grey appearances and layout controls render. Native screenshots were inspected outside the repository. +- A malformed config remains byte-for-byte intact and displays a recovery banner while the app remains usable. An unwritable config root creates no partial file and displays the same recovery path. +- SHA-256 hashes and file membership of all nine synthetic source files remain unchanged after native checks. Test photos, videos, snapshots, and session data are outside the repository. + +## Reproducing native feature checks + +Use an isolated XDG config/data/cache directory. The combined native `--selftest ` runs baseline checks and the additional integration checks. Supply two or more still images; include a playable MP4 to test video. An optional `selfTestSources` array in the temporary config can point at separate RAW and JPEG source folders to verify cross-card pairing. This option is read only by the integration self-test, not normal application flows. + +The browser plugin reported `Browser is not available: iab`; rendered validation used the real Linux GTK/WebKit application under a private X display instead. No external browser or existing user browser session was controlled. + +These fixtures validate session, pairing, copying, persistence, and media delivery. They are synthetic previews with RAW filenames, not real sensor-data captures. Original RAW parsing retains its existing Rust tests. Physical card-reader behavior and macOS/Windows runtime behavior still need reports from those systems; the fork's cross-platform build is separate from local Linux validation. + +## Personal configuration + +`$XDG_CONFIG_HOME/opencull/config.json` (normally `~/.config/opencull/config.json`): + +```json +{ + "theme": "grey", + "font": { "choice": "space-grotesk", "family": "" }, + "ignoreVideos": true +} +``` + +Existing panel defaults remain unchanged. These are personal values; standalone feature branches preserve the original first-run defaults. + + +## October 16 follow-up + +- Combined frontend: 57 tests pass; TypeScript/Vite production build passes. +- Combined Rust library: 285 tests pass, including retained session history and legacy session compatibility. +- Browser runtime: mixed native-aspect widths, fixed full-width stack filmstrip geometry, and the experimental 320ms cubic click-zoom transition verified after a fresh dev-server launch. +- Linux native preview fix: WebKitGTK dev-origin CORS allowlist enables opencull://localhost previews. Reproduced failure with the DMABUF flag both set and unset; verified JPEG and real RW2 preview dimensions after the fix. +- Stack placement lives under Layout in the combined app. Standalone branch uses Culling. +- New standalone branches: feature/recent-sessions (depends on cull-before-ingest), feature/stack-filmstrip-layout, feature/experimental-filmstrip-aspect, feature/experimental-smooth-zoom. +- Native macOS runtime remains unverified locally. + + +## Interface scale and compact home follow-up + +- Interface scale uses native webview page zoom, with a saved 80–200% range and Reset. Located under Settings → Layout in the combined app. Native Linux inspection verified 150% (960×600 CSS viewport) and 200% (720×450), bounded Settings dialogs, no horizontal overflow or errors, and 200% retained after reload. +- Typography uses clamped container-relative heading sizes with rem bounds and fixed fallbacks. Identity stays above the horizontal toolbar in both visual themes. +- RapidRAW recent-folder contact sheets remain visible. Its session cards measured 304px versus 437px in the default preset at the same 1440px viewport; card layouts and toolbar hierarchy verified in browser runtime. +- User rejected the monochrome mountain demo. demo/rapidraw-welcome is excluded from integration and the app continues to use its original opening page. +- Frontend suite: 58 tests pass; combined production frontend build passes. Native macOS scaling has not been run locally. + +## Contrast, strips, history deletion and graphics follow-up + +- 61 frontend tests pass; production frontend build passes. 288 Rust tests pass, including session removal/promotion, legacy cleanup and graphics-profile choice. +- Wisp's measured setting title/detail contrast meets 4.5:1, and row boundaries meet 3:1, in dark, light and grey. Active tabs have explicit boundaries. Guidelines are in DESIGN.md. +- Browser runtime verifies session deletion, bulk controls hidden before selection, source-history separation, whole-window OpenCull layout after theme switching, both strip resize controls, and hide/show for both classic stack strips. +- Native aspect covers both the active-frame and scene overview strips. Decoded upright JPEG dimensions supply early proportions; full camera dimensions remain separate and supersede the preview ratio. +- This NVIDIA/Wayland test hit protocol error 71 with direct DMA-BUF. The narrower FORCE_SHM/DISABLE_GBM transport keeps the accelerated renderer enabled. An isolated CSS-only WebKit animation used the RTX 3060 Ti with 53 MiB allocated; no WebGL probe was required. NVIDIA profile requests hardware compositing/WebGL/available 2D canvas acceleration and uses this transport. CPU decoding remains separate. No zoom-performance gain is claimed without representative timings. +- The earlier combined macOS Apple-silicon build passed. Fork main's new macOS dev startup smoke passed, checking the native process and Vite frontend. Final combined commit CI is still being checked; Intel Mac runtime is not covered. + + +## Final native NVIDIA check + +The actual OpenCull WebKit process (not an isolated WebGL probe) was observed using the RTX 3060 Ti with a 311 MiB graphics allocation while rendering real RAW previews. No failed previews were found in the inspected sample. This uses FORCE_SHM/DISABLE_GBM presentation, keeping hardware compositing enabled. The broad WEBKIT_DISABLE_DMABUF_RENDERER flag is cleared only for this opt-in profile. Native Linux desktop is running via npm run dev:desktop:nvidia. GPU acceleration does not imply CPU image decoding or AI inference has moved to GPU. + +Additional feature PRs: [Wisp theme](https://github.com/wispdevon/OpenCull/pull/19), [resizable stack strips](https://github.com/wispdevon/OpenCull/pull/20), [NVIDIA rendering](https://github.com/wispdevon/OpenCull/pull/21). Wisp depends on the RapidRAW preset infrastructure; the resize and NVIDIA branches start from upstream. The combined macOS dev-startup smoke at source fb2a83a passed; final source 4b7316d CI is running after the Linux-only transport correction and diagnostics clarification. diff --git a/docs/features/appearance-modes.md b/docs/features/appearance-modes.md new file mode 100644 index 0000000..a3d6cd0 --- /dev/null +++ b/docs/features/appearance-modes.md @@ -0,0 +1,5 @@ +# Appearance modes + +Settings → Culling → Appearance selects Dark (unchanged default), Light, or 18% grey. Selection persists and applies immediately. The neutral grey canvas is sRGB #767676, approximately 18% linear luminance; panels use neutral darker tones. Interface neutrals use semantic CSS roles while image pixels and rating colours remain unchanged. Native controls follow the explicit colour scheme. + +Verify all three modes on home, card ingest, culling, settings, menus, and dialogs, including a small desktop window. Run frontend unit tests and production build. No other feature branch is required. diff --git a/docs/features/configurable-ui.md b/docs/features/configurable-ui.md new file mode 100644 index 0000000..969a21c --- /dev/null +++ b/docs/features/configurable-ui.md @@ -0,0 +1,5 @@ +# Configurable workspace + +Settings → Layout controls filmstrip, face panel, EXIF panel, histogram, information overlay, rating bar, and filter bar. Size controls share the existing persisted dimensions with drag handles; side panels remain constrained to the workspace. Defaults and shortcuts are unchanged. + +Validation: frontend unit suite and production build; visually check small windows and restarting after a layout change. No other feature branch is required. diff --git a/docs/features/cull-before-ingest.md b/docs/features/cull-before-ingest.md new file mode 100644 index 0000000..61d8513 --- /dev/null +++ b/docs/features/cull-before-ingest.md @@ -0,0 +1,9 @@ +# Cull before importing + +Ingest from Card → select a source → Start Culling opens the existing workspace directly on the card. Review keeps ratings, flags, colours and crops in a local saved session; source XMP and embedded JPEG writes are bypassed even when normally enabled. Resume card session on Home restores the latest session; disconnected cards must be reconnected. + +Finish review initially selects all non-rejects, with All/None and individual selection. Copy selected files uses the existing staged, size-checked transfer path with collision renaming and writes marks/crops only at the destination. A RAW and its JPEG travel together. Completed copies are recorded for retry after cancellation or failure; retries retain the chosen destination. Import in place opens only chosen members without copying or source writes. Sessions live in the native application data directory, separately from configuration. + +Move and Ship cannot bypass this workflow. Deleting remains an explicit source action and always asks during card review. Reject never deletes. Ordinary folder culling retains its existing sidecar behavior. + +Validation: frontend tests include local-only marks/crops/undo and forced deletion confirmation. Rust tests check unchanged source bytes, destination sidecars, paired copying, source-destination refusal, and session persistence. Run `npm test`, `npm run build`, and a native desktop smoke test. This branch works independently; integration with multi-card selection is separate. diff --git a/docs/features/font-selection.md b/docs/features/font-selection.md new file mode 100644 index 0000000..0783f3a --- /dev/null +++ b/docs/features/font-selection.md @@ -0,0 +1,5 @@ +# Interface fonts + +Settings → Culling → Interface font offers System (unchanged default), bundled Space Grotesk, or a single installed font family. Selection previews and applies immediately and persists. Space Grotesk is included under the SIL Open Font License in `public/fonts/SpaceGrotesk-OFL.txt`; no network font service is used. Technical values retain monospace styling. + +Verify with `npm run test:web`, `npm run build`, and a restart with Space Grotesk and an unavailable custom font selected. This branch requires no other feature. diff --git a/docs/features/ingest-subfolders.md b/docs/features/ingest-subfolders.md new file mode 100644 index 0000000..0e98edd --- /dev/null +++ b/docs/features/ingest-subfolders.md @@ -0,0 +1,18 @@ +# Choose a specific folder on a card + +Branch: `feature/ingest-subfolders`, based directly on upstream `a8c7f32`. + +Opening a card now browses its folders without selecting the whole card. +Open `CARD → DCIM → 100CANON` to select that leaf folder. Camera directory +names no longer stop navigation. Mixed folders containing both photographs +and subfolders can also be browsed. **Use this folder** explicitly selects +the currently displayed parent when a whole directory is wanted. +The system **Browse Folder** picker follows the same browse-first rule. + +This changes source selection only. It works independently of multi-card, +culling-session, and appearance features. Original files are untouched. + +Validation: 31 frontend tests and TypeScript/Vite build passed. Browser runtime +check verified zero scan requests while opening CARD/DCIM, a single scan of +`/DCIM/100CANON` when selected, and an explicit DCIM scan with **Use this folder**. +No browser JavaScript errors were observed. diff --git a/docs/features/multi-card-session.md b/docs/features/multi-card-session.md new file mode 100644 index 0000000..b5a55cf --- /dev/null +++ b/docs/features/multi-card-session.md @@ -0,0 +1,9 @@ +# Multiple cards, one shoot + +Ingest from Card now accumulates selected cards/folders in a removable source list. All selected sources are scanned recursively, including nested DCIM folders, and displayed in one combined selection with source labels. Overlapping folders do not duplicate photos. Removed/disconnected sources are reported before copying. + +A cross-source RAW/JPEG pair requires one RAW and one JPEG with matching filename stem, camera make/model, and capture timestamp. Missing metadata, differing camera/time, or multiple candidates keep entries separate. Existing same-folder pairs remain intact. The JPEG supplies the preview and both files travel together under the existing collision/cancellation rules. + +Standalone: copies the selected combined shoot through existing ingest and opens the destination. Integration with cull-before-ingest is implemented separately, so this feature has no prerequisite branch. + +Validation: `npm test`, `npm run build`; Rust tests cover cross-card pairs, ambiguous/missing metadata, differing timestamps/cameras, overlapping roots, and disconnected sources. Native checks should include actual JPEG-only and RAW-only cards. diff --git a/docs/features/rapidraw-theme.md b/docs/features/rapidraw-theme.md new file mode 100644 index 0000000..7b0a00e --- /dev/null +++ b/docs/features/rapidraw-theme.md @@ -0,0 +1,29 @@ +# RapidRAW inspired design preset + +Branch: `feature/rapidraw-theme`, based directly on upstream `a8c7f32`. +No other feature branch is required. Select **Settings → Culling → Design theme +→ RapidRAW inspired**. OpenCull's existing design remains the default. + +The reference is RapidRAW's first library screen at +[`MainLibrary.tsx`](https://github.com/CyberTimon/RapidRAW/blob/e90a9bba7422ed833892e4945f06da35a65470d2/src/components/panel/MainLibrary.tsx): +a split image/action layout, prominent identity, neutral surfaces, and direct +session actions. This is an original implementation using OpenCull's existing +folder, settings, keyboard, card detection, and recent-folder controls. +No RapidRAW code, photographs, or logos are distributed. + +The image panel uses the latest available recent folder's local preview, or +bundled original SVG artwork. Small windows show the action panel alone. +Space Grotesk is bundled offline under its SIL Open Font License. The preset +includes that font, with an option to use the normal interface font instead. +Selecting another design removes the preset font override without changing +the separate interface-font preference. + +Preferences `visualTheme` and `visualThemeFont` use the shared preferences API, +so they also persist through the optional XDG configuration feature. +With appearance modes installed, dark, light, and 18% grey remain independent +of the design preset. Rating colours and photograph pixels are unchanged. + +Validation: 33 frontend tests and production TypeScript/Vite build passed. +Chromium runtime checks cover selecting the theme, persistence after reload, +font override opt-out, returning to the default design, and desktop/small +window layouts with no horizontal overflow or JavaScript errors. diff --git a/docs/features/ui-changes.md b/docs/features/ui-changes.md new file mode 100644 index 0000000..0b6879d --- /dev/null +++ b/docs/features/ui-changes.md @@ -0,0 +1,22 @@ +# Clearer UI headings and ingest instructions + +Branch: `feature/ui-changes`. Prerequisite: `feature/cull-before-ingest`. +This branch starts from that feature because the moved **Review first** +instruction describes its cull-before-copy workflow. Review its PR against +`feature/cull-before-ingest` to see only the UI changes. + +- Ingest uses a semantic 24px heading. The 14px **Review first** instruction + sits immediately below it, above the whole workspace, rather than in the + destination sidebar. Header height follows the wrapped instruction text. +- Home branding increases to 32px, recent-folder headings to 18px, the current + culling folder to 16px, and shared dialog/confirmation titles to 20px. +- Source section labels increase to 14px, with 12px card/drive subheadings. +- The unused destination sidebar is removed during card review. Previews use + the available width, with **Start Culling** in a bottom action bar. In the + combined app, the video inclusion toggle remains in Settings. + +These changes are independent of the optional design theme. Actions, photo +selection, keyboard shortcuts, and file operations retain their behavior. + +Validation: frontend tests, TypeScript/Vite build, and browser runtime checks +of the ingest heading/instruction placement and shared dialog title sizes. diff --git a/docs/features/video-culling.md b/docs/features/video-culling.md new file mode 100644 index 0000000..17bcaec --- /dev/null +++ b/docs/features/video-culling.md @@ -0,0 +1,9 @@ +# Video culling + +Ignore videos is enabled initially, in Settings → Culling and the card browser. Disable it to list camera video containers alongside photos, keep/reject/rate them, copy them, and play supported codecs in the loupe. Native playback controls provide pause, seek, and volume; their keyboard events stay with the player. Unsupported codecs show a message and remain selectable for import. No transcoding or codec downloads are performed. + +Playback uses a bounded, token-protected loopback stream on Linux because WebKitGTK's media backend rejects custom asset schemes. Other platforms use Tauri's range-capable asset protocol, with an empty initial scope. Only a video already belonging to an open session is granted individual file access. Videos use a thumbnail icon and skip image preview warming, histogram processing, and face analysis. Normal folder/video sidecars follow existing mark behavior; integration with source-safe card review is separate. + +Linux playback depends on system WebKitGTK/GStreamer codec support. Formats are recognized as containers, not promises that every codec inside them will decode. + +Validation: `npm test`, `npm run build`, native desktop smoke checks with a playable video, unsupported video, video-only source folder, seeking, and source-copy preservation. No other feature branch is required. Tauri asset protocol reference: https://v2.tauri.app/reference/javascript/api/namespacecore/#convertfilesrc diff --git a/docs/features/view-mode-preferences.md b/docs/features/view-mode-preferences.md new file mode 100644 index 0000000..a988ff7 --- /dev/null +++ b/docs/features/view-mode-preferences.md @@ -0,0 +1,24 @@ +# Default and remembered view modes + +Branch: `feature/view-mode-preferences`, directly based on upstream `a8c7f32`. +No other feature is required. + +**Settings → Culling → View mode** provides a default opening view: +Filmstrip (Loupe), Stack (Scene), or Grid. **Remember last view** is enabled by +default. With it enabled, toolbar, keyboard, and other view changes persist +across folders and restarts. With it disabled, each folder opens in the chosen +default, while the current folder can still be switched freely. + +First use still starts in Loupe. Invalid saved modes fall back to the valid +default. Changing the default does not interrupt the current culling view. +Filmstrip panel visibility remains its existing separate setting. + +The shared preferences API stores `defaultViewMode`, `rememberViewMode`, and +`viewMode`. Their mode values are `loupe`, `scene`, or `grid`. With XDG settings +installed they are written into the native JSON configuration automatically. +In the combined integration, these controls live under **Settings → Layout**. + +Validation: 35 standalone frontend tests and TypeScript/Vite production build. +Browser runtime checks cover every toolbar mode across folder changes and +page reloads, configured defaults with remembering disabled, and keyboard +changes saved to preferences. No JavaScript errors were observed. diff --git a/docs/features/xdg-config.md b/docs/features/xdg-config.md new file mode 100644 index 0000000..469fdb2 --- /dev/null +++ b/docs/features/xdg-config.md @@ -0,0 +1,13 @@ +# Native configuration + +Linux settings are stored at `$XDG_CONFIG_HOME/opencull/config.json`, or `~/.config/opencull/config.json` when XDG_CONFIG_HOME is unset or not absolute. Other platforms use the native Tauri application config directory. The file is a JSON object of existing preference keys. Unknown keys survive writes. Restart after editing manually. + +Startup migrates webview preferences only into missing keys, then imports the application so stores see file values immediately. Existing file values win. Writes are serialized and use a synced temporary file and rename; malformed or unreadable files are left untouched and reported visibly. Changes remain in webview storage if native initialization fails. `session.*` manifests remain separate from preferences. + +Personal settings, when the corresponding independent features are installed: + +```json +{"theme":"grey","font":{"choice":"space-grotesk","family":""},"ignoreVideos":true} +``` + +Run `npm test`, `npm run build`, and desktop startup tests with an XDG override, missing directory, malformed file, and an unwritable parent. This feature does not require appearance or font branches. diff --git a/index.html b/index.html index 43c5b34..fbb5e9f 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + OpenCull diff --git a/package.json b/package.json index 398fbee..653af39 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "pretest:web": "npm run check:node", "prebuild:web": "npm run check:node", "predev": "npm run check:node", - "check": "npm run test:web && npm run build:web && npm run test:rust" + "check": "npm run test:web && npm run build:web && npm run test:rust", + "dev:desktop:nvidia": "node scripts/dev-graphics.mjs nvidia", + "dev:desktop:compatibility": "node scripts/dev-graphics.mjs compatibility", + "dev:desktop:software": "node scripts/dev-graphics.mjs software" }, "dependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/public/fonts/SpaceGrotesk-OFL.txt b/public/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 0000000..cb512b9 --- /dev/null +++ b/public/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/SpaceGrotesk.ttf b/public/fonts/SpaceGrotesk.ttf new file mode 100644 index 0000000..a1b2e6c Binary files /dev/null and b/public/fonts/SpaceGrotesk.ttf differ diff --git a/public/theme-landscape.svg b/public/theme-landscape.svg new file mode 100644 index 0000000..b73d4da --- /dev/null +++ b/public/theme-landscape.svg @@ -0,0 +1,14 @@ + + Mountain ridges at dusk + + + + + + + + + + + + diff --git a/scripts/dev-graphics.mjs b/scripts/dev-graphics.mjs new file mode 100644 index 0000000..cbabba6 --- /dev/null +++ b/scripts/dev-graphics.mjs @@ -0,0 +1,10 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +const profile = process.argv[2]; +if (!["nvidia", "compatibility", "software"].includes(profile) || process.platform !== "linux") { + console.error("Graphics profiles are Linux-only. Use npm run dev:desktop for native macOS or Windows rendering."); process.exit(1); +} +const cli = fileURLToPath(new URL("../node_modules/@tauri-apps/cli/tauri.js", import.meta.url)); +const child = spawn(process.execPath, [cli, "dev", ...process.argv.slice(3)], { stdio: "inherit", env: { ...process.env, OPENCULL_GRAPHICS: profile } }); +child.on("error", (e) => { console.error(e.message); process.exitCode = 1; }); +child.on("exit", (code) => { process.exitCode = code ?? 1; }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 714ec29..0726dde 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1623,6 +1623,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -4043,6 +4049,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni 0.21.1", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1796562..c7cfada 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,7 +36,7 @@ turbo-system = ["dep:turbojpeg", "turbojpeg/pkg-config"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a553e9..11efb0f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:webview:allow-set-webview-zoom", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", "core:window:allow-internal-toggle-maximize", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 97e84ce..a97a5ff 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -224,7 +224,7 @@ const TIME_BUDGET: Duration = Duration::from_millis(1500); /// on screen before either — but the capture times are read here, by the /// short route, because the order of the folder depends on them. #[tauri::command] -pub async fn open_folder(app: AppState<'_>, path: String, include_subfolders: bool, kinds: Option) -> Result { +pub async fn open_folder(app: AppState<'_>, path: String, include_subfolders: bool, kinds: Option, members: Option>, ignore_videos: Option) -> Result { let kinds = kinds.unwrap_or_default(); let folder = PathBuf::from(&path); if !folder.is_dir() { @@ -234,7 +234,18 @@ pub async fn open_folder(app: AppState<'_>, path: String, include_subfolders: bo let scanned_folder = folder.clone(); let (result, read) = tauri::async_runtime::spawn_blocking(move || { let folder = scanned_folder; - let result = scan::scan_folder_of(&folder, include_subfolders, kinds); + let result = match members { + Some(images) => { + for image in &images { + if !image.path.is_file() || !image.display.is_file() { return Err(format!("Reconnect the source containing {}", image.name)); } + let meta = image.path.metadata().map_err(|e| e.to_string())?; + let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64).unwrap_or(0); + if meta.len() != image.size || modified != image.modified { return Err(format!("{} changed since this session was saved. Start a new card review to avoid applying marks to a different file.", image.name)); } + } + scan::ScanResult { images, subfolder_count: 0, left_out: 0 } + } + None => scan::scan_folder_media(&folder, include_subfolders, kinds, !ignore_videos.unwrap_or(true)), + }; let known = shared.times.load(&folder); let deadline = Instant::now() + TIME_BUDGET; @@ -246,7 +257,7 @@ pub async fn open_folder(app: AppState<'_>, path: String, include_subfolders: bo .par_iter() .map(|image| { times::remembered(&known, image) - .or_else(|| (Instant::now() < deadline).then(|| raw::capture_time(&image.path))) + .or_else(|| (Instant::now() < deadline).then(|| if scan::is_video(&raw::extension(&image.path)) { None } else { raw::capture_time(&image.path) })) }) .collect() }); @@ -255,10 +266,10 @@ pub async fn open_folder(app: AppState<'_>, path: String, include_subfolders: bo if learned { shared.times.save(&folder, &result.images, &read); } - (result, read) + Ok((result, read)) }) .await - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())??; let timed = read.iter().all(Option::is_some); let times: Vec> = read.into_iter().map(Option::flatten).collect(); @@ -309,6 +320,9 @@ pub enum MetaEvent { } fn read_meta(index: usize, image: &Scanned) -> Meta { + if scan::is_video(&raw::extension(&image.path)) { + return Meta { index, marks: xmp::read(&image.path, None), exif: Exif::default(), full_width: 0, full_height: 0, framing: framing::Framing::default(), framable: false }; + } let owner = raw::probe(&image.path).ok(); let sidecar = xmp::sidecar_text(&image.path); let marks = xmp::read_from(sidecar.as_deref(), owner.as_ref()); @@ -695,6 +709,7 @@ pub async fn ai_scan(app: AppState<'_>, session: u64, order: Vec, advance let mut ready = Vec::new(); for index in order { let Some(image) = session.images.get(index) else { continue }; + if scan::is_video(&raw::extension(&image.path)) { continue; } match ai::store::remembered(&known.lock().unwrap(), &image.display, &image.version) { Some(record) => ready.push(Analysed { index, record }), None => todo.push(index), @@ -848,10 +863,10 @@ pub struct Browsed { /// What is inside a folder on a card or drive, for the source browser. #[tauri::command] -pub async fn ingest_browse(path: String) -> Result { +pub async fn ingest_browse(path: String, ignore_videos: Option) -> Result { tauri::async_runtime::spawn_blocking(move || { let path = Path::new(&path); - Browsed { folders: ingest::photo_folders(path), image_count: ingest::shallow_count(path) } + Browsed { folders: ingest::photo_folders_media(path, !ignore_videos.unwrap_or(true)), image_count: ingest::shallow_count_media(path, !ignore_videos.unwrap_or(true)) } }) .await .map_err(|e| e.to_string()) @@ -859,8 +874,8 @@ pub async fn ingest_browse(path: String) -> Result { /// The photos a folder on a card has to offer. #[tauri::command] -pub async fn ingest_scan(path: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || ingest::scan(Path::new(&path))).await.map_err(|e| e.to_string()) +pub async fn ingest_scan(path: String, ignore_videos: Option) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || ingest::scan_media(Path::new(&path), !ignore_videos.unwrap_or(true))).await.map_err(|e| e.to_string()) } #[derive(Serialize, Clone)] @@ -1093,3 +1108,96 @@ pub async fn trim_cache(app: AppState<'_>, limit: u64) -> Result { let cache = app.previews.cache.clone(); tauri::async_runtime::spawn_blocking(move || cache.trim(limit)).await.map_err(|e| e.to_string()) } + +#[derive(Deserialize)] +pub struct ReviewCopyItem { + path: PathBuf, display: PathBuf, marks: Marks, + framing: framing::Framing, upright: (u32, u32), +} + +fn copy_review_items(items: &[ReviewCopyItem], sources: &[PathBuf], destination: &Path, cancel: &AtomicBool, progress: impl Fn(usize)) -> Result<(transfer::Outcome, HashMap), String> { + let destination = destination.canonicalize().map_err(|e| e.to_string())?; + for source in sources { + let root = source.canonicalize().map_err(|e| format!("Reconnect {}: {e}", source.display()))?; + if destination.starts_with(root) { return Err("Choose a destination outside the source cards".into()); } + } + let mut outcome = transfer::Outcome::default(); + let mut completed = HashMap::new(); + for (i, item) in items.iter().enumerate() { + if cancel.load(Ordering::SeqCst) { break; } + let job = transfer::Job { owner: item.path.clone(), companions: if item.display != item.path { vec![item.display.clone()] } else { vec![] } }; + let mut result = transfer::run(&[job], &destination, false, transfer::Conflict::KeepBoth, |_| {}); + if result.failed.is_empty() { + if let Some(target) = result.placed.first() { + if let Err(e) = xmp::write(target, item.marks) { result.sidecar_failed.push(format!("{}: {e}", target.display())); } + if !item.framing.is_plain() { + let wanted = framing::Wanted { framing: item.framing, upright: item.upright, seed: item.marks }; + if let Err(e) = framing::write(target, Some(item.marks), &wanted) { result.sidecar_failed.push(format!("{}: {e}", target.display())); } + } + } + } + if result.failed.is_empty() && result.sidecar_failed.is_empty() { + if let Some(target) = result.placed.first() { completed.insert(item.path.to_string_lossy().into_owned(), target.to_string_lossy().into_owned()); } + } + outcome.placed.extend(result.placed); outcome.failed.extend(result.failed); outcome.sidecar_failed.extend(result.sidecar_failed); + progress(i + 1); + } + Ok((outcome, completed)) +} + +#[derive(Serialize)] +pub struct ReviewCopied { #[serde(flatten)] result: Shipped, completed: HashMap } + +#[tauri::command] +pub async fn review_copy(app: AppState<'_>, items: Vec, sources: Vec, destination: String, progress: Channel) -> Result { + let app = app.inner().clone(); + if app.ingesting.swap(true, Ordering::SeqCst) { return Err("A copy is already running".into()); } + let running = Ingesting(app.clone()); + let cancel = Arc::new(AtomicBool::new(false)); *app.ingest_cancel.lock().unwrap() = cancel.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _running = running; + copy_review_items(&items, &sources, Path::new(&destination), &cancel, |done| { progress.send(done).ok(); }).map(|(result, completed)| ReviewCopied { result: result.into(), completed }) + }).await.map_err(|e| e.to_string())? +} + +#[cfg(test)] mod review_tests { + use super::*; + #[test] fn review_copy_keeps_sources_unchanged_and_writes_destination_marks() { + let root = std::env::temp_dir().join(format!("opencull-review-copy-{}", std::process::id())); + let source = root.join("card"); let dest = root.join("dest"); std::fs::create_dir_all(&source).unwrap(); std::fs::create_dir_all(&dest).unwrap(); + let raw = source.join("A.RAW"); let jpeg = source.join("A.JPG"); + std::fs::write(&raw, b"raw bytes").unwrap(); std::fs::write(&jpeg, b"jpeg bytes").unwrap(); + let marks = Marks { rating: 4, ..Default::default() }; + let item = ReviewCopyItem { path: raw.clone(), display: jpeg.clone(), marks, framing: framing::Framing::default(), upright: (0, 0) }; + let (result, completed) = copy_review_items(&[item], &[source.clone()], &dest, &AtomicBool::new(false), |_| {}).unwrap(); + assert!(result.failed.is_empty()); assert!(result.sidecar_failed.is_empty()); assert_eq!(completed.len(), 1); + assert_eq!(std::fs::read(raw).unwrap(), b"raw bytes"); assert_eq!(std::fs::read(jpeg).unwrap(), b"jpeg bytes"); + assert!(!source.join("A.xmp").exists()); assert_eq!(xmp::read(&dest.join("A.RAW"), None).rating, 4); + assert_eq!(std::fs::read_dir(&source).unwrap().count(), 2); std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn destination_on_a_source_card_is_refused() { + let root = std::env::temp_dir().join(format!("opencull-review-same-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); + assert!(copy_review_items(&[], &[root.clone()], &root, &AtomicBool::new(false), |_| {}).is_err()); std::fs::remove_dir_all(root).unwrap(); + } +} + +/// Selected cards are scanned as one shoot; disconnected sources are reported. +#[tauri::command] +pub async fn ingest_sources(sources: Vec, ignore_videos: Option) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || crate::multicard::scan_sources_media(&sources, !ignore_videos.unwrap_or(true))).await.map_err(|e| e.to_string())? +} + +/// Grant only the requested video already belonging to an open session. +#[tauri::command] +pub fn video_source(handle: tauri::AppHandle, app: AppState, path: String) -> Result { + use tauri::Manager; + let requested = PathBuf::from(path).canonicalize().map_err(|e| e.to_string())?; + if !scan::is_video(&raw::extension(&requested)) { return Err("Not a supported video container".into()); } + let sessions = app.sessions.lock().map_err(|e| e.to_string())?; + if !sessions.values().any(|s| s.images.iter().any(|p| p.path.canonicalize().ok().as_ref() == Some(&requested))) { return Err("Video is not in the selected session".into()); } + handle.asset_protocol_scope().allow_file(&requested).map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] + { crate::video_stream::url(requested) } + #[cfg(not(target_os = "linux"))] + { Ok(requested.to_string_lossy().into_owned()) } +} diff --git a/src-tauri/src/graphics.rs b/src-tauri/src/graphics.rs new file mode 100644 index 0000000..86ef5f0 --- /dev/null +++ b/src-tauri/src/graphics.rs @@ -0,0 +1,54 @@ +//! Renderer profiles are applied before GTK/WebKit is initialized. +use serde::Serialize; +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Profile { Auto, Nvidia, Compatibility, Software } +impl Profile { + fn parse(value: &str) -> Option { + match value { "auto" => Some(Self::Auto), "nvidia" => Some(Self::Nvidia), "compatibility" => Some(Self::Compatibility), "software" => Some(Self::Software), _ => None } + } +} +fn choose(args: impl Iterator, environment: Option) -> Profile { + let requested = args.filter_map(|s| s.strip_prefix("--graphics=").map(str::to_owned)).last().or(environment); + requested.as_deref().and_then(Profile::parse).unwrap_or(Profile::Auto) +} +pub fn initialize() -> Profile { + let profile = choose(std::env::args().skip(1), std::env::var("OPENCULL_GRAPHICS").ok()); + #[cfg(target_os="linux")] + match profile { + Profile::Nvidia => { + // Keep accelerated compositing; only replace the failing GBM + // buffer transport. Disabling the whole renderer would disable GPU composition. + for name in ["WEBKIT_DISABLE_DMABUF_RENDERER", "WEBKIT_DISABLE_COMPOSITING_MODE", "LIBGL_ALWAYS_SOFTWARE"] { std::env::remove_var(name); } + std::env::set_var("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1"); + std::env::set_var("WEBKIT_DMABUF_RENDERER_DISABLE_GBM", "1"); + if std::env::var_os("GDK_BACKEND").is_none() && std::env::var_os("DISPLAY").is_some() { std::env::set_var("GDK_BACKEND", "x11"); } + std::env::set_var("__GLX_VENDOR_LIBRARY_NAME", "nvidia"); + }, + Profile::Compatibility => { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); }, + Profile::Software => { std::env::set_var("LIBGL_ALWAYS_SOFTWARE", "1"); std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); }, + Profile::Auto => {}, + } + eprintln!("[graphics] requested profile: {profile:?}"); + profile +} +#[derive(Serialize)] +#[serde(rename_all="camelCase")] +pub struct GraphicsInfo { requested: Profile, platform: &'static str, dmabuf_disabled: bool, software_forced: bool, shared_memory_transport: bool } +#[tauri::command] +pub fn graphics_info(profile: tauri::State) -> GraphicsInfo { + GraphicsInfo { requested: *profile, platform: std::env::consts::OS, + dmabuf_disabled: std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").as_deref() == Ok("1"), + software_forced: std::env::var("LIBGL_ALWAYS_SOFTWARE").as_deref() == Ok("1"), + shared_memory_transport: std::env::var("WEBKIT_DMABUF_RENDERER_FORCE_SHM").as_deref() == Ok("1") } +} +#[cfg(test)] mod tests { + use super::*; + #[test] fn profiles_are_opt_in_and_cli_overrides_environment() { + assert_eq!(choose(std::iter::empty(), None),Profile::Auto); + assert_eq!(choose(std::iter::empty(),Some("nvidia".into())),Profile::Nvidia); + assert_eq!(choose(["--graphics=software".into()].into_iter(),Some("nvidia".into())),Profile::Software); + assert_eq!(choose(["/Photos/nvidia".into()].into_iter(),None),Profile::Auto); + assert_eq!(choose(std::iter::empty(),Some("bad".into())),Profile::Auto); + } +} diff --git a/src-tauri/src/ingest.rs b/src-tauri/src/ingest.rs index b117b41..d892557 100644 --- a/src-tauri/src/ingest.rs +++ b/src-tauri/src/ingest.rs @@ -182,12 +182,13 @@ pub fn volumes() -> Vec { } /// How many photos sit directly in a folder. -pub fn shallow_count(folder: &Path) -> usize { +pub fn shallow_count(folder: &Path) -> usize { shallow_count_media(folder, false) } +pub fn shallow_count_media(folder: &Path, include_videos: bool) -> usize { let Ok(entries) = std::fs::read_dir(folder) else { return 0 }; entries .flatten() .filter(|e| e.file_name().to_str().is_some_and(|n| !hidden(n))) - .filter(|e| is_image(&extension(&e.path())) && e.file_type().is_ok_and(|k| !k.is_dir())) + .filter(|e| (is_image(&extension(&e.path())) || include_videos && scan::is_video(&extension(&e.path()))) && e.file_type().is_ok_and(|k| !k.is_dir())) .count() } @@ -204,26 +205,27 @@ fn subfolders(folder: &Path) -> Vec { /// Whether there is a photo anywhere beneath `folder`. Stops at the first: /// counting an archive drive's tens of thousands to answer yes would be slow /// for nothing. -fn contains_photos(folder: &Path, depth: usize) -> bool { - if shallow_count(folder) > 0 { +fn contains_photos(folder: &Path, depth: usize, include_videos: bool) -> bool { + if shallow_count_media(folder, include_videos) > 0 { return true; } - depth < 12 && subfolders(folder).iter().any(|sub| contains_photos(sub, depth + 1)) + depth < 12 && subfolders(folder).iter().any(|sub| contains_photos(sub, depth + 1, include_videos)) } /// Folders a system keeps on a drive that are never a photographer's. const SYSTEM_FOLDERS: &[&str] = &["system volume information", "$recycle.bin", "trash"]; /// The folders directly inside `directory` that have photos somewhere in them. -pub fn photo_folders(directory: &Path) -> Vec { +pub fn photo_folders(directory: &Path) -> Vec { photo_folders_media(directory, false) } +pub fn photo_folders_media(directory: &Path, include_videos: bool) -> Vec { let mut found: Vec = subfolders(directory) .into_iter() .filter_map(|path| { let name = path.file_name()?.to_str()?.to_string(); - if SYSTEM_FOLDERS.contains(&name.to_lowercase().as_str()) || !contains_photos(&path, 0) { + if SYSTEM_FOLDERS.contains(&name.to_lowercase().as_str()) || !contains_photos(&path, 0, include_videos) { return None; } - Some(Folder { image_count: shallow_count(&path), name, path }) + Some(Folder { image_count: shallow_count_media(&path, include_videos), name, path }) }) .collect(); found.sort_by(|a, b| natural_cmp(&a.name, &b.name)); @@ -232,10 +234,11 @@ pub fn photo_folders(directory: &Path) -> Vec { /// The photos to offer from `folder`: those directly in it, or, when it holds /// only folders — a card's DCIM — everything beneath. -pub fn scan(folder: &Path) -> Vec { - let direct = scan::scan_folder(folder, false); +pub fn scan(folder: &Path) -> Vec { scan_media(folder, false) } +pub fn scan_media(folder: &Path, include_videos: bool) -> Vec { + let direct = scan::scan_folder_media(folder, false, scan::Kinds::Both, include_videos); if direct.images.is_empty() && direct.subfolder_count > 0 { - scan::scan_folder(folder, true).images + scan::scan_folder_media(folder, true, scan::Kinds::Both, include_videos).images } else { direct.images } @@ -244,6 +247,7 @@ pub fn scan(folder: &Path) -> Vec { /// What a photo's own file says about it: when it was taken, in milliseconds, /// and the stars given in the camera. pub fn details(photo: &Path) -> (Option, u8) { + if scan::is_video(&extension(photo)) { return (None, xmp::read(photo, None).rating); } let probe = raw::probe(photo).ok(); let taken = probe.as_ref().and_then(|p| p.exif.capture_ms); (taken, xmp::read(photo, probe.as_ref()).rating) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6bb84b0..b68cfab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,17 +12,23 @@ pub mod editors; pub mod embed; pub mod faces; pub mod framing; +pub mod graphics; pub mod ingest; +pub mod multicard; pub mod pool; pub mod preview; pub mod raw; +pub mod review; pub mod scan; pub mod serve; pub mod sidecar; +pub mod settings; pub mod times; pub mod transfer; pub mod update; pub mod xmp; +#[cfg(target_os = "linux")] +pub mod video_stream; use std::sync::Arc; @@ -34,6 +40,7 @@ use serve::Previews; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let graphics = graphics::initialize(); let launch = Launch::from_args(); let mut builder = tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()).plugin(tauri_plugin_updater::Builder::new().build()); if !launch.self_test { @@ -45,6 +52,7 @@ pub fn run() { builder = builder.plugin(tauri_plugin_window_state::Builder::default().with_state_flags(StateFlags::all() & !StateFlags::VISIBLE).build()); } builder + .manage(graphics) .setup(move |app| { // The dev page is HTTP, while previews use a custom scheme. // WebKitGTK otherwise rejects those cross-origin image requests. @@ -53,6 +61,21 @@ pub fn run() { use webkit2gtk::WebViewExt; webview.inner().set_cors_allowlist(&["opencull://localhost/*"]); })?; + #[cfg(target_os = "linux")] + if graphics != graphics::Profile::Auto { + app.get_webview_window("main").expect("main window").with_webview(move |webview| { + use webkit2gtk::{SettingsExt, WebViewExt}; + use webkit2gtk::glib::prelude::ObjectExt; + if let Some(settings) = webview.inner().settings() { + let policy = if graphics == graphics::Profile::Software { webkit2gtk::HardwareAccelerationPolicy::Never } else { webkit2gtk::HardwareAccelerationPolicy::Always }; + settings.set_hardware_acceleration_policy(policy); + if graphics == graphics::Profile::Nvidia { + settings.set_enable_webgl(true); + if settings.find_property("enable-2d-canvas-acceleration").is_some() { settings.set_property("enable-2d-canvas-acceleration", true); } + } + } + })?; + } // A self-test keeps its previews apart, so a run measures making // them rather than finding the last run's. let (dir, times_dir, faces_dir) = if launch.self_test { @@ -104,6 +127,7 @@ pub fn run() { // painting. window.show().ok(); } + app.manage(settings::Settings(std::sync::Mutex::new(settings::location(app.handle())?))); app.manage(launch); // Bring the cache back under its limit, well away from startup. @@ -145,6 +169,15 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + review::review_save, + review::review_list, + review::review_remove, + review::review_load, + commands::review_copy, + settings::config_load, + settings::config_set, + commands::video_source, + graphics::graphics_info, commands::app_info, commands::report, commands::finish_self_test, @@ -175,6 +208,7 @@ pub fn run() { commands::ingest_volumes, commands::ingest_browse, commands::ingest_scan, + commands::ingest_sources, commands::ingest_details, commands::ingest_conflicts, commands::ingest_copy, diff --git a/src-tauri/src/multicard.rs b/src-tauri/src/multicard.rs new file mode 100644 index 0000000..a28ec7a --- /dev/null +++ b/src-tauri/src/multicard.rs @@ -0,0 +1,74 @@ +//! Combining independent source cards without guessing from names alone. +use std::{collections::{HashMap, HashSet}, path::{Path, PathBuf}}; +use crate::{raw, scan::{self, Scanned}}; + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct Facts { make: String, model: String, time: u64 } + +fn pair(mut images: Vec, facts: impl Fn(&Path) -> Option) -> Vec { + let mut groups: HashMap<(String, Facts), Vec> = HashMap::new(); + for (i, image) in images.iter().enumerate() { + if image.path != image.display || !(raw::is_raw(&image.path) || raw::is_jpeg(&image.path)) { continue; } + let Some(stem) = image.path.file_stem().and_then(|s| s.to_str()) else { continue }; + if let Some(meta) = facts(&image.path) { groups.entry((stem.to_ascii_lowercase(), meta)).or_default().push(i); } + } + let mut remove = HashSet::new(); + for group in groups.values() { + if group.len() != 2 { continue; } + let raws: Vec<_> = group.iter().copied().filter(|i| raw::is_raw(&images[*i].path)).collect(); + let jpegs: Vec<_> = group.iter().copied().filter(|i| raw::is_jpeg(&images[*i].path)).collect(); + if let ([r], [j]) = (raws.as_slice(), jpegs.as_slice()) { + images[*r].display = images[*j].path.clone(); images[*r].version = images[*j].version.clone(); remove.insert(*j); + } + } + images.into_iter().enumerate().filter_map(|(i, image)| (!remove.contains(&i)).then_some(image)).collect() +} + +pub fn scan_sources(sources: &[PathBuf]) -> Result, String> { scan_sources_media(sources, false) } +pub fn scan_sources_media(sources: &[PathBuf], include_videos: bool) -> Result, String> { + let mut seen = HashSet::new(); let mut images = Vec::new(); + for source in sources { + let root = source.canonicalize().map_err(|e| format!("{}: {e}", source.display()))?; + if !root.is_dir() { return Err(format!("{} is not a folder", root.display())); } + for mut image in scan::scan_folder_media(&root, true, scan::Kinds::Both, include_videos).images { + if !seen.insert(image.path.clone()) { continue; } + let label = root.file_name().unwrap_or_default().to_string_lossy(); + image.subpath = Some(match image.subpath { Some(sub) => format!("{label}/{sub}"), None => label.to_string() }); + images.push(image); + } + } + let paired: HashSet<_> = images.iter().filter(|p| p.path != p.display).map(|p| p.display.clone()).collect(); + images.retain(|p| !paired.contains(&p.path)); + Ok(pair(images, |path| { + let e = raw::probe(path).ok()?.exif; + let make = e.make?.trim().to_lowercase(); let model = e.model?.trim().to_lowercase(); + if make.is_empty() || model.is_empty() { return None; } + Some(Facts { make, model, time: e.capture_ms?.to_bits() }) + })) +} + +#[cfg(test)] mod tests { + use super::*; + fn image(path: &str) -> Scanned { Scanned { path: path.into(), display: path.into(), name: Path::new(path).file_name().unwrap().to_string_lossy().into_owned(), subpath: None, version: "1".into(), size: 4, modified: 0 } } + fn facts(_: &Path) -> Option { Some(Facts { make: "Sony".into(), model: "A7".into(), time: 1234 }) } + #[test] fn raw_and_jpeg_on_two_cards_become_one_frame() { + let result = pair(vec![image("/raw/A.ARW"), image("/jpeg/A.JPG")], facts); + assert_eq!(result.len(), 1); assert_eq!(result[0].display, Path::new("/jpeg/A.JPG")); + } + #[test] fn ambiguous_and_missing_metadata_are_never_paired() { + let images = vec![image("/r1/A.ARW"), image("/r2/A.ARW"), image("/j/A.JPG")]; + assert_eq!(pair(images, facts).len(), 3); + assert_eq!(pair(vec![image("/r/A.ARW"), image("/j/A.JPG")], |_| None).len(), 2); + } + #[test] fn different_camera_or_time_is_not_a_match() { + let files = vec![image("/r/A.ARW"), image("/j/A.JPG")]; + assert_eq!(pair(files.clone(), |p| { let mut f = facts(p)?; if raw::is_jpeg(p) { f.time += 1; } Some(f) }).len(), 2); + assert_eq!(pair(files, |p| { let mut f = facts(p)?; if raw::is_jpeg(p) { f.model = "Other".into(); } Some(f) }).len(), 2); + } + #[test] fn overlapping_sources_do_not_duplicate_files() { + let root = std::env::temp_dir().join(format!("opencull-multicard-{}", std::process::id())); + std::fs::create_dir_all(root.join("DCIM")).unwrap(); std::fs::write(root.join("DCIM/A.JPG"), b"fake").unwrap(); + let result = scan_sources(&[root.clone(), root.join("DCIM")]).unwrap(); assert_eq!(result.len(), 1); + assert!(scan_sources(&[root.join("missing")]).is_err()); std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/review.rs b/src-tauri/src/review.rs new file mode 100644 index 0000000..b7b657f --- /dev/null +++ b/src-tauri/src/review.rs @@ -0,0 +1,172 @@ +//! Saved card sessions live on this computer, never beside source photos. +use std::{fs, io::Write, path::{Path, PathBuf}, sync::Mutex}; +use serde_json::Value; +use tauri::Manager; +static WRITING: Mutex<()> = Mutex::new(()); +fn path(app: &tauri::AppHandle) -> Result { + Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("sessions/latest.json")) +} +fn save(path: &Path, value: &Value) -> Result<(), String> { + let parent = path.parent().ok_or("No session directory")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let tmp = parent.join(".latest.tmp"); + let result = (|| { + let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + let mut file = options.open(&tmp).map_err(|e| e.to_string())?; + file.write_all(&serde_json::to_vec(value).map_err(|e| e.to_string())?).and_then(|_| file.sync_all()).map_err(|e| e.to_string())?; + fs::rename(&tmp, path).map_err(|e| e.to_string()) + })(); + if result.is_err() { let _ = fs::remove_file(tmp); } + result +} +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +fn read(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|e| format!("Saved session could not be read: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} +fn save_history(latest: &Path, value: &Value) -> Result<(), String> { + let id = value.get("id").and_then(Value::as_str).filter(|id| valid_id(id) && *id != "latest").ok_or("Invalid session ID")?; + if value.get("version").and_then(Value::as_u64) != Some(1) || !value.get("items").is_some_and(Value::is_array) { return Err("Invalid session".into()); } + save(&latest.with_file_name(format!("{id}.json")), value)?; + save(latest, value) +} +fn history(latest: &Path) -> Result, String> { + let parent = latest.parent().ok_or("No session directory")?; + let entries = match fs::read_dir(parent) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.to_string()), + }; + let mut files: Vec<_> = entries.filter_map(Result::ok).filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { return None; } + let modified = entry.metadata().ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64; + Some((path, modified)) + }).collect(); + files.sort_by(|a, b| b.1.cmp(&a.1)); + let mut summaries = Vec::new(); + for (file, modified) in files { + let Ok(Some(value)) = read(&file) else { continue; }; + if value["version"] != 1 || !value["items"].is_array() || !value["sources"].is_array() { continue; } + let id = value["id"].as_str().filter(|id| valid_id(id)).unwrap_or("latest"); + if summaries.iter().any(|v: &Value| v["id"] == id) { continue; } + summaries.push(serde_json::json!({ + "id": id, "name": value["name"].as_str().unwrap_or("Card session"), + "sources": value["sources"], "count": value["items"].as_array().unwrap().len(), + "updatedAt": value["updatedAt"].as_u64().unwrap_or(modified), + "stage": value["stage"].as_str().unwrap_or("review"), "cover": value["items"][0], + })); + if summaries.len() == 24 { break; } + } + summaries.sort_by(|a,b| b["updatedAt"].as_u64().cmp(&a["updatedAt"].as_u64())); + Ok(summaries) +} +fn remove_history(latest: &Path, ids: &[String]) -> Result<(), String> { + if ids.iter().any(|id| !valid_id(id)) { return Err("Invalid session ID".into()); } + let current = read(latest)?; + let remove_latest = current.as_ref().is_some_and(|v| ids.iter().any(|id| id == v["id"].as_str().unwrap_or("latest"))); + for id in ids { + let file = latest.with_file_name(format!("{id}.json")); + match fs::remove_file(file) { Ok(()) => {}, Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, Err(e) => return Err(e.to_string()) } + } + if remove_latest { + match fs::remove_file(latest) { Ok(()) => {}, Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, Err(e) => return Err(e.to_string()) } + if let Some(next) = history(latest)?.first() { + let id = next["id"].as_str().ok_or("Invalid saved session")?; + if let Some(value) = read(&latest.with_file_name(format!("{id}.json")))? { save(latest, &value)?; } + } + } + Ok(()) +} +#[tauri::command] +pub fn review_remove(app: tauri::AppHandle, ids: Vec) -> Result<(), String> { + let _lock = WRITING.lock().map_err(|e| e.to_string())?; + remove_history(&path(&app)?, &ids) +} +#[tauri::command] +pub fn review_save(app: tauri::AppHandle, value: Value) -> Result<(), String> { + let _lock = WRITING.lock().map_err(|e| e.to_string())?; + save_history(&path(&app)?, &value) +} +#[tauri::command] +pub fn review_load(app: tauri::AppHandle, id: Option) -> Result, String> { + let _lock = WRITING.lock().map_err(|e| e.to_string())?; + let latest = path(&app)?; + if let Some(id) = id { + if !valid_id(&id) { return Err("Invalid session ID".into()); } + let file = latest.with_file_name(format!("{id}.json")); + if let Some(value) = read(&file)? { return Ok(Some(value)); } + let legacy = read(&latest)?; + return Ok(legacy.filter(|v| v["id"] == id)); + } + read(&latest) +} +#[tauri::command] +pub async fn review_list(app: tauri::AppHandle) -> Result, String> { + let latest = path(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let _lock = WRITING.lock().map_err(|e| e.to_string())?; + history(&latest) + }).await.map_err(|e| e.to_string())? +} +#[cfg(test)] mod tests { + use super::*; + #[test] fn removing_sessions_updates_latest_without_touching_source_files() { + let root = std::env::temp_dir().join(format!("opencull-remove-{}", std::process::id())); + let latest = root.join("sessions/latest.json"); fs::create_dir_all(&root).unwrap(); + let source = root.join("original.raw"); fs::write(&source, b"original photo").unwrap(); + for (id, updated) in [("a", 1), ("b", 2), ("c", 3)] { + save_history(&latest, &serde_json::json!({"version":1,"id":id,"sources":[source],"items":[{"path":source}],"updatedAt":updated})).unwrap(); + } + assert!(remove_history(&latest, &["../original".into(), "b".into()]).is_err()); + assert!(root.join("sessions/b.json").exists()); + remove_history(&latest, &["a".into()]).unwrap(); + assert_eq!(read(&latest).unwrap().unwrap()["id"], "c"); + remove_history(&latest, &["c".into()]).unwrap(); + assert_eq!(read(&latest).unwrap().unwrap()["id"], "b"); + assert_eq!(history(&latest).unwrap().len(), 1); + remove_history(&latest, &["b".into()]).unwrap(); + assert!(read(&latest).unwrap().is_none()); assert!(history(&latest).unwrap().is_empty()); + assert_eq!(fs::read(&source).unwrap(), b"original photo"); fs::remove_dir_all(root).unwrap(); + } + #[test] fn removing_legacy_session_clears_latest_alias() { + let root = std::env::temp_dir().join(format!("opencull-remove-legacy-{}", std::process::id())); + let latest = root.join("latest.json"); + save(&latest, &serde_json::json!({"version":1,"sources":[],"items":[]})).unwrap(); + remove_history(&latest, &["latest".into()]).unwrap(); + assert!(history(&latest).unwrap().is_empty());fs::remove_dir_all(root).unwrap(); + } + #[test] fn history_retains_independent_sessions_and_updates_them() { + let root = std::env::temp_dir().join(format!("opencull-history-{}", std::process::id())); + let latest = root.join("latest.json"); + for (id, updated) in [("a", 1), ("b", 2), ("a", 3)] { + save_history(&latest, &serde_json::json!({"version":1,"id":id,"name":id,"sources":["/card"],"items":[],"updatedAt":updated})).unwrap(); + } + let list = history(&latest).unwrap(); + assert_eq!(list.len(), 2); assert_eq!(list[0]["id"], "a"); assert_eq!(list[1]["id"], "b"); + assert_eq!(read(&root.join("b.json")).unwrap().unwrap()["id"], "b"); + assert!(save_history(&latest, &serde_json::json!({"version":1,"id":"../outside","items":[]})).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn legacy_latest_session_is_listed_without_rewriting_it() { + let root = std::env::temp_dir().join(format!("opencull-history-legacy-{}", std::process::id())); let latest = root.join("latest.json"); + save(&latest, &serde_json::json!({"version":1,"id":"old","sources":["/card"],"items":[]})).unwrap(); + let before = fs::read(&latest).unwrap(); assert_eq!(history(&latest).unwrap()[0]["id"], "old"); + assert_eq!(fs::read(&latest).unwrap(), before); fs::remove_dir_all(root).unwrap(); + } + #[test] fn session_is_stored_separately_and_replaced_whole() { + let root = std::env::temp_dir().join(format!("opencull-review-{}", std::process::id())); + let path = root.join("sessions/latest.json"); + save(&path, &serde_json::json!({"version":1,"items":[]})).unwrap(); + save(&path, &serde_json::json!({"version":1,"items":["chosen"]})).unwrap(); + let read: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(read["items"][0], "chosen"); assert!(!root.join("sessions/.latest.tmp").exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index d99bf62..ce0da67 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -16,8 +16,13 @@ pub(crate) fn is_image(ext: &str) -> bool { RAW_EXTENSIONS.contains(&ext) || JPEG_EXTENSIONS.contains(&ext) || ext == "png" } +/// Containers offered for playback; the webview decides codec support. +pub fn is_video(ext: &str) -> bool { + matches!(ext, "mp4" | "mov" | "m4v" | "avi" | "mkv" | "webm" | "mts" | "m2ts" | "mpg" | "mpeg" | "3gp") +} + /// One photo as the scan found it. -#[derive(Clone, Debug, Serialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Scanned { /// The file that owns the photo's marks and sidecar. @@ -125,7 +130,7 @@ struct Found { modified: u128, } -fn list(dir: &Path, recurse: bool, out: &mut Vec, subfolders: Option<&mut usize>) { +fn list(dir: &Path, recurse: bool, out: &mut Vec, subfolders: Option<&mut usize>, include_videos: bool) { let Ok(entries) = std::fs::read_dir(dir) else { return }; let mut dirs = Vec::new(); let mut count = 0usize; @@ -151,7 +156,7 @@ fn list(dir: &Path, recurse: bool, out: &mut Vec, subfolders: Option<&mut } let ext = extension(&path); - if !is_image(&ext) { + if !is_image(&ext) && !(include_videos && is_video(&ext)) { continue; } let Ok(meta) = entry.metadata() else { continue }; @@ -168,7 +173,7 @@ fn list(dir: &Path, recurse: bool, out: &mut Vec, subfolders: Option<&mut *total = count; } for sub in dirs { - list(&sub, true, out, None); + list(&sub, true, out, None, include_videos); } } @@ -182,9 +187,13 @@ pub fn scan_folder(folder: &Path, include_subfolders: bool) -> ScanResult { /// The same, listing only the kinds of file asked for. pub fn scan_folder_of(folder: &Path, include_subfolders: bool, kinds: Kinds) -> ScanResult { + scan_folder_media(folder, include_subfolders, kinds, false) +} + +pub fn scan_folder_media(folder: &Path, include_subfolders: bool, kinds: Kinds, include_videos: bool) -> ScanResult { let mut found = Vec::new(); let mut subfolder_count = 0; - list(folder, include_subfolders, &mut found, Some(&mut subfolder_count)); + list(folder, include_subfolders, &mut found, Some(&mut subfolder_count), include_videos); // Only one kind wanted: the other is dropped here, before anything is // paired, so that a frame is its own file and brings nothing with it. @@ -192,7 +201,7 @@ pub fn scan_folder_of(folder: &Path, include_subfolders: bool, kinds: Kinds) -> if kinds != Kinds::Both { let is_raw = |f: &Found| RAW_EXTENSIONS.contains(&f.ext.as_str()); let frame = |f: &Found| (f.path.parent().map(Path::to_path_buf).unwrap_or_default(), f.path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_lowercase()); - let wanted = |f: &Found| is_raw(f) == (kinds == Kinds::Raw); + let wanted = |f: &Found| is_video(&f.ext) || is_raw(f) == (kinds == Kinds::Raw); let shown: HashSet<(PathBuf, String)> = found.iter().filter(|f| wanted(f)).map(frame).collect(); left_out = found.iter().filter(|f| !wanted(f) && !shown.contains(&frame(f))).map(frame).collect::>().len(); found.retain(wanted); @@ -442,3 +451,23 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } } + +#[cfg(test)] mod media_tests { + use super::*; + #[test] fn videos_are_opt_in_and_never_pair_with_stills() { + let root = std::env::temp_dir().join(format!("opencull-media-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); + for name in ["A.MP4", "A.JPG", "B.MOV"] { std::fs::write(root.join(name), b"test media").unwrap(); } + assert_eq!(scan_folder(&root, false).images.len(), 1); + let result = scan_folder_media(&root, false, Kinds::Both, true); assert_eq!(result.images.len(), 3); + assert!(result.images.iter().all(|p| p.path == p.display)); + assert_eq!(scan_folder_media(&root, false, Kinds::Raw, true).images.len(), 2); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn video_only_card_folders_are_visible_when_enabled() { + let root = std::env::temp_dir().join(format!("opencull-video-only-{}", std::process::id())); + std::fs::create_dir_all(root.join("PRIVATE")).unwrap(); std::fs::write(root.join("PRIVATE/A.MTS"), b"test").unwrap(); + assert!(crate::ingest::photo_folders(&root).is_empty()); + assert_eq!(crate::ingest::photo_folders_media(&root, true).len(), 1); + assert_eq!(crate::ingest::scan_media(&root, true).len(), 1); std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..6ed94e5 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,101 @@ +//! Native preferences. File writes are serialized and replace atomically. +use std::{collections::BTreeMap, fs, io::Write, path::{Path, PathBuf}, sync::{Mutex, atomic::{AtomicU64, Ordering}}}; +use serde_json::Value; +use tauri::Manager; +type Values = BTreeMap; +static NEXT: AtomicU64 = AtomicU64::new(0); +pub struct Settings(pub Mutex); + +#[cfg(target_os = "linux")] +pub fn linux_path(xdg: Option<&Path>, home: &Path) -> PathBuf { + xdg.filter(|p| p.is_absolute()).map(Path::to_path_buf).unwrap_or_else(|| home.join(".config")).join("opencull/config.json") +} + +pub fn location(app: &tauri::AppHandle) -> Result { + #[cfg(target_os = "linux")] + { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + Ok(linux_path(std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).as_deref(), &home)) + } + #[cfg(not(target_os = "linux"))] + { Ok(app.path().app_config_dir().map_err(|e| e.to_string())?.join("config.json")) } +} + +fn read(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}; file left untouched", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Values::new()), + Err(e) => Err(format!("{}: {e}", path.display())), + } +} + +fn write(path: &Path, values: &Values) -> Result<(), String> { + let parent = path.parent().ok_or("Settings path has no parent")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let temp = parent.join(format!(".config-{}-{}.tmp", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + let result = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + let mut file = options.open(&temp).map_err(|e| e.to_string())?; + let bytes = serde_json::to_vec_pretty(values).map_err(|e| e.to_string())?; + file.write_all(&bytes).and_then(|_| file.sync_all()).map_err(|e| e.to_string())?; + fs::rename(&temp, path).map_err(|e| e.to_string()) + })(); + if result.is_err() { let _ = fs::remove_file(&temp); } + result +} + +pub fn migrate(path: &Path, legacy: Values) -> Result { + let mut values = read(path)?; + for (key, value) in legacy { values.entry(key).or_insert(value); } + write(path, &values)?; + Ok(values) +} + +#[tauri::command] +pub fn config_load(settings: tauri::State, legacy: Values) -> Result { + let path = settings.0.lock().map_err(|e| e.to_string())?; + migrate(&path, legacy) +} + +#[tauri::command] +pub fn config_set(settings: tauri::State, key: String, value: Value) -> Result<(), String> { + if key.is_empty() || key.len() > 256 { return Err("Invalid preference key".into()); } + let path = settings.0.lock().map_err(|e| e.to_string())?; + let mut values = read(&path)?; + values.insert(key, value); + write(&path, &values) +} + +#[cfg(test)] +mod tests { + use super::*; + fn root() -> PathBuf { std::env::temp_dir().join(format!("opencull-config-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))) } + #[cfg(target_os = "linux")] + #[test] fn xdg_paths() { + assert_eq!(linux_path(Some(Path::new("/custom")), Path::new("/home/a")), Path::new("/custom/opencull/config.json")); + assert_eq!(linux_path(None, Path::new("/home/a")), Path::new("/home/a/.config/opencull/config.json")); + assert_eq!(linux_path(Some(Path::new("relative")), Path::new("/home/a")), linux_path(None, Path::new("/home/a"))); + } + #[test] fn migration_preserves_existing_and_unknown_values() { + let dir = root(); let path = dir.join("nested/config.json"); + let first = Values::from([("theme".into(), Value::from("grey")), ("future".into(), Value::from(42))]); + migrate(&path, first).unwrap(); + let result = migrate(&path, Values::from([("theme".into(), Value::from("dark")), ("font".into(), Value::from("system"))])).unwrap(); + assert_eq!(result["theme"], "grey"); assert_eq!(result["future"], 42); assert_eq!(result["font"], "system"); + assert_eq!(read(&path).unwrap(), result); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn malformed_file_is_never_overwritten() { + let dir = root(); fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.json"); + fs::write(&path, b"{broken").unwrap(); assert!(migrate(&path, Values::new()).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"{broken"); fs::remove_dir_all(dir).unwrap(); + } + #[test] fn failed_write_keeps_previous_config() { + let dir = root(); fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.json"); + write(&path, &Values::new()).unwrap(); + assert!(write(&path.join("bad"), &Values::new()).is_err()); assert_eq!(read(&path).unwrap(), Values::new()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src-tauri/src/video_stream.rs b/src-tauri/src/video_stream.rs new file mode 100644 index 0000000..fd3f564 --- /dev/null +++ b/src-tauri/src/video_stream.rs @@ -0,0 +1,107 @@ +//! Linux WebKit's media backend cannot open Tauri custom schemes. Serve only +//! explicitly selected videos over a token-protected loopback stream instead. +use std::{collections::HashMap, fs::File, io::{Read, Seek, SeekFrom, Write}, net::{TcpListener, TcpStream}, path::PathBuf, sync::{Arc, Mutex, OnceLock, mpsc}}; +type Files = Arc>>; +struct Server { port: u16, secret: String, files: Files } +static SERVER: OnceLock> = OnceLock::new(); + +pub fn url(path: PathBuf) -> Result { + let server = SERVER.get_or_init(start).as_ref().map_err(Clone::clone)?; + let key = format!("{:x}", xxhash_rust::xxh3::xxh3_64(path.to_string_lossy().as_bytes())); + server.files.lock().map_err(|e| e.to_string())?.insert(key.clone(), path); + Ok(format!("http://127.0.0.1:{}/{}/{key}", server.port, server.secret)) +} + +fn start() -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).map_err(|e| e.to_string())?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let mut random = [0u8; 32]; File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut random)).map_err(|e| e.to_string())?; + let secret: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let files: Files = Default::default(); + let (send, receive) = mpsc::sync_channel::(8); + let receive = Arc::new(Mutex::new(receive)); + for n in 0..4 { + let (receive, files, secret) = (receive.clone(), files.clone(), secret.clone()); + std::thread::Builder::new().name(format!("opencull-video-{n}")).spawn(move || loop { + let stream = receive.lock().ok().and_then(|r| r.recv().ok()); + let Some(mut stream) = stream else { break }; + let _ = respond(&mut stream, &files, &secret); + }).map_err(|e| e.to_string())?; + } + std::thread::Builder::new().name("opencull-video-listener".into()).spawn(move || { + for stream in listener.incoming().flatten() { let _ = send.try_send(stream); } + }).map_err(|e| e.to_string())?; + Ok(Server { port, secret, files }) +} + +fn range(value: Option<&str>, len: u64) -> Option<(u64, u64)> { + let value = value?.strip_prefix("bytes=")?; + if len == 0 || value.contains(',') { return None; } + let (start, end) = value.split_once('-')?; + if start.is_empty() { + let count: u64 = end.parse().ok()?; if count == 0 { return None; } + return Some((len.saturating_sub(count), len - 1)); + } + let start = start.parse::().ok()?; + let end = if end.is_empty() { len - 1 } else { end.parse::().ok()?.min(len - 1) }; + (start < len && end >= start).then_some((start, end)) +} +fn error(stream: &mut TcpStream, status: &str) -> std::io::Result<()> { + write!(stream, "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") +} +fn respond(stream: &mut TcpStream, files: &Files, secret: &str) -> std::io::Result<()> { + stream.set_read_timeout(Some(std::time::Duration::from_secs(3)))?; + stream.set_write_timeout(Some(std::time::Duration::from_secs(15)))?; + let mut header = Vec::new(); + let mut byte = [0]; + while header.len() < 8192 && !header.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte)? == 0 { return Ok(()); } header.push(byte[0]); + } + if !header.ends_with(b"\r\n\r\n") { return error(stream, "431 Request Header Fields Too Large"); } + let text = String::from_utf8_lossy(&header); + let mut lines = text.split("\r\n"); let mut request = lines.next().unwrap_or_default().split_whitespace(); + let method = request.next().unwrap_or_default(); let uri = request.next().unwrap_or_default(); + if method != "GET" && method != "HEAD" { return error(stream, "405 Method Not Allowed"); } + let prefix = format!("/{secret}/"); + let Some(key) = uri.split('?').next().unwrap_or_default().strip_prefix(&prefix) else { return error(stream, "404 Not Found"); }; + let path = files.lock().ok().and_then(|f| f.get(key).cloned()); + let Some(path) = path else { return error(stream, "404 Not Found"); }; + let Ok(mut file) = File::open(&path) else { return error(stream, "404 Not Found"); }; + let len = file.metadata()?.len(); + let wanted = lines.find_map(|line| line.split_once(':').filter(|(k, _)| k.eq_ignore_ascii_case("range")).map(|(_, v)| v.trim())); + let (start, end) = if wanted.is_some() { + let Some(bounds) = range(wanted, len) else { + return write!(stream, "HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{len}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }; bounds + } else { (0, len.saturating_sub(1)) }; + let count = if len == 0 { 0 } else { end - start + 1 }; + let mime = match crate::raw::extension(&path).as_str() { "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", "mkv" => "video/x-matroska", "mts" | "m2ts" => "video/mp2t", "avi" => "video/x-msvideo", "3gp" => "video/3gpp", _ => "video/mpeg" }; + let status = if wanted.is_some() { "206 Partial Content" } else { "200 OK" }; + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {count}\r\nAccept-Ranges: bytes\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n")?; + if wanted.is_some() { write!(stream, "Content-Range: bytes {start}-{end}/{len}\r\n")?; } + write!(stream, "\r\n")?; + if method == "GET" { file.seek(SeekFrom::Start(start))?; std::io::copy(&mut file.take(count), stream)?; } + Ok(()) +} + +#[cfg(test)] mod tests { + use super::*; + #[test] fn valid_and_invalid_ranges() { + assert_eq!(range(Some("bytes=2-5"), 10), Some((2,5))); + assert_eq!(range(Some("bytes=7-"), 10), Some((7,9))); + assert_eq!(range(Some("bytes=-3"), 10), Some((7,9))); + assert_eq!(range(Some("bytes=0-999"), 10), Some((0,9))); + for value in ["bytes=10-", "bytes=5-2", "bytes=-0", "bytes=1-2,4-5", "bad"] { assert_eq!(range(Some(value),10),None); } + } + #[test] fn loopback_requires_token_and_serves_selected_byte_ranges() { + let path = std::env::temp_dir().join(format!("opencull-stream-{}.mp4",std::process::id())); std::fs::write(&path,b"0123456789").unwrap(); + let files = Arc::new(Mutex::new(HashMap::from([("file".into(),path.clone())]))); + let listener=TcpListener::bind("127.0.0.1:0").unwrap(); let addr=listener.local_addr().unwrap(); + let worker=std::thread::spawn(move || { for _ in 0..3 { let (mut stream,_) = listener.accept().unwrap(); respond(&mut stream,&files,"secret").unwrap(); } }); + let request=|url:&str,range:&str| { let mut stream=TcpStream::connect(addr).unwrap(); write!(stream,"GET {url} HTTP/1.1\r\nHost: localhost\r\n{range}\r\n").unwrap(); let mut out=String::new(); stream.read_to_string(&mut out).unwrap(); out }; + let partial=request("/secret/file","Range: bytes=2-5\r\n"); assert!(partial.starts_with("HTTP/1.1 206")); assert!(partial.ends_with("2345")); + assert!(request("/wrong/file", "").starts_with("HTTP/1.1 404")); + assert!(request("/secret/file","Range: bytes=99-\r\n").starts_with("HTTP/1.1 416")); + worker.join().unwrap(); std::fs::remove_file(path).unwrap(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e169c91..3201bf8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,16 +31,21 @@ "img-src": "'self' opencull: http://opencull.localhost data: blob:", "style-src": "'self' 'unsafe-inline'", "font-src": "'self' data:", - "connect-src": "'self' ipc: http://ipc.localhost" + "connect-src": "'self' ipc: http://ipc.localhost", + "media-src": "'self' asset: http://asset.localhost http://127.0.0.1:* blob:" }, - "devCsp": null + "devCsp": null, + "assetProtocol": { + "enable": true, + "scope": [] + } } }, "bundle": { "active": true, "targets": "all", "license": "GPL-3.0-or-later", - "copyright": "Copyright © 2026 The OpenCull authors. Free software under the GNU General Public License, version 3 or later.", + "copyright": "Copyright \u00a9 2026 The OpenCull authors. Free software under the GNU General Public License, version 3 or later.", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index b13dafe..e0077e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,9 @@ +import { useInterfaceScale } from "@/components/InterfaceScaleSettings"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppVisualTheme } from "@/components/VisualThemeSettings"; + +import { useAppFont } from "@/components/FontSettings"; +import { useAppTheme } from "@/components/ThemeSettings"; import { CullingView } from "@/components/cull/CullingView"; import { NoticeDialog, SettingsDialog, ShortcutsDialog, TrashDialog } from "@/components/cull/Dialogs"; @@ -12,6 +17,10 @@ import { installShortcuts, loadBindings, saveBindings, type Action, type Combo } import { useStore } from "@/lib/store"; export default function App() { + useAppFont(); + useAppTheme(); + useAppVisualTheme(); + useInterfaceScale(); const open = useStore((s) => s.session !== null); const ingesting = useStore((s) => s.ingestOpen); const [bindings, setBindings] = useState(loadBindings); diff --git a/src/components/FontSettings.tsx b/src/components/FontSettings.tsx new file mode 100644 index 0000000..8b96dba --- /dev/null +++ b/src/components/FontSettings.tsx @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { fontStack, validFont, type FontChoice, type FontSetting } from "@/lib/font"; + +const useFont = create<{ value: FontSetting; set: (value: FontSetting) => void }>((set) => ({ + value: validFont(load("font", null)), + set(value) { const safe = validFont(value); save("font", safe); set({ value: safe }); }, +})); + +export function useAppFont() { + const value = useFont((s) => s.value); + useEffect(() => { document.documentElement.style.setProperty("--app-font", fontStack(value.choice, value.family)); }, [value]); +} + +export function FontSettings() { + const { value, set } = useFont(); + return ( +
+ Interface font + + {value.choice === "custom" && } +

Space Grotesk is included offline. Unavailable fonts fall back to the system font.

+

The quick brown fox · IMG_0001.RAW · 0123456789

+
+ ); +} diff --git a/src/components/GraphicsSettings.tsx b/src/components/GraphicsSettings.tsx new file mode 100644 index 0000000..8e97796 --- /dev/null +++ b/src/components/GraphicsSettings.tsx @@ -0,0 +1,30 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { inTauri } from "@/lib/api"; + +export function GraphicsSettings() { + const [result, setResult] = useState(""); + const [busy, setBusy] = useState(false); + const check = async () => { + setBusy(true); + try { + const info = inTauri ? await invoke<{requested:string;platform:string;dmabufDisabled:boolean;softwareForced:boolean;sharedMemoryTransport:boolean}>("graphics_info") : null; + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl"); + let renderer = "WebGL unavailable"; + if (gl) { + const debug = gl.getExtension("WEBGL_debug_renderer_info"); + renderer = String(gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER)); + gl.getExtension("WEBGL_lose_context")?.loseContext(); + } + setResult(`${info ? `${info.platform} · requested profile: ${info.requested}${info.sharedMemoryTransport ? " · shared-memory transport" : ""}${info.dmabufDisabled ? " · DMA-BUF disabled" : ""}${info.softwareForced ? " · software forced" : ""}` : "Browser renderer"}\n${renderer}`); + } catch(e) { setResult(`Renderer check failed: ${String(e)}`); } + finally { setBusy(false); } + }; + return
+ Graphics acceleration +

The desktop webview renders the interface and photo transformations. The check reports the WebGL device name, which the browser may mask for privacy. Image decoding and AI can still use the CPU.

+ + {result && {result}} +
; +} diff --git a/src/components/InterfaceScaleSettings.tsx b/src/components/InterfaceScaleSettings.tsx new file mode 100644 index 0000000..ceb134e --- /dev/null +++ b/src/components/InterfaceScaleSettings.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { inTauri } from "@/lib/api"; +import { load, save } from "@/lib/prefs"; +import { INTERFACE_SCALES, validInterfaceScale } from "@/lib/interface-scale"; + +const useScale = create<{ value: number; error: string; set: (value: number) => void }>((set) => ({ + value: validInterfaceScale(load("interfaceScale", 1)), error: "", + set(value) { set({ value: validInterfaceScale(value), error: "" }); }, +})); +let applying = Promise.resolve(); +export function useInterfaceScale() { + const value = useScale((s) => s.value); + useEffect(() => { + if (!inTauri) return; + let stale = false; + applying = applying.catch(() => {}).then(async () => { + if (stale) return; + const { getCurrentWebview } = await import("@tauri-apps/api/webview"); + await getCurrentWebview().setZoom(value); + if (!stale) { save("interfaceScale", value); useScale.setState({ error: "" }); } + }).catch((e) => { if (!stale) useScale.setState({ error: `Could not change interface scale: ${String(e)}` }); }); + return () => { stale = true; }; + }, [value]); +} +export function InterfaceScaleSettings() { + const { value, set, error } = useScale(); + return
+ Interface scale + +

{inTauri ? "Resize the whole interface, including menus, panels and thumbnails. Saved for your next launch. Smaller windows have less room for photos at larger scales." : "Use your browser’s zoom controls here. This saved setting controls scaling in the desktop app."}

+ + {error &&

{error}

} +
; +} diff --git a/src/components/StackLayoutSettings.tsx b/src/components/StackLayoutSettings.tsx new file mode 100644 index 0000000..1fe3709 --- /dev/null +++ b/src/components/StackLayoutSettings.tsx @@ -0,0 +1,14 @@ +import { useStore } from "@/lib/store"; +export function StackLayoutSettings() { + const fixed = useStore((s) => s.stackFilmstripAtBottom); + return
+ Stack filmstrip placement +
+ + +
+

Keep the active stack's frames in the normal full-width bottom filmstrip, with the same height and resize control as Filmstrip view. The scene overview stays above it. Classic preserves the original layout.

+
; +} diff --git a/src/components/ThemeSettings.tsx b/src/components/ThemeSettings.tsx new file mode 100644 index 0000000..94d46c2 --- /dev/null +++ b/src/components/ThemeSettings.tsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { validTheme, type Theme } from "@/lib/theme"; +const useTheme = create<{ theme: Theme; set: (theme: Theme) => void }>((set) => ({ + theme: validTheme(load("theme", "dark")), + set(theme) { const safe = validTheme(theme); save("theme", safe); set({ theme: safe }); }, +})); +export function useAppTheme() { + const theme = useTheme((s) => s.theme); + useEffect(() => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme === "light" ? "light" : "dark"; + document.documentElement.classList.toggle("dark", theme !== "light"); + }, [theme]); +} +export function ThemeSettings() { + const { theme, set } = useTheme(); + return
+ Appearance + +

18% grey uses a neutral photographic surround. Photo pixels and rating colours are unchanged.

+
; +} diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index 50380f6..d46bcf6 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -80,7 +80,7 @@ export function UpdateDialog() {
-
{progress < 1 ? `Downloading… ${Math.round(progress * 100)}%` : "Installing…"}
+
{progress < 1 ? `Downloading… ${Math.round(progress * 100)}%` : "Installing…"}
)} diff --git a/src/components/ViewModeSettings.tsx b/src/components/ViewModeSettings.tsx new file mode 100644 index 0000000..335a79d --- /dev/null +++ b/src/components/ViewModeSettings.tsx @@ -0,0 +1,28 @@ +import { useId } from "react"; +import { useStore } from "@/lib/store"; + +export function ViewModeSettings() { + const defaultMode = useStore((s) => s.defaultViewMode); + const remember = useStore((s) => s.rememberViewMode); + const id = useId(); + const set = useStore.getState().set; + return
+ View mode +
+ Default opening view +
+ {([['loupe', 'Filmstrip (Loupe)'], ['scene', 'Stack (Scene)'], ['grid', 'Grid']] as const).map(([mode, label]) => + )} +
+

Used for new folders and sessions when remembering the last view is off.

+
+ +

Reopen in the last Filmstrip, Stack, or Grid view, including after restarting OpenCull. Toolbar and keyboard changes are saved automatically.

+
; +} diff --git a/src/components/VisualThemeSettings.tsx b/src/components/VisualThemeSettings.tsx new file mode 100644 index 0000000..c0df53d --- /dev/null +++ b/src/components/VisualThemeSettings.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { load, save } from "@/lib/prefs"; +import { validVisualTheme, visualThemeFont, type VisualTheme } from "@/lib/visual-theme"; +import "@/visual-theme.css"; +import "@/wisp-theme.css"; + +const useVisualTheme = create<{ + theme: VisualTheme; + includedFont: boolean; + setTheme: (theme: VisualTheme) => void; + setIncludedFont: (included: boolean) => void; +}>((set) => ({ + theme: validVisualTheme(load("visualTheme", "default")), + includedFont: load("visualThemeFont", true) === true, + setTheme(theme) { + const safe = validVisualTheme(theme); + save("visualTheme", safe); + set({ theme: safe }); + }, + setIncludedFont(includedFont) { + save("visualThemeFont", includedFont); + set({ includedFont }); + }, +})); + +export function useDesignTheme() { + return useVisualTheme((s) => s.theme); +} + +export function useAppVisualTheme() { + const { theme, includedFont } = useVisualTheme(); + useEffect(() => { + const root = document.documentElement; + root.dataset.visualTheme = theme; + const font = visualThemeFont(theme, includedFont); + if (font) root.style.setProperty("--visual-theme-font", font); + else root.style.removeProperty("--visual-theme-font"); + }, [theme, includedFont]); +} + +export function VisualThemeSettings() { + const { theme, includedFont, setTheme, setIncludedFont } = useVisualTheme(); + return ( +
+ Design theme + +

OpenCull is the original layout. RapidRAW adds a photographic split page. Wisp adds distinct surfaces, stronger contrast and violet accents. Your colour mode stays separate.

+ {theme === "rapidraw" && } +
+ ); +} diff --git a/src/components/cull/Crop.tsx b/src/components/cull/Crop.tsx index 429efd4..68d5fbf 100644 --- a/src/components/cull/Crop.tsx +++ b/src/components/cull/Crop.tsx @@ -308,8 +308,8 @@ function CropBar() { const c = useCrop(); const s = useStore.getState(); const mod = isMac ? "⌘" : "Ctrl+"; - const divider = ; - const tool = "flex size-7 shrink-0 items-center justify-center rounded-md text-[#bdbdbd] hover:bg-white/10 hover:text-white [&_svg]:size-[15px]"; + const divider = ; + const tool = "flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--ui-text-bdbdbd)] hover:bg-white/10 hover:text-white [&_svg]:size-[15px]"; const lit = "bg-brand/20 text-brand hover:bg-brand/25 hover:text-brand"; const [custom, setCustom] = useState({ w: "", h: "" }); @@ -323,7 +323,7 @@ function CropBar() {
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} - className="absolute bottom-5 left-1/2 flex -translate-x-1/2 cursor-default items-center gap-2.5 rounded-xl border border-white/10 bg-[#2a2a30]/90 px-3 py-2 text-xs text-[#d9d9d9] shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" + className="absolute bottom-5 left-1/2 flex -translate-x-1/2 cursor-default items-center gap-2.5 rounded-xl border border-white/10 bg-[var(--ui-surface-2a2a30)]/90 px-3 py-2 text-xs text-[var(--ui-text-d9d9d9)] shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" > @@ -343,10 +343,10 @@ function CropBar() { {/* Typed, so the keys go to the boxes and not to the menu or the app. */}
e.stopPropagation()}> - Custom + Custom {(["w", "h"] as const).map((side, i) => ( - {i === 1 && ×} + {i === 1 && ×} setCustom({ ...custom, [side]: e.target.value.replace(/[^0-9.]/g, "") })} onKeyDown={(e) => e.key === "Enter" && applyCustom()} onBlur={applyCustom} - className="h-6 w-10 rounded bg-white/[0.08] px-1 text-center text-xs text-white outline-none focus:ring-1 focus:ring-brand" + className="h-6 w-10 rounded bg-white/[0.08] px-1 text-center text-xs text-foreground outline-none focus:ring-1 focus:ring-brand" /> ))} @@ -400,7 +400,7 @@ function CropBar() { c.tilt(0); c.tiltDone(); }} - className="w-11 shrink-0 text-right font-mono tabular-nums text-[#bdbdbd]" + className="w-11 shrink-0 text-right font-mono tabular-nums text-[var(--ui-text-bdbdbd)]" > {tilt > 0 ? "+" : ""} {tilt.toFixed(2)}° @@ -423,7 +423,7 @@ function CropBar() { {divider} - @@ -439,10 +439,10 @@ function CropBar() { {divider} - - -

Stays that way for folders you open from now on. Change it any time in Settings.

+

Stays that way for folders you open from now on. Change it any time in Settings.

)} {otherKind ? null : hidden ? ( <> -

+

It contains {session.subfolderCount === 1 ? "1 subfolder" : `${session.subfolderCount} subfolders`}. Photos inside subfolders are not opened unless you ask for them.

-

Stays on for folders you open from now on. Change it any time in Settings.

+

Stays on for folders you open from now on. Change it any time in Settings.

) : ( - session.includeSubfolders &&

Nothing was found here or in any of its subfolders.

+ session.includeSubfolders &&

Nothing was found here or in any of its subfolders.

)} -
diff --git a/src/components/cull/Dialogs.tsx b/src/components/cull/Dialogs.tsx index 81fd146..34a5f15 100644 --- a/src/components/cull/Dialogs.tsx +++ b/src/components/cull/Dialogs.tsx @@ -1,5 +1,9 @@ +import { ExperimentalFilmstripSettings } from "./ExperimentalFilmstripSettings"; +import { ExperimentalZoomSettings } from "./ExperimentalZoomSettings"; +import { InterfaceScaleSettings } from "@/components/InterfaceScaleSettings"; +import { GraphicsSettings } from "@/components/GraphicsSettings"; import { useEffect, useRef, useState } from "react"; -import { HardDrive, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; +import { HardDrive, PanelsTopLeft, Keyboard, ScanFace, Star, ZoomIn, type LucideIcon } from "lucide-react"; import { AlertDialog, @@ -11,7 +15,12 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { FontSettings } from "@/components/FontSettings"; +import { ThemeSettings } from "@/components/ThemeSettings"; import { Button } from "@/components/ui/button"; +import { VisualThemeSettings } from "@/components/VisualThemeSettings"; +import { ViewModeSettings } from "@/components/ViewModeSettings"; +import { StackLayoutSettings } from "@/components/StackLayoutSettings"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -115,16 +124,16 @@ export function ShortcutsDialog({ bindings, onChange, onRecording }: { bindings:
{SECTIONS.map((section) => (
-
{section.title}
+
{section.title}
{section.actions.map(([action, label]) => ( -
+
{label} @@ -381,6 +421,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT /> + {isMac ? "⌘ drag" : "Ctrl + drag"}} + control={{isMac ? "⌘ drag" : "Ctrl + drag"}} /> @@ -425,6 +466,7 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT /> + setLimit(s.cacheLimitGb - (s.cacheLimitGb > 10 ? 5 : 1))}> − - {s.cacheLimitGb} GB + {s.cacheLimitGb} GB @@ -497,10 +539,10 @@ function SettingsBody({ tab, onTab }: { tab: SettingsTab; onTab: (tab: SettingsT function Setting({ title, detail, control }: { title: string; detail: string; control: React.ReactNode }) { return ( -
+
-
{title}
-
{detail}
+
{title}
+
{detail}
{control}
diff --git a/src/components/cull/ExifPanel.tsx b/src/components/cull/ExifPanel.tsx index c89ccc3..bd9a10f 100644 --- a/src/components/cull/ExifPanel.tsx +++ b/src/components/cull/ExifPanel.tsx @@ -1,3 +1,4 @@ +import { isVideo } from "@/lib/media"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { Aperture, Calendar, Camera, Clock, FileText, Folder, HardDrive, Image as ImageIcon, Info, Ratio, Sun, Target, Timer, X } from "lucide-react"; @@ -8,20 +9,21 @@ import type { Histogram as HistogramData, Photo } from "@/lib/types"; /** The panel down the left: a histogram, then what the camera recorded. */ export function ExifPanel() { + const showHistogram = useStore((s) => s.showHistogram); const photo = useStore((s) => (s.current >= 0 ? s.photos[s.current] : undefined)); return (
- EXIF Data -
-
- -
+ {showHistogram &&
+ {photo && isVideo(photo.path) ?

Histogram unavailable for video

: } +
} {photo ?
: }
); @@ -29,8 +31,8 @@ export function ExifPanel() { function Empty() { return ( -
- +
+ No image selected
); @@ -51,7 +53,7 @@ function Details({ photo }: { photo: Photo }) { } /> } />
- {comp && comp !== "0 EV" &&
± Exposure Comp: {comp}
} + {comp && comp !== "0 EV" &&
± Exposure Comp: {comp}
}
@@ -81,12 +83,12 @@ function Details({ photo }: { photo: Photo }) { ); } -const Rule = () =>
; +const Rule = () =>
; function Section({ title, children }: { title: string; children: ReactNode }) { return (
-
{title}
+
{title}
{children}
); @@ -95,11 +97,11 @@ function Section({ title, children }: { title: string; children: ReactNode }) { function Badge({ title, value, icon }: { title: string; value: string | null; icon: ReactNode }) { return (
-
+
{icon} {title}
-
{value ?? "—"}
+
{value ?? "—"}
); } @@ -109,8 +111,8 @@ function Row({ icon, title, value }: { icon: ReactNode; title: string; value: st
{icon}
-
{title}
-
{value}
+
{title}
+
{value}
); @@ -196,5 +198,5 @@ function Histogram({ photo }: { photo: Photo | undefined }) { draw(data.luma, "rgba(255, 255, 255, .16)"); }, [data, rgb, width]); - return ; + return ; } diff --git a/src/components/cull/ExperimentalFilmstripSettings.tsx b/src/components/cull/ExperimentalFilmstripSettings.tsx new file mode 100644 index 0000000..c36c553 --- /dev/null +++ b/src/components/cull/ExperimentalFilmstripSettings.tsx @@ -0,0 +1,14 @@ +import { Switch } from "@/components/ui/switch"; +import { useStore } from "@/lib/store"; + +export function ExperimentalFilmstripSettings() { + const enabled = useStore((s) => s.experimentalNativeFilmstrip); + return
+ Experimental Features + +
; +} diff --git a/src/components/cull/ExperimentalZoomSettings.tsx b/src/components/cull/ExperimentalZoomSettings.tsx new file mode 100644 index 0000000..8a949a7 --- /dev/null +++ b/src/components/cull/ExperimentalZoomSettings.tsx @@ -0,0 +1,14 @@ +import { Switch } from "@/components/ui/switch"; +import { useStore } from "@/lib/store"; + +export function ExperimentalZoomSettings() { + const enabled = useStore((s) => s.experimentalSmoothZoom); + return
+ Experimental Features + +
; +} diff --git a/src/components/cull/FacePanel.tsx b/src/components/cull/FacePanel.tsx index 3122ac6..eb459c7 100644 --- a/src/components/cull/FacePanel.tsx +++ b/src/components/cull/FacePanel.tsx @@ -1,3 +1,4 @@ +import { isVideo } from "@/lib/media"; import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { Focus, ScanFace } from "lucide-react"; @@ -169,9 +170,9 @@ export function FacePanel() { ))}
) : ( -
+
- {!enabled ? "OpenCull AI is off" : result ? "No faces detected" : "Analyzing…"} + {photo && isVideo(photo.path) ? "Face analysis is unavailable for video" : !enabled ? "OpenCull AI is off" : result ? "No faces detected" : "Analyzing…"}
)}
@@ -249,7 +250,7 @@ function FaceCell({ face, eyes, photo, width, height, softSrc, sharpSrc, fullSrc type="button" title={face.reason ?? "Zoom to this face"} onClick={() => useStore.getState().zoomToFace(face)} - className={cn("relative shrink-0 cursor-pointer overflow-hidden rounded-md bg-[#333]", !face.subject && "opacity-40")} + className={cn("relative shrink-0 cursor-pointer overflow-hidden rounded-md bg-[var(--ui-surface-333333)]", !face.subject && "opacity-40")} style={{ width, height }} > @@ -268,7 +269,7 @@ function KeyCell({ region, photo, width, height, softSrc, sharpSrc, fullSrc }: { const [hovering, setHovering] = useState(false); const k = Math.min(Math.max(Math.min(width, height) / 150, 0.62), 1); return ( - ); } @@ -54,7 +54,7 @@ export function FilterBar() { }, [photos, order]); const flag = (filter: FlagFilter) => () => s.setFlagFilter(flagFilter === filter ? "all" : filter); - const sep = ; + const sep = ; return (
@@ -82,7 +82,7 @@ export function FilterBar() { s.toggleColorFilter("none")}> - + {COLORS.map((c) => ( s.toggleColorFilter(c)}> @@ -93,7 +93,7 @@ export function FilterBar() { {filtering && ( <> {sep} - diff --git a/src/components/cull/Loupe.tsx b/src/components/cull/Loupe.tsx index 9cb0bd6..66bc43f 100644 --- a/src/components/cull/Loupe.tsx +++ b/src/components/cull/Loupe.tsx @@ -1,3 +1,5 @@ +import { isVideo } from "@/lib/media"; +import { VideoPlayer } from "./VideoPlayer"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Flag, Loader2, X } from "lucide-react"; @@ -42,7 +44,7 @@ const MAX_ZOOM = 20; const SHARPEN_MS = 120; /** How long each zoom takes to glide into place, in milliseconds. */ -const GLIDE = { in: 220, out: 200, box: 250 }; + /** A press that travels no further than this is a click, not a drag. */ const CLICK_SLOP = 3; @@ -125,12 +127,12 @@ export function Loupe() { return (
- {photo && cropping ? ( + {photo && isVideo(photo.path) ? : photo && cropping ? ( ) : photo ? ( ) : ( -
+
{loading && } {loading ? `Opening ${folder}…` : "No image selected"}
@@ -142,6 +144,21 @@ export function Loupe() { } function Canvas({ photo }: { photo: Photo }) { + const smoothZoom = useStore((s) => s.experimentalSmoothZoom); + const [reducedMotion, setReducedMotion] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches); + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(query.matches); + query.addEventListener("change", update); + update(); + return () => query.removeEventListener("change", update); + }, []); + const GLIDE = smoothZoom + ? reducedMotion ? { in: 0, out: 0, box: 0 } : { in: 320, out: 280, box: 340 } + : { in: 220, out: 200, box: 250 }; + const zoomCurve = smoothZoom ? "cubic-bezier(0.22, 1, 0.36, 1)" : "ease-in-out"; + + const frame = useRef(null); const sheet = useRef(null); const [box, setBox] = useState({ w: 0, h: 0 }); @@ -620,7 +637,7 @@ function Canvas({ photo }: { photo: Photo }) { if (at + d < view.length) near.push(view[at + d]); if (d <= BEHIND && at - d >= 0) near.push(view[at - d]); } - void api.prefetch(near.map((i) => ({ path: photos[i].display, version: photos[i].version })), fitSize); + void api.prefetch(near.filter((i) => !isVideo(photos[i].path)).map((i) => ({ path: photos[i].display, version: photos[i].version })), fitSize); }); return () => cancelAnimationFrame(handle); }, [photo.index, photo.display, photo.version, fitSize, box.w]); @@ -639,7 +656,7 @@ function Canvas({ photo }: { photo: Photo }) { marginLeft: -laid.w / 2, marginTop: -laid.h / 2, transform, - transition: glide > 0 ? `transform ${glide}ms ease-in-out` : undefined, + transition: glide > 0 ? `transform ${glide}ms ${zoomCurve}` : undefined, }} > {/* The pictures, in one box that is always here: for a photo with @@ -723,7 +740,7 @@ function ZoomBadge({ percent, sharp, loading }: { percent: number | null; sharp:
{percent === null ? "Fit" : `${percent}%`} {loading && ( - + HD )} @@ -738,7 +755,7 @@ function InfoOverlay({ photo }: { photo: Photo }) { return (
{fmt.displayName(photo)}
- {parts.length > 0 &&
{parts.map((p) => {p})}
} + {parts.length > 0 &&
{parts.map((p) => {p})}
}
); } @@ -746,24 +763,24 @@ function InfoOverlay({ photo }: { photo: Photo }) { /** Flag, stars and colour for the photo in the loupe, along its bottom edge. */ function RatingBar({ photo }: { photo: Photo }) { const s = useStore.getState(); - const divider = ; + const divider = ; return (
e.stopPropagation()} - className="absolute bottom-5 left-1/2 flex -translate-x-1/2 items-center gap-3.5 rounded-xl border border-white/10 bg-[#2a2a30]/80 px-4.5 py-2.5 shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" + className="absolute bottom-5 left-1/2 flex -translate-x-1/2 items-center gap-3.5 rounded-xl border border-white/10 bg-[var(--ui-surface-2a2a30)]/80 px-4.5 py-2.5 shadow-[0_4px_12px_rgba(0,0,0,.4)] backdrop-blur-md" > {divider} {[1, 2, 3, 4, 5].map((n) => ( - ))} diff --git a/src/components/cull/SceneView.tsx b/src/components/cull/SceneView.tsx index 4f2a090..c880cd4 100644 --- a/src/components/cull/SceneView.tsx +++ b/src/components/cull/SceneView.tsx @@ -1,9 +1,11 @@ +import { previewAspect, usePreviewAspectRevision } from "@/lib/preview-aspect"; +import { stripPhotoWidth } from "@/lib/strip-aspect"; import { useMemo } from "react"; import { Check } from "lucide-react"; import { groupOf, useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; -import { Filmstrip } from "./Filmstrip"; +import { Filmstrip, FilmstripResizeHandle } from "./Filmstrip"; import { Loupe } from "./Loupe"; import { useStrip } from "./strip"; import { FramedThumb } from "./Thumb"; @@ -13,7 +15,6 @@ interface Scene { frames: number[]; } -const SCENE_STRIP = 116; /** * The folder as scenes rather than frames. @@ -25,11 +26,12 @@ const SCENE_STRIP = 116; * working through the shoot one moment at a time, with the middle row for * choosing within each. */ -export function SceneView() { +export function SceneView({ externalFilmstrip = false }: { externalFilmstrip?: boolean }) { const view = useStore((s) => s.view); const photos = useStore((s) => s.photos); const current = useStore((s) => s.current); const height = useStore((s) => s.filmstripHeight); + const showFilmstrip = useStore((s) => s.showFilmstrip); const poses = useStore((s) => s.poses); const scenes = useMemo(() => { @@ -50,16 +52,14 @@ export function SceneView() {
-
- -
- + {showFilmstrip && !externalFilmstrip &&
+ +
} + {showFilmstrip && }
); } -const CARD_H = SCENE_STRIP - 20; -const CARD_W = Math.round(CARD_H * 1.5); const CARD_GAP = 8; /** @@ -70,23 +70,38 @@ const CARD_GAP = 8; * opened — almost none of them for anything on screen. */ function SceneStrip({ scenes, active }: { scenes: Scene[]; active: Scene | undefined }) { - const starts = useMemo(() => Float64Array.from({ length: scenes.length + 1 }, (_, i) => 10 + i * (CARD_W + CARD_GAP)), [scenes.length]); + const height = useStore((s) => s.sceneStripHeight); + const cardHeight = Math.max(24, height - 24); + const photos = useStore((s) => s.photos); + const native = useStore((s) => s.experimentalNativeFilmstrip); + const aspectRevision = usePreviewAspectRevision(native); + const widths = useMemo(() => scenes.map((scene) => stripPhotoWidth(photos[scene.frames[0]], cardHeight, native, previewAspect(photos[scene.frames[0]]))), [scenes, photos, native, aspectRevision, cardHeight]); + const starts = useMemo(() => { + const positions = new Float64Array(scenes.length + 1); + let x = 10; + widths.forEach((width, i) => { positions[i] = x; x += width + CARD_GAP; }); + positions[scenes.length] = x; + return positions; + }, [scenes.length, widths]); const { scroller, length, from, to, onScroll, onWheel } = useStrip({ starts, pad: 10 - CARD_GAP, position: active ? active.number - 1 : -1, anchor: active?.frames[0] }); return ( -
+
+ +
{scenes.slice(from, to).map((scene, i) => (
- +
))}
+
); } -function SceneCard({ scene, active }: { scene: Scene; active: boolean }) { +function SceneCard({ scene, active, width, height, native }: { scene: Scene; active: boolean; width: number; height: number; native: boolean }) { const first = useStore((s) => s.photos[scene.frames[0]]); // Marked all the way through: every frame has a flag, a rating or a colour. const complete = useStore((s) => scene.frames.every((i) => s.photos[i].rating > 0 || s.photos[i].color !== "none" || s.photos[i].flag !== "none")); @@ -98,11 +113,11 @@ function SceneCard({ scene, active }: { scene: Scene; active: boolean }) { // Opens on the scene's first frame, with any selection dropped: a // selection made in one scene means nothing in the next. onClick={() => useStore.getState().select(scene.frames[0], { meta: false, shift: false })} - className={cn("relative overflow-hidden rounded-md bg-[#141414]", active ? "ring-2 ring-[#d9d9d9]" : "opacity-75 hover:opacity-100")} - style={{ width: CARD_W, height: CARD_H }} + className={cn("relative overflow-hidden rounded-md bg-[var(--ui-surface-141414)]", active ? "ring-2 ring-[#d9d9d9]" : "opacity-75 hover:opacity-100")} + style={{ width, height }} > - - {scene.number} + + {scene.number} {scene.frames.length > 1 && ( ×{scene.frames.length} )} diff --git a/src/components/cull/ShipDialog.tsx b/src/components/cull/ShipDialog.tsx index c3923cb..c249f28 100644 --- a/src/components/cull/ShipDialog.tsx +++ b/src/components/cull/ShipDialog.tsx @@ -119,7 +119,7 @@ export function ShipDialog() { const open = useStore((s) => s.shipOpen); return ( useStore.getState().set("shipOpen", o)}> - e.preventDefault()}> + e.preventDefault()}> {/* Only exists while Ship is open, so it reads the folder's ratings when asked to and not on every change while culling. */} @@ -292,13 +292,13 @@ function Ship() { }; return ( -
+
Ship - Export, copy, or open images in your editing app + Export, copy, or open images in your editing app
-
@@ -314,9 +314,9 @@ function Ship() { key={mode} type="button" onClick={() => change({ mode })} - className={cn("flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px]", settings.mode === mode ? "bg-[#59a6ff]/15 text-white" : "text-[#b3b3b3] hover:bg-white/[0.04]")} + className={cn("flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-[13px]", settings.mode === mode ? "bg-[#59a6ff]/15 text-white" : "text-[var(--ui-text-b3b3b3)] hover:bg-white/[0.04]")} > - + {label} ))} @@ -324,7 +324,7 @@ function Ship() { {settings.mode === "ratings" && (
-
Select ratings to include
+
Select ratings to include
{/* The filter bar's symbols, in the filter bar's order. */} {(["pick", "none", "reject"] as Flag[]).map((flag) => ( @@ -348,7 +348,7 @@ function Ship() { count={count((p) => p.rating === r)} onClick={() => change({ ratings: toggleIn(settings.ratings, r) })} > - + ))}
@@ -364,7 +364,7 @@ function Ship() {
-
+
{images.length.toLocaleString()} of {everything.length.toLocaleString()} selected @@ -384,7 +384,7 @@ function Ship() { {apps.map((app) => ( change({ editor: app.id, choseEditor: true })}> {app.name} - {!app.installed && not installed} + {!app.installed && not installed} ))} @@ -407,11 +407,11 @@ function Ship() { change({ operation: "move" })}>Move files to…
- + {/* Cut from the left, so the folder's own name is what stays in view. */} {settings.destination ?? "Choose a destination folder"} -
@@ -421,26 +421,26 @@ function Ship() {
- {blocked ? : } - {blocked ?? `${images.length.toLocaleString()} image${images.length === 1 ? "" : "s"} ready to ship`} + {blocked ? : } + {blocked ?? `${images.length.toLocaleString()} image${images.length === 1 ? "" : "s"} ready to ship`} {shipping ? ( <> - Shipping… + Shipping… ) : ( <> - @@ -548,7 +548,7 @@ function Chip({ title, on, count, onClick, children }: { title: string; on: bool className={cn("flex h-7 items-center gap-2 rounded-md border px-2 [&_svg]:size-[13px]", on ? "border-[#59a6ff] bg-[#59a6ff]/20" : "border-transparent bg-white/[0.06] hover:bg-white/[0.1]")} > {children} - {count.toLocaleString()} + {count.toLocaleString()} ); } diff --git a/src/components/cull/Thumb.tsx b/src/components/cull/Thumb.tsx index 0611790..289f57a 100644 --- a/src/components/cull/Thumb.tsx +++ b/src/components/cull/Thumb.tsx @@ -1,3 +1,6 @@ +import { Film } from "lucide-react"; +import { isVideo } from "@/lib/media"; +import { rememberPreviewAspect } from "@/lib/preview-aspect"; import { memo, useEffect, useState, type CSSProperties } from "react"; import { Crop, Flag, X } from "lucide-react"; @@ -15,7 +18,12 @@ import type { Photo } from "@/lib/types"; * a folder closed — and a cell that gave up at the first refusal would stay * blank for as long as it was on screen. */ -export function ThumbImage({ photo, size, className, style }: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { +export function ThumbImage(props: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { + if (isVideo(props.photo.display)) return
; + return ; +} + +function StillThumbImage({ photo, size, className, style }: { photo: Pick; size: number; className?: string; style?: CSSProperties }) { const [attempt, setAttempt] = useState(0); const [failed, setFailed] = useState(false); @@ -49,6 +57,7 @@ export function ThumbImage({ photo, size, className, style }: { photo: Pick rememberPreviewAspect(photo, e.currentTarget)} className={className} style={style} onError={() => { @@ -82,7 +91,7 @@ export function FramedThumb({ photo, size, box, inset = 0, cover = false, classN
{photo.framing.crop && ( - + )} @@ -126,7 +135,7 @@ export function FlagBadge({ flag, small }: { flag: Photo["flag"]; small?: boolea return ( {flag === "pick" ? ( - + ) : ( )} @@ -139,7 +148,7 @@ export function StarRow({ rating, small }: { rating: number; small?: boolean }) return ( {[1, 2, 3, 4, 5].map((n) => ( - + ★ ))} diff --git a/src/components/cull/Toolbar.tsx b/src/components/cull/Toolbar.tsx index 0a8ad76..3aaa2fc 100644 --- a/src/components/cull/Toolbar.tsx +++ b/src/components/cull/Toolbar.tsx @@ -48,7 +48,7 @@ function Tool({ label, active, onClick, children, className }: { label: string; onClick={onClick} className={cn( "flex size-8 shrink-0 items-center justify-center rounded-md transition-colors [&_svg]:size-[15px]", - active ? "bg-brand/15 text-brand" : "text-[#737373] hover:text-[#b3b3b3]", + active ? "bg-brand/15 text-brand" : "text-[var(--ui-text-737373)] hover:text-[var(--ui-text-b3b3b3)]", className, )} > @@ -61,7 +61,7 @@ function Tool({ label, active, onClick, children, className }: { label: string; } export function Divider({ className }: { className?: string }) { - return ; + return ; } const SORTS: { key: SortKey; title: string; up: string; down: string }[] = [ @@ -93,21 +93,21 @@ export function Toolbar() { const s = useStore.getState(); return ( -
+
s.closeFolder()} className="w-9"> - - +

+ {name} {kinds !== "both" && ( - + {kinds === "raw" ? "RAW only" : "JPEG only"} )} - +

@@ -198,7 +198,7 @@ function Mode({ mode, label, current, children }: { mode: ViewMode; label: strin aria-label={label} aria-pressed={current === mode} onClick={() => useStore.getState().set("viewMode", mode)} - className={cn("flex h-6 w-[26px] items-center justify-center rounded [&_svg]:size-[13px]", current === mode ? "bg-[#383838] text-white" : "text-[#737373] hover:text-[#b3b3b3]")} + className={cn("flex h-6 w-[26px] items-center justify-center rounded [&_svg]:size-[13px]", current === mode ? "bg-[var(--ui-surface-383838)] text-white" : "text-[var(--ui-text-737373)] hover:text-[var(--ui-text-b3b3b3)]")} > {children} @@ -235,10 +235,10 @@ function Status() { // Counted from how many there were when this round began. Never shown // as finished while any are left. const done = toPrepare > 0 ? Math.min(0.99, Math.max(0, 1 - preparing / toPrepare)) : 0; - return ; + return ; } return ( - + Ready @@ -275,7 +275,7 @@ function SortMenu() { diff --git a/src/components/cull/VideoPlayer.tsx b/src/components/cull/VideoPlayer.tsx new file mode 100644 index 0000000..ab45743 --- /dev/null +++ b/src/components/cull/VideoPlayer.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { inTauri } from "@/lib/api"; + +export function VideoPlayer({ path }: { path: string }) { + const [url, setUrl] = useState(""); + const [error, setError] = useState(""); + useEffect(() => { + let stale = false; setUrl(""); setError(""); + if (!inTauri) { setError("Video playback is available in the desktop application."); return; } + void invoke("video_source", { path }).then((file) => { if (!stale) setUrl(file.startsWith("http://127.0.0.1:") ? file : convertFileSrc(file)); }, (e) => { if (!stale) setError(String(e)); }); + return () => { stale = true; }; + }, [path]); + return
e.stopPropagation()}> + {url && !error &&
; +} diff --git a/src/components/cull/symbols.tsx b/src/components/cull/symbols.tsx index 35b8a31..3997427 100644 --- a/src/components/cull/symbols.tsx +++ b/src/components/cull/symbols.tsx @@ -9,9 +9,9 @@ import type { Flag } from "@/lib/types"; /** A flag state: flagged, unflagged or rejected. */ export function FlagSymbol({ flag, lit }: { flag: Flag; lit: boolean }) { - if (flag === "pick") return ; - if (flag === "none") return ; - return ; + if (flag === "pick") return ; + if (flag === "none") return ; + return ; } /** @@ -21,7 +21,7 @@ export function FlagSymbol({ flag, lit }: { flag: Flag; lit: boolean }) { */ export function RatingSymbol({ rating, lit, behind }: { rating: number; lit: boolean; behind: string }) { return ( - + {rating === 0 ? : } {rating > 0 && {rating}} diff --git a/src/components/home/HomeView.tsx b/src/components/home/HomeView.tsx index bd4bb76..d71a62f 100644 --- a/src/components/home/HomeView.tsx +++ b/src/components/home/HomeView.tsx @@ -1,7 +1,10 @@ +import "./home-typography.css"; import { useEffect, useRef, useState } from "react"; import { Circle, CircleCheck, FolderPlus, FolderSearch, Images, Keyboard, Settings, Trash2, X } from "lucide-react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { useDesignTheme } from "@/components/VisualThemeSettings"; +import { RecentSessions, ResumeLatestSession } from "./RecentSessions"; import { CardIcon } from "@/components/ingest/IngestView"; import { api, type Volume } from "@/lib/api"; import { useStore } from "@/lib/store"; @@ -9,6 +12,7 @@ import { cn } from "@/lib/utils"; import type { Scanned } from "@/lib/types"; const GRADIENT = "bg-gradient-to-r from-[#4d80ff] to-[#804dff]"; +const HOME_ACTION = "inline-flex h-10 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg px-4 py-0 text-[13px] font-medium leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"; function baseName(path: string): string { const parts = path.split(/[\\/]/).filter(Boolean); @@ -26,6 +30,7 @@ async function chooseFolder() { /** Where the app opens: the folders worked on lately, and a way to open another. */ export function HomeView({ dropping }: { dropping: boolean }) { + const designTheme = useDesignTheme(); const recents = useStore((s) => s.recents); const platform = useStore((s) => s.platform); const openError = useStore((s) => s.openError); @@ -94,38 +99,48 @@ export function HomeView({ dropping }: { dropping: boolean }) { }; return ( -
-
-
-

OpenCull

-

Culling Software made for Photographers

+
+ {designTheme === "rapidraw" && !missing.has(path))} />} +
+
+
+

OpenCull

+

Culling Software made for Photographers

+

{recents.length ? "Welcome back. Continue a session or bring in your next shoot." : "Your next great photograph starts with a closer look."}

- - +
+
+ +
+
{arrived && (
- “{arrived.name}” detected + “{arrived.name}” detected -
@@ -144,12 +159,13 @@ export function HomeView({ dropping }: { dropping: boolean }) { {openError &&
Could not open that folder: {openError}
} -
+
+ {recents.length === 0 ? ( -
+
-
Open a folder to start culling
-

Choose a folder of photos, or drop one anywhere on this window. RAW files open straight from the previews your camera already made.

+
Open a folder to start culling
+

Choose a folder of photos, or drop one anywhere on this window. RAW files open straight from the previews your camera already made.

@@ -157,13 +173,15 @@ export function HomeView({ dropping }: { dropping: boolean }) { ) : ( <>
-

Recent Folders ({recents.length})

- - + } {selected.size > 0 && (
-
+
{recents.map((path) => ( )}
+
Local photos. A clear view. Your best frames.
+
{dropping && (
@@ -202,6 +222,40 @@ export function HomeView({ dropping }: { dropping: boolean }) { ); } +/** Uses only local session previews; original artwork fills the first-run state. */ +function WelcomeArtwork({ path }: { path?: string }) { + const [image, setImage] = useState(null); + const [sharp, setSharp] = useState(null); + const fullUrl = image ? api.url("full", image) : null; + useEffect(() => { + let stale = false; + setSharp(null); + if (fullUrl) { + // Decode the largest camera preview before replacing the quick thumbnail. + // The thumb tier is capped at 1280px even when a larger size is requested. + const next = new Image(); + next.src = fullUrl; + void next.decode().then(() => { if (!stale) setSharp(fullUrl); }, () => {}); + } + return () => { stale = true; }; + }, [fullUrl]); + useEffect(() => { + let stale = false; + setImage(null); + if (path) void api.folderCover(path).then( + (images) => { if (!stale) setImage(images[0] ?? null); }, + () => {}, + ); + return () => { stale = true; }; + }, [path]); + return ; +} + function FolderCard({ path, missing, @@ -237,17 +291,17 @@ function FolderCard({ onClick={open} onKeyDown={(e) => e.key === "Enter" && open()} className={cn( - "group relative cursor-pointer overflow-hidden rounded-xl border bg-[#1f1f1f] shadow-[0_4px_8px_rgba(0,0,0,.3)] transition-transform duration-150 hover:scale-[1.02] hover:bg-[#292929]", - selected ? "border-2 border-[#5999ff]" : "border-[#383838] hover:border-[#4d80ff]/60", + "group relative cursor-pointer overflow-hidden rounded-xl border bg-[var(--ui-surface-1f1f1f)] shadow-[0_4px_8px_rgba(0,0,0,.3)] transition-transform duration-150 hover:scale-[1.02] hover:bg-[var(--ui-surface-292929)]", + selected ? "border-2 border-[#5999ff]" : "border-[var(--ui-line-383838)] hover:border-[#4d80ff]/60", )} > -
{missing ? : }
-
-
+
{missing ? : }
+
+
{missing && } {baseName(path)}
-
{missing ? "Not where it was — click to find it" : path}
+
{missing ? "Not where it was — click to find it" : path}
@@ -274,8 +328,8 @@ function FolderCard({ function Placeholder() { return ( -
- +
+
); } @@ -297,7 +351,7 @@ function Cover({ path }: { path: string }) { const tile = (image: Scanned, size: number) => ; return ( -
+
0 ? "w-3/5" : "w-full")}>{tile(first, 640)}
{rest.length > 0 &&
{rest.slice(0, 3).map((image) => tile(image, 320))}
}
diff --git a/src/components/home/RecentSessions.tsx b/src/components/home/RecentSessions.tsx new file mode 100644 index 0000000..b8e9d93 --- /dev/null +++ b/src/components/home/RecentSessions.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { create } from "zustand"; +import { Images, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { load } from "@/lib/prefs"; +import { listReviews, loadReview, removeReviews } from "@/lib/review-api"; +import { latestSession, type RecentSession, type ResumeTarget } from "@/lib/session-history"; +import { useStore } from "@/lib/store"; + +const useHistory = create<{ sessions: RecentSession[]; error: string }>(() => ({ sessions: [], error: "" })); +let reading: Promise | null = null; +function refresh() { + if (!reading) reading = listReviews().then((sessions) => { useHistory.setState({ sessions, error: "" }); }) + .catch((e) => { useHistory.setState({ error: String(e) }); }).finally(() => { reading = null; }); + return reading; +} +function useRecentSessions() { + const state = useHistory(); + useEffect(() => { + const reload = () => { void refresh(); }; + reload(); window.addEventListener("opencull:sessions-changed", reload); + return () => window.removeEventListener("opencull:sessions-changed", reload); + }, []); + return state; +} +async function resume(target: ResumeTarget) { + if (target.kind === "folder") { await useStore.getState().openFolder(target.path); return; } + const review = await loadReview(target.id); + if (!review) throw new Error("This saved session is no longer available."); + const paths = [...new Set(review.items.flatMap((p) => [p.path,p.display]))]; + if (!review.sources.length || (await api.pathsExist(paths)).some((p) => !p)) throw new Error("Reconnect the source cards before resuming this session."); + await useStore.getState().openFolder(review.sources[0],review); +} +export function ResumeLatestSession({ className }: { className?: string }) { + const { sessions } = useRecentSessions(); + const recents = useStore((s) => s.recents); + const target = latestSession(sessions,load("lastFolderSession",null),recents[0]); + const [error,setError] = useState(""); + const [busy,setBusy] = useState(false); + if (!target) return null; + const open = async () => { setBusy(true);setError("");try { await resume(target); } catch(e) { setError(String(e)); } finally { setBusy(false); } }; + return <> + {error &&

{error}

}; +} +export function RecentSessions() { + const { sessions,error } = useRecentSessions(); + const [opening,setOpening] = useState(null); + const [openError,setOpenError] = useState(""); + const [selected,setSelected] = useState>(new Set()); + const [removing,setRemoving] = useState(false); + if (!sessions.length && !error) return null; + const toggle = (id: string) => setSelected((before) => { const next = new Set(before); if (!next.delete(id)) next.add(id); return next; }); + const open = async (id: string) => { + if (selected.size) { toggle(id); return; } + setOpening(id);setOpenError("");try { await resume({kind:"card",id}); } catch(e) { setOpenError(String(e)); } finally { setOpening(null); } + }; + const remove = async () => { + setRemoving(true);setOpenError(""); + try { await removeReviews([...selected]); await refresh(); setSelected(new Set()); } + catch(e) { setOpenError(String(e)); } finally { setRemoving(false); } + }; + return
+
+

Recent Sessions ({sessions.length})

+ {selected.size > 0 && <> + + + + } +
+ {selected.size > 0 &&

Deletes saved session history. Source photos stay on disk.

} + {(error || openError) &&

{error || openError}

} +
+ {sessions.map((session) =>
+ + +
)} +
+
; +} diff --git a/src/components/home/home-typography.css b/src/components/home/home-typography.css new file mode 100644 index 0000000..014eae6 --- /dev/null +++ b/src/components/home/home-typography.css @@ -0,0 +1,20 @@ +/* Keep the opening page's hierarchy proportional to its preview area. */ +.home-content { container-type: inline-size; } +.home-heading h1 { font-size: 2.75rem; line-height: 1.12; letter-spacing: -0.025em; } +.home-heading > p { font-size: 1rem; line-height: 1.5; } +.home-recents h2 { font-size: 1.5rem; line-height: 1.25; } +.home-recents { padding-top: 1.5rem; } +@supports (font-size: 1cqi) { + .home-heading h1 { font-size: clamp(2.25rem, 1.75rem + 2cqi, 3.5rem); } + .home-heading > p { font-size: clamp(0.875rem, 0.75rem + 0.4cqi, 1.125rem); } + .home-recents h2 { font-size: clamp(1.375rem, 1rem + 1cqi, 2rem); } +} +@media (max-width: 40em) { + .home-header { padding: 1.5rem; } + .home-recents { padding: 1.5rem; } +} +.home-heading { flex: 1 0 100%; min-width: 0; } +.home-controls { flex: 1 0 100%; justify-content: flex-start; } +/* The original and Wisp designs always use the whole window, including after switching from a split preset. */ +:root:not([data-visual-theme="rapidraw"]) .home-view { display: flex; inline-size: 100%; block-size: 100%; max-inline-size: none; } +:root:not([data-visual-theme="rapidraw"]) .home-content { inline-size: 100%; min-inline-size: 0; align-self: stretch; } diff --git a/src/components/ingest/IngestView.tsx b/src/components/ingest/IngestView.tsx index 1256b9d..d7fe5a0 100644 --- a/src/components/ingest/IngestView.tsx +++ b/src/components/ingest/IngestView.tsx @@ -5,7 +5,6 @@ import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Circle, CircleCheck, Fol import { AlertDialog, AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -14,9 +13,10 @@ import { } from "@/components/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { ThumbImage } from "@/components/cull/Thumb"; -import { api, bucket, THUMB_SIZES, type IngestFolder, type IngestPolicy, type IngestProgress, type Volume } from "@/lib/api"; +import { api, bucket, THUMB_SIZES, type IngestFolder, type Volume } from "@/lib/api"; import { load, save } from "@/lib/prefs"; import { isMac } from "@/lib/shortcuts"; +import { newReview } from "@/lib/review"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import type { Scanned } from "@/lib/types"; @@ -37,7 +37,7 @@ interface Item extends Scanned { rating: number; } -type Phase = { is: "idle" | "scanning" | "ready" | "copying" } | { is: "conflict"; count: number; items: Item[] } | { is: "failed"; message: string }; +type Phase = { is: "idle" | "scanning" | "ready" } | { is: "failed"; message: string }; /** Where the source browser is: each folder gone into, outermost first. */ interface Level { @@ -48,12 +48,6 @@ interface Level { /** The way a file browser orders names: `IMG_9` before `IMG_10`. */ const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); -/** The folders a camera makes at the top of its card. Chosen whole, not gone into. */ -function isCameraMedia(name: string): boolean { - const lower = name.toLowerCase(); - return lower.startsWith("dcim") || lower === "m4root" || lower === "private" || lower === "misc"; -} - /** * When a photo was taken, for sorting and for showing. The camera's own time * once its file has been read; until then, and for a file that does not say, @@ -67,33 +61,14 @@ function timeLabel(item: Item, long = false): string { return new Date(takenAt(item)).toLocaleString(undefined, item.captureMs === null ? options : { ...options, timeZone: "UTC" }); } -function rateText(bytesPerSecond: number): string { - const mb = bytesPerSecond / 1_000_000; - if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB/s`; - if (mb >= 100) return `${mb.toFixed(0)} MB/s`; - if (mb >= 1) return `${mb.toFixed(1)} MB/s`; - return `${(bytesPerSecond / 1000).toFixed(0)} KB/s`; -} - -function timeLeftText(seconds: number): string { - if (seconds < 60) return `${Math.round(seconds)}s`; - if (seconds < 90) return "about a minute"; - return `about ${Math.round(seconds / 60)} min`; -} - -/** How long a burst of speed is remembered for, in seconds, and how often the speed is measured. */ -const RATE_MEMORY = 3; -const RATE_SAMPLE = 0.4; - -const SELECTED_ROW = "bg-[#4073f2]/45"; - /** - * Ingest: choose photos on a card, choose where they go, copy them there. - * - * Takes over the window while it is open. When the copy finishes it closes - * and the destination opens for culling. + * Choose card files, then review them in the existing culling workspace. + * Import selection and destination are decided after culling. */ +const SELECTED_ROW = "bg-[#4073f2]/45"; + export function IngestView() { + const ignoreVideos = useStore((s) => s.ignoreVideos); const startAt = useStore((s) => s.ingestFrom); const platform = useStore((s) => s.platform); @@ -102,26 +77,20 @@ export function IngestView() { const [children, setChildren] = useState([]); const [browsing, setBrowsing] = useState(false); const [source, setSource] = useState(null); + const [sources, setSources] = useState([]); + const sourceList = useRef([]); const [items, setItems] = useState([]); const [selected, setSelected] = useState>(new Set()); const [phase, setPhase] = useState({ is: "idle" }); const [sort, setSort] = useState(() => load("ingestSort", "captureTime")); const [ascending, setAscending] = useState(() => load("ingestSortAscending", true)); - const [destination, setDestination] = useState(() => load("ingestDestination", null)); - - const [progress, setProgress] = useState(null); - const [fraction, setFraction] = useState(0); - const [rate, setRate] = useState(null); - const [secondsLeft, setSecondsLeft] = useState(null); - /** Where a shift-click's range starts from, as a place in the sorted list. */ const anchor = useRef(0); /** Which listing of a folder is the wanted one; an older one arriving late is dropped. */ const listing = useRef(0); const browse = useRef(0); - const copying = phase.is === "copying"; - const busy = copying || phase.is === "scanning"; + const busy = phase.is === "scanning"; const close = useCallback(() => useStore.setState({ ingestOpen: false, ingestFrom: null }), []); @@ -139,29 +108,24 @@ export function IngestView() { }; }, []); - // A destination remembered from last time may have been moved or deleted. - useEffect(() => { - if (!destination) return; - void api.pathsExist([destination]).then(([there]) => !there && setDestination(null)); - // Once, as ingest opens. - }, []); - useEffect(() => save("ingestSort", sort), [sort]); useEffect(() => save("ingestSortAscending", ascending), [ascending]); - useEffect(() => save("ingestDestination", destination), [destination]); /** Lists the photos in a folder and makes it the one being ingested from. */ - const choose = useCallback((path: string) => { + const scanSources = useCallback((paths: string[]) => { + sourceList.current = paths; setSources(paths); + const path = paths[paths.length - 1] ?? null; const mine = ++listing.current; setSource(path); setSelected(new Set()); anchor.current = 0; setItems([]); setPhase({ is: "scanning" }); - void api.ingestScan(path).then( + void api.ingestSources(paths, ignoreVideos).then( (found) => { if (listing.current !== mine) return; setItems(found.map((f) => ({ ...f, captureMs: null, rating: 0 }))); + setSelected(new Set(found.map((_, i) => i))); setPhase({ is: "ready" }); // Capture times and in-camera stars are read afterwards, so the // card's photos are on screen without waiting for either. @@ -178,7 +142,10 @@ export function IngestView() { }, (e) => listing.current === mine && setPhase({ is: "failed", message: String(e) }), ); - }, []); + }, [ignoreVideos]); + useEffect(() => { if (source) choose(source); }, [ignoreVideos]); + + const choose = useCallback((path: string) => scanSources([...new Set([...sourceList.current, path])]), [scanSources]); /** Shows the folders inside `path`, one level further into the browser. */ const goInto = useCallback((path: string, title: string, known?: IngestFolder[]) => { @@ -195,7 +162,7 @@ export function IngestView() { } setChildren([]); setBrowsing(true); - void api.ingestBrowse(path).then( + void api.ingestBrowse(path, ignoreVideos).then( (found) => { if (browse.current !== mine) return; setChildren(found.folders); @@ -207,25 +174,21 @@ export function IngestView() { const openVolume = useCallback( (volume: Volume) => { - // A camera's card is listed whole straight away, and can still be - // looked through for one folder of it. A drive is only looked through: - // listing all of an archive would take minutes nobody asked for. - if (volume.cameraCard) choose(volume.path); + // Browse first so CARD → DCIM → 100CANON can be chosen precisely. goInto(volume.path, volume.name); }, - [choose, goInto], + [goInto], ); const openFolder = (folder: IngestFolder) => { - if (isCameraMedia(folder.name)) return choose(folder.path); const mine = ++browse.current; setBrowsing(true); - void api.ingestBrowse(folder.path).then( + void api.ingestBrowse(folder.path, ignoreVideos).then( (found) => { if (browse.current !== mine) return; setBrowsing(false); - // A folder of folders is gone into; a folder of photos is chosen. - if (found.folders.length > 0 && found.imageCount === 0) goInto(folder.path, folder.name, found.folders); + // Mixed folders may also contain a more specific source to choose. + if (found.folders.length > 0) goInto(folder.path, folder.name, found.folders); else choose(folder.path); }, () => browse.current === mine && setBrowsing(false), @@ -241,7 +204,7 @@ export function IngestView() { const browseForFolder = async () => { const picked = await api.pickFolder("Choose a folder of photos to ingest"); - if (picked) choose(picked); + if (picked) openFolder({ path: picked, name: picked.split(/[\\/]/).filter(Boolean).pop() ?? picked, imageCount: 0 }); }; // Opened from "card detected": go straight to that card once it is listed. @@ -312,101 +275,36 @@ export function IngestView() { // ── Copying ─────────────────────────────────────────────────────────────── - const chooseDestination = async () => { - const picked = await api.pickFolder("Where should OpenCull copy the selected photos?", destination ?? undefined); - if (picked) setDestination(picked); - }; - - const canIngest = selected.size > 0 && destination !== null && !busy; - const sample = useRef({ at: 0, bytes: 0, rate: null as number | null }); + const canIngest = selected.size > 0 && !busy; const requestIngest = async () => { - if (!canIngest || !destination) return; + if (!canIngest || !source) return; + close(); const chosen = order.filter((i) => selected.has(i)).map((i) => items[i]); - const taken = await api.ingestConflicts(chosen.map(({ path, display }) => ({ path, display })), destination).catch(() => 0); - if (taken > 0) setPhase({ is: "conflict", count: taken, items: chosen }); - // Nothing is in the way, so the answer to "what if something is" is - // never used — but if it were, it must not be to replace it. - else void copy(chosen, "uniqueName"); - }; + await useStore.getState().openFolder(source, newReview(sources, chosen)); - const copy = async (chosen: Item[], policy: IngestPolicy) => { - if (!destination) return; - setPhase({ is: "copying" }); - setProgress({ done: 0, total: chosen.length, bytesCopied: 0, bytesSettled: 0, bytesTotal: 0 }); - setFraction(0); - setRate(null); - setSecondsLeft(null); - sample.current = { at: performance.now() / 1000, bytes: 0, rate: null }; - - const onProgress = (p: IngestProgress) => { - setProgress(p); - // By bytes, so a card of large files does not sit still and then - // jump. Never backwards: reports from copies running side by side can - // arrive a little out of order. - const now = p.bytesTotal > 0 ? p.bytesSettled / p.bytesTotal : p.total > 0 ? p.done / p.total : 0; - setFraction((was) => Math.min(1, Math.max(was, now))); - - const s = sample.current; - const t = performance.now() / 1000; - const elapsed = t - s.at; - if (elapsed < RATE_SAMPLE) return; - const measured = (p.bytesCopied - s.bytes) / elapsed; - // Smoothed, so one slow file does not swing the estimate about. - const weight = 1 - Math.exp(-elapsed / RATE_MEMORY); - const smoothed = s.rate === null ? measured : s.rate + weight * (measured - s.rate); - sample.current = { at: t, bytes: p.bytesCopied, rate: smoothed }; - setRate(smoothed); - setSecondsLeft(smoothed > 0 ? Math.max(0, p.bytesTotal - p.bytesSettled) / smoothed : null); - }; - - let result; - try { - result = await api.ingestCopy(chosen.map(({ path, display }) => ({ path, display })), destination, policy, onProgress); - } catch (e) { - result = { status: "failed" as const, message: String(e) }; - } - setProgress(null); - if (result.status === "failed") return setPhase({ is: "failed", message: result.message ?? "Something went wrong while copying." }); - setPhase({ is: "ready" }); - if (result.status === "success") { - // Straight on to culling what was just copied. - close(); - void useStore.getState().openFolder(destination); - } - }; - - const cancelCopy = () => { - void api.ingestCancel(); - setPhase((p) => (p.is === "copying" ? { is: "ready" } : p)); - setProgress(null); }; - const status = (() => { - if (!progress) return ""; - const parts = [`Copying ${progress.done}/${progress.total}`]; - if (rate !== null && rate > 0) parts.push(rateText(rate)); - if (secondsLeft !== null && Number.isFinite(secondsLeft) && secondsLeft >= 1) parts.push(`${timeLeftText(secondsLeft)} left`); - return parts.length === 1 ? `${parts[0]}…` : parts.join(" · "); - })(); - const cards = volumes.filter((v) => v.cameraCard); const drives = volumes.filter((v) => !v.cameraCard); const current = levels[levels.length - 1]; return ( -
-
- - Ingest from Card - +
+
+
+

Ingest from Card · {sources.length} source{sources.length === 1 ? "" : "s"}

+

+ Review first.{" "} + Cull the card in the normal workspace. At the end, choose files to copy or import in place. Nothing is copied or deleted now. +

+
@@ -414,15 +312,22 @@ export function IngestView() {
- {/* Where from. */} -
-
Files to be copied (ingested) to destination folder
+
Review these files before choosing what to import
{phase.is === "scanning" ? ( @@ -511,71 +416,25 @@ export function IngestView() { )} - {copying && progress && ( -
-
-
-
-
{status}
- -
- )}
- - - {/* Where to. */} -
+
- -
- - !open && setPhase((p) => (p.is === "conflict" ? { is: "ready" } : p))}> - - - Files Already Exist - - {phase.is === "conflict" && - `${phase.count} selected file${phase.count === 1 ? "" : "s"} already exist in the destination, or share a name with another selected file. Apply one action to all conflicts.`} - - - - {( - [ - ["uniqueName", "Copy with Unique Name"], - ["overwrite", "Overwrite"], - ["skip", "Skip Existing"], - ] as [IngestPolicy, string][] - ).map(([policy, label]) => ( - phase.is === "conflict" && void copy(phase.items, policy)}> - {label} - - ))} - Cancel - - - + !open && setPhase({ is: "ready" })}> - Ingest Failed + Could not open card - {phase.is === "failed" && `${phase.message}\n\nKeep the card as it is. Photos copied before this one are in the destination; nothing after it was started.`} + {phase.is === "failed" && `${phase.message}\n\nKeep the card as it is. No files were copied or deleted.`} @@ -590,20 +449,20 @@ export function IngestView() { const Rule = () =>
; function Label({ className, children }: { className?: string; children: ReactNode }) { - return
{children}
; + return
{children}
; } function Hint({ children }: { children: ReactNode }) { - return
{children}
; + return
{children}
; } function Centre({ children }: { children: ReactNode }) { - return
{children}
; + return
{children}
; } function TextButton({ disabled, onClick, children }: { disabled: boolean; onClick: () => void; children: ReactNode }) { return ( - ); @@ -611,7 +470,7 @@ function TextButton({ disabled, onClick, children }: { disabled: boolean; onClic function Row({ selected, onClick, className, children }: { selected?: boolean; onClick: () => void; className?: string; children: ReactNode }) { return ( - ); @@ -620,9 +479,9 @@ function Row({ selected, onClick, className, children }: { selected?: boolean; o function VolumeRow({ volume, selected, onClick }: { volume: Volume; selected: boolean; onClick: () => void }) { return ( - {volume.cameraCard ? : } + {volume.cameraCard ? : } {volume.name} - {!volume.cameraCard && } + ); } @@ -722,15 +581,15 @@ const Cell = memo(function Cell({ }) { return ( ); }); diff --git a/src/components/ingest/ReviewBar.tsx b/src/components/ingest/ReviewBar.tsx new file mode 100644 index 0000000..6579c51 --- /dev/null +++ b/src/components/ingest/ReviewBar.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { initialSelection, selectedReview } from "@/lib/review"; +import { copyReview, loadReview, saveReview } from "@/lib/review-api"; +import { useStore } from "@/lib/store"; + +export function ResumeCardSession({ className }: { className?: string }) { + const [available, setAvailable] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { void loadReview().then((r) => setAvailable(r !== null)).catch((e) => setError(String(e))); }, []); + const resume = async () => { + try { + const review = await loadReview(); if (!review) return; + const paths = review.items.flatMap((p) => [p.path, p.display]); + const present = await api.pathsExist(paths); + if (present.some((p) => !p)) throw new Error("Reconnect the source cards before resuming this session."); + await useStore.getState().openFolder(review.sources[0], review); + } catch (e) { setError(String(e)); } + }; + return <>{available && }{error &&

{error}

}; +} + +export function ReviewBar() { + const review = useStore((s) => s.review); + const photos = useStore((s) => s.photos); + const loading = useStore((s) => s.loading); + const [open, setOpen] = useState(false); + const [chosen, setChosen] = useState([]); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(0); + const [error, setError] = useState(""); + if (!review) return null; + const finish = () => { setChosen(review.chosen ?? initialSelection(photos)); setError(""); setOpen(true); }; + const selected = selectedReview(photos, chosen); + const setSelection = (next: string[]) => { + setChosen(next); useStore.setState({ review: { ...review, chosen: next } }); + }; + const inPlace = async () => { + try { + const next = { ...review, items: selected, chosen, stage: "in-place" as const }; + await saveReview(next); setOpen(false); + await useStore.getState().openFolder(review.sources[0], next); + } catch (e) { setError(String(e)); } + }; + const copy = async () => { + const destination = review.destination ?? await api.pickFolder("Copy selected card files to:"); if (!destination) return; + setBusy(true); setError(""); setDone(0); + useStore.setState({ review: { ...review, destination } }); + try { + const recorded = selected.filter((p) => review.completed[p.path]); + const present = await api.pathsExist(recorded.map((p) => review.completed[p.path])); + const complete = new Set(recorded.filter((_, i) => present[i]).map((p) => p.path)); + const pending = selected.filter((p) => !complete.has(p.path)); + const result = await copyReview(pending, destination, review.sources, setDone); + const next = { ...review, destination, chosen, completed: { ...review.completed, ...result.completed } }; + useStore.setState({ review: next }); await saveReview(next); + if (result.failed.length || result.sidecarFailed.length) throw new Error([...result.failed, ...result.sidecarFailed].join("\n")); + if (pending.some((p) => !next.completed[p.path])) throw new Error("Copy cancelled. Completed files are remembered; you can retry the remaining files."); + const destinations = selected.map((p) => next.completed[p.path]); + const copied = (await api.ingestScan(destination, useStore.getState().ignoreVideos)).filter((p) => destinations.includes(p.path)); + setOpen(false); + const imported = { ...next, sources: [destination], items: copied, chosen: copied.map((p) => p.path), decisions: {}, completed: {}, stage: "in-place" as const }; + await useStore.getState().openFolder(destination, imported); + } catch (e) { setError(String(e)); } finally { setBusy(false); } + }; + return <> +
+ {review.stage === "review" ? "Card review — source files stay untouched" : "Imported in place — only chosen files"} + +
+ !busy && setOpen(value)}> + busy && e.preventDefault()}> + Choose files to importStarts with all non-rejected photos. Copy keeps every source file. Import in place opens only chosen files without copying or writing to the cards. +
+
+ {photos.map((p) => )} +
+

{selected.length} selected{busy ? ` · ${done} copied` : ""}

+ {error &&

{error}

} +
+ {busy ? : <>} +
+
+
+ ; +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 600a6e0..ed72190 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -123,7 +123,7 @@ function AlertDialogTitle({ ) { - return + return } function TabsTrigger({ className, ...props }: React.ComponentProps) { @@ -15,7 +15,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps; - openFolder(path: string, includeSubfolders: boolean, kinds: FileKinds): Promise; + openFolder(path: string, includeSubfolders: boolean, kinds: FileKinds, members?: Scanned[], ignoreVideos?: boolean): Promise; closeFolder(session: number): Promise; loadMetadata(session: number, onEvent: (e: MetaEvent) => void): Promise; prefetch(items: { path: string; version: string }[], size: number): Promise; @@ -169,8 +169,9 @@ export interface Backend { /** Opens a release's page in the browser, or the list of releases. */ openReleasePage(version: string | null): Promise; ingestVolumes(): Promise; - ingestBrowse(path: string): Promise<{ folders: IngestFolder[]; imageCount: number }>; - ingestScan(path: string): Promise; + ingestBrowse(path: string, ignoreVideos?: boolean): Promise<{ folders: IngestFolder[]; imageCount: number }>; + ingestScan(path: string, ignoreVideos?: boolean): Promise; + ingestSources(sources: string[], ignoreVideos?: boolean): Promise; /** Reads capture times and in-camera stars, in batches. Asking again stops the last asking. */ ingestDetails(paths: string[], onFound: (found: IngestDetail[]) => void): Promise; ingestConflicts(items: { path: string; display: string }[], destination: string): Promise; @@ -209,7 +210,7 @@ const previewBase = /Windows|Android/.test(navigator.userAgent) ? "http://opencu const tauri: Backend = { appInfo: () => invoke("app_info"), - openFolder: (path, includeSubfolders, kinds) => invoke("open_folder", { path, includeSubfolders, kinds }), + openFolder: (path, includeSubfolders, kinds, members, ignoreVideos) => invoke("open_folder", { path, includeSubfolders, kinds, members, ignoreVideos }), closeFolder: (session) => invoke("close_folder", { session }), loadMetadata: (session, onEvent) => { const channel = new Channel(); @@ -253,8 +254,9 @@ const tauri: Backend = { }, openReleasePage: (version) => invoke("open_release_page", { version }), ingestVolumes: () => invoke("ingest_volumes"), - ingestBrowse: (path) => invoke("ingest_browse", { path }), - ingestScan: (path) => invoke("ingest_scan", { path }), + ingestBrowse: (path, ignoreVideos) => invoke("ingest_browse", { path, ignoreVideos }), + ingestScan: (path, ignoreVideos) => invoke("ingest_scan", { path, ignoreVideos }), + ingestSources: (sources, ignoreVideos) => invoke("ingest_sources", { sources, ignoreVideos }), ingestDetails: (paths, onFound) => { const found = new Channel(); found.onmessage = onFound; diff --git a/src/lib/faces.ts b/src/lib/faces.ts index a642493..b4cdd0b 100644 --- a/src/lib/faces.ts +++ b/src/lib/faces.ts @@ -1,3 +1,4 @@ +import { isVideo } from "./media"; // Runs face analysis over the open folder, in the background. import { useEffect, useRef } from "react"; @@ -59,7 +60,7 @@ export function useFaceScan(): void { if (at + d < s.view.length) near.push(s.view[at + d]); if (at - d >= 0) near.push(s.view[at - d]); } - void api.aiLookAt(near.filter((index) => s.ai[index] === undefined)).catch(() => {}); + void api.aiLookAt(near.filter((index) => s.ai[index] === undefined && !isVideo(s.photos[index].path))).catch(() => {}); }; const stop = useStore.subscribe((s) => { if (s.current === last) return; @@ -88,7 +89,7 @@ export function useFaceScan(): void { const { order, current, photos } = useStore.getState(); if (photos.length === 0) return; const here = Math.max(0, order.indexOf(current)); - const nearest = order.map((index, place) => ({ index, far: Math.abs(place - here) })).sort((a, b) => a.far - b.far).map((o) => o.index); + const nearest = order.map((index, place) => ({ index, far: Math.abs(place - here) })).sort((a, b) => a.far - b.far).map((o) => o.index).filter((i) => !isVideo(photos[i].path)); useStore.setState({ aiRunning: true }); // A scan that has been stopped may still have answers on their way. diff --git a/src/lib/font.test.ts b/src/lib/font.test.ts new file mode 100644 index 0000000..2ff9300 --- /dev/null +++ b/src/lib/font.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { fontStack, validFont } from "./font"; + +describe("font selection", () => { + it("keeps the system default and a bundled offline choice", () => { + expect(fontStack("system", "")).toContain("sans-serif"); + expect(fontStack("space-grotesk", "")).toMatch(/^"Space Grotesk"/); + }); + it("treats custom families as one quoted family, with system fallback", () => { + expect(fontStack("custom", "A, B")).toMatch(/^"A, B",/); + expect(fontStack("custom", "")).toBe(fontStack("system", "")); + expect(validFont({ choice: "bad", family: "" }).choice).toBe("system"); + expect(validFont(null).choice).toBe("system"); + }); +}); diff --git a/src/lib/font.ts b/src/lib/font.ts new file mode 100644 index 0000000..be87593 --- /dev/null +++ b/src/lib/font.ts @@ -0,0 +1,16 @@ +export type FontChoice = "system" | "space-grotesk" | "custom"; +export interface FontSetting { choice: FontChoice; family: string } +const SYSTEM = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + +export function validFont(value: unknown): FontSetting { + const v = value as Partial | null; + return { + choice: v && ["system", "space-grotesk", "custom"].includes(String(v.choice)) ? v.choice as FontChoice : "system", + family: typeof v?.family === "string" ? v.family.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200) : "", + }; +} + +export function fontStack(choice: FontChoice, family: string): string { + const name = choice === "space-grotesk" ? "Space Grotesk" : choice === "custom" ? family.trim() : ""; + return name ? `${JSON.stringify(name)}, ${SYSTEM}` : SYSTEM; +} diff --git a/src/lib/interface-scale.test.ts b/src/lib/interface-scale.test.ts new file mode 100644 index 0000000..4cd6b99 --- /dev/null +++ b/src/lib/interface-scale.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from "vitest"; +import { INTERFACE_SCALES, validInterfaceScale } from "./interface-scale"; +it("accepts supported scales including 200% and rejects corrupt or unsafe saved values", () => { + for (const value of INTERFACE_SCALES) expect(validInterfaceScale(value)).toBe(value); + for (const value of [null, "1.5", 0, -1, Infinity, NaN, 10, {}, 1.234]) expect(validInterfaceScale(value)).toBe(1); +}); diff --git a/src/lib/interface-scale.ts b/src/lib/interface-scale.ts new file mode 100644 index 0000000..1a544f5 --- /dev/null +++ b/src/lib/interface-scale.ts @@ -0,0 +1,4 @@ +export const INTERFACE_SCALES = [0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const; +export function validInterfaceScale(value: unknown): number { + return typeof value === "number" && INTERFACE_SCALES.some((scale) => scale === value) ? value : 1; +} diff --git a/src/lib/linux-features.selftest.ts b/src/lib/linux-features.selftest.ts new file mode 100644 index 0000000..0c1e211 --- /dev/null +++ b/src/lib/linux-features.selftest.ts @@ -0,0 +1,84 @@ +// Integration-only native checks. All copies land in the self-test scratch folder. +import { invoke, convertFileSrc } from "@tauri-apps/api/core"; +import { api } from "./api"; +import { isVideo } from "./media"; +import { newReview } from "./review"; +import { copyReview, loadReview, saveReview } from "./review-api"; +import { useStore } from "./store"; + +const assert = (ok: unknown, why: string) => { if (!ok) throw new Error(why); }; +const say = (s: string) => api.report(`features: ${s}`); +async function until(check: () => boolean, message: string, limit = 10_000) { + const start = performance.now(); + while (!check()) { if (performance.now() - start > limit) throw new Error(message); await api.pause(20); } +} + +export async function checkLinuxFeatures(folder: string, scratch: string) { + const s = useStore.getState; + const configured = await invoke>("config_load", { legacy: {} }); + assert(configured && typeof configured === "object", "Native config did not load"); + if (typeof configured.theme === "string") assert(document.documentElement.dataset.theme === configured.theme, "Theme did not hydrate before startup"); + await document.fonts.load('14px "Space Grotesk"'); + assert(document.fonts.check('14px "Space Grotesk"'), "Offline Space Grotesk did not load"); + say("native configuration and offline font loaded"); + + const sources = Array.isArray(configured.selfTestSources) && configured.selfTestSources.every((p) => typeof p === "string") ? configured.selfTestSources as string[] : [folder]; + const items = await api.ingestSources([...sources, sources[0]], false); + if (sources.length > 1) assert(items.some((p) => p.path !== p.display), "Separate RAW/JPEG cards did not produce paired frames"); + assert(new Set(items.map((p) => p.path)).size === items.length, "Overlapping sources duplicated a file"); + const stills = items.filter((p) => !isVideo(p.path)); + assert(stills.length >= 2, "Feature checks need two still images"); + const before = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); + const review = newReview(sources, stills.slice(0, 2)); + await s().openFolder(sources[0], review); + await until(() => s().metaProgress >= 1, "Card metadata did not finish"); + s().setFlag("reject", [0]); s().setRating(4, [1]); + await saveReview(s().review!); + const restored = await loadReview(); + assert(restored?.decisions[stills[0].path].flag === "reject", "Local rejection did not persist"); + assert(restored?.decisions[stills[1].path].rating === 4, "Local rating did not persist"); + const copied = await copyReview([s().photos[1]], scratch, sources, () => {}); + assert(copied.failed.length === 0 && copied.sidecarFailed.length === 0, "Selected card copy failed"); + assert(Object.keys(copied.completed).length === 1, "Import copied the wrong membership"); + const after = await api.pathsExist(stills.map((p) => p.path.replace(/\.[^./\\]+$/, ".xmp"))); + assert(JSON.stringify(before) === JSON.stringify(after), "Review wrote a sidecar onto the source"); + const chosen = { ...s().review!, items: [stills[1]], chosen: [stills[1].path], stage: "in-place" as const }; + await s().openFolder(sources[0], chosen); + await until(() => s().metaProgress >= 1, "In-place metadata did not finish"); + assert(s().photos.length === 1 && s().photos[0].rating === 4, "In-place import did not preserve selected membership and marks"); + say("card review, local decisions, selected copying, and in-place membership passed"); + + s().set("settingsOpen", true); + await until(() => !!document.querySelector('select[aria-label="Theme"]'), "Appearance settings did not render"); + const theme = document.querySelector('select[aria-label="Theme"]')!; + const original = theme.value; theme.value = "light"; theme.dispatchEvent(new Event("change", { bubbles: true })); + await until(() => document.documentElement.dataset.theme === "light", "Theme control did not apply"); + theme.value = original; theme.dispatchEvent(new Event("change", { bubbles: true })); + const layout = [...document.querySelectorAll('[role="tab"]')].find((tab) => tab.textContent?.includes("Layout")); + assert(layout, "Layout tab missing"); layout!.focus(); layout!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + await until(() => !!document.querySelector('[aria-label="Face panel width"]'), "Layout size controls missing"); + s().set("settingsOpen", false); + say("settings render and theme interaction passed"); + + const video = items.find((p) => p.path.toLowerCase().endsWith(".mp4")); + if (video) { + s().set("ignoreVideos", false); + await s().openFolder(sources[0], newReview(sources, [video])); + await until(() => s().metaProgress >= 1, "Video metadata did not finish"); + const allowed = await invoke("video_source", { path: video.path }); + const player = document.createElement("video"); + say(`codec support MP4=${player.canPlayType('video/mp4; codecs="avc1.64001e"')} WebM=${player.canPlayType('video/webm; codecs="vp8"')}`); player.muted = true; player.preload = "auto"; player.src = allowed.startsWith("http://127.0.0.1:") ? allowed : convertFileSrc(allowed); document.body.append(player); + try { + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => reject(new Error("Video metadata timed out")), 10_000); + player.onloadedmetadata = () => { clearTimeout(timeout); resolve(); }; + player.onerror = () => { clearTimeout(timeout); reject(new Error(`System could not decode the MP4 fixture (${player.error?.message})`)); }; + }); + assert(player.duration > 0, "Video has no duration"); + player.currentTime = Math.min(1, player.duration / 2); + await until(() => !player.seeking, "Video seeking failed"); + await player.play(); await api.pause(200); player.pause(); assert(player.currentTime > 0, "Video did not advance"); + say(`native video metadata, seeking and playback passed (${player.duration.toFixed(1)}s)`); + } finally { player.pause(); player.removeAttribute("src"); player.load(); player.remove(); } + } else say("video fixture absent; native playback check skipped"); +} diff --git a/src/lib/media.test.ts b/src/lib/media.test.ts new file mode 100644 index 0000000..e078a3f --- /dev/null +++ b/src/lib/media.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from "vitest"; +import { isVideo } from "./media"; +it("recognizes camera video filenames without mistaking stills", () => { + for (const p of ["A.MOV", "a.mp4", "/card/B.MTS", "c.m2ts", "d.webm", "e.mkv"]) expect(isVideo(p)).toBe(true); + for (const p of ["a.ARW", "b.JPG", "photo.mp4.jpg", "/video.mp4/photo.raw"]) expect(isVideo(p)).toBe(false); +}); diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 0000000..e33d851 --- /dev/null +++ b/src/lib/media.ts @@ -0,0 +1,4 @@ +const VIDEO_EXTENSIONS = new Set(["mp4", "mov", "m4v", "avi", "mkv", "webm", "mts", "m2ts", "mpg", "mpeg", "3gp"]); +export function isVideo(path: string): boolean { + return VIDEO_EXTENSIONS.has(path.split(/[\\/]/).pop()?.split(".").pop()?.toLowerCase() ?? ""); +} diff --git a/src/lib/mock.ts b/src/lib/mock.ts index e8fafe6..712fff9 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -5,6 +5,7 @@ import type { Backend } from "./api"; import type { Exif, Meta, Scanned } from "./types"; const COUNT = 240; +let activeImages: Scanned[]; const images: Scanned[] = Array.from({ length: COUNT }, (_, i) => { const name = `A7V${String(2751 + i).padStart(5, "0")}.ARW`; @@ -55,7 +56,7 @@ export const mock: Backend = { appInfo: () => later({ platform: "macos", version: "dev", cacheDir: "/tmp/opencull", openPath: null, selfTest: false }), // Untimed, so the slower path — filename order first, shooting order once // the metadata is in — is the one a browser tab exercises. - openFolder: () => later({ session: 1, images, subfolderCount: 2, leftOut: 0, times: images.map(() => null), timed: false, groups: null }, 150), + openFolder: (_path, _include, _kinds, members) => { activeImages = members ?? images; return later({ session: 1, images: activeImages, subfolderCount: 2, leftOut: 0, times: activeImages.map(() => null), timed: false, groups: null }, 150); }, closeFolder: () => later(undefined), loadMetadata: async (_session, onEvent) => { // Bursts of one to six frames, a few seconds apart. @@ -64,9 +65,9 @@ export const mock: Backend = { const items: Meta[] = []; let i = 0; let group = 0; - while (i < COUNT) { + while (i < activeImages.length) { const run = 1 + ((i * 7) % 6); - for (let k = 0; k < run && i < COUNT; k++, i++) { + for (let k = 0; k < run && i < activeImages.length; k++, i++) { ms += k === 0 ? 4000 + (i % 5) * 3000 : 120; const portrait = i % 7 === 3; items.push({ @@ -199,11 +200,14 @@ export const mock: Backend = { ]), ingestBrowse: (path) => later( - path.endsWith("100MSDCF") - ? { folders: [], imageCount: images.length } - : { folders: [{ path: `${path}/DCIM`, name: "DCIM", imageCount: 0 }, { path: `${path}/Weddings`, name: "Weddings", imageCount: 0 }], imageCount: 0 }, + path.endsWith("DCIM") + ? { folders: [{ path: `${path}/100CANON`, name: "100CANON", imageCount: images.length }], imageCount: 0 } + : path.endsWith("100CANON") || path.endsWith("Weddings") + ? { folders: [], imageCount: images.length } + : { folders: [{ path: `${path}/DCIM`, name: "DCIM", imageCount: 0 }, { path: `${path}/Weddings`, name: "Weddings", imageCount: 0 }], imageCount: 0 }, 200, ), + ingestSources: (_sources) => later(images.slice(0, 60)), ingestScan: () => later(images, 300), ingestDetails: async (paths, onFound) => { for (let at = 0; at < paths.length; at += 64) { diff --git a/src/lib/prefs.test.ts b/src/lib/prefs.test.ts new file mode 100644 index 0000000..d77f266 --- /dev/null +++ b/src/lib/prefs.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { load } from "./prefs"; +beforeEach(() => localStorage.clear()); +describe("preference validation", () => { + it("does not hydrate a boolean or dimension from invalid config types", () => { + localStorage.setItem("opencull.showExif", '"false"'); + localStorage.setItem("opencull.facePanelWidth", '"wide"'); + expect(load("showExif", false)).toBe(false); + expect(load("facePanelWidth", 340)).toBe(340); + }); + it("recovers from malformed legacy values", () => { + localStorage.setItem("opencull.theme", "{oops"); + expect(load("theme", "dark")).toBe("dark"); + }); +}); diff --git a/src/lib/prefs.ts b/src/lib/prefs.ts index 32bd3cb..20e2e65 100644 --- a/src/lib/prefs.ts +++ b/src/lib/prefs.ts @@ -1,21 +1,48 @@ -// Small settings that outlive the app, kept in the web view's own storage. - +// Shared preferences interface; native files are hydrated before the app imports. +import { invoke } from "@tauri-apps/api/core"; const PREFIX = "opencull."; +let nativeValues: Record | null = null; +let writes = Promise.resolve(); + +function report(error: unknown) { + console.error("Preferences could not be saved", String(error)); + let message = document.getElementById("config-error"); + if (!message) { + message = document.createElement("div"); message.id = "config-error"; + message.setAttribute("role", "alert"); + message.style.cssText = "position:fixed;bottom:0;left:0;right:0;z-index:1000;padding:12px;background:#5c2020;color:white;font:13px sans-serif"; + document.body.append(message); + } + message.textContent = `Settings file unavailable: ${String(error)}. Changes remain in this app's local storage. Repair the file or permissions and restart to retry.`; +} + +export async function initializePreferences(): Promise { + if (!("__TAURI_INTERNALS__" in window)) return; + const legacy: Record = {}; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(PREFIX) || key.startsWith(`${PREFIX}session.`)) continue; + try { legacy[key.slice(PREFIX.length)] = JSON.parse(localStorage.getItem(key)!); } catch { /* Malformed legacy values are ignored. */ } + } + nativeValues = await invoke>("config_load", { legacy }); + } catch (error) { report(error); } +} export function load(key: string, fallback: T): T { try { - const raw = localStorage.getItem(PREFIX + key); - return raw === null ? fallback : (JSON.parse(raw) as T); - } catch { - return fallback; - } + const raw = nativeValues && Object.hasOwn(nativeValues, key) ? nativeValues[key] : JSON.parse(localStorage.getItem(PREFIX + key) ?? "null"); + if (raw === null || raw === undefined) return fallback; + if (fallback !== null && (typeof raw !== typeof fallback || Array.isArray(raw) !== Array.isArray(fallback))) return fallback; + if (typeof raw === "number" && !Number.isFinite(raw)) return fallback; + return raw as T; + } catch { return fallback; } } export function save(key: string, value: unknown): void { - try { - localStorage.setItem(PREFIX + key, JSON.stringify(value)); - } catch { - // Storage full or unavailable. A setting that does not stick is not worth - // interrupting anyone over. + try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); } catch (error) { report(error); } + if (nativeValues && !key.startsWith("session.")) { + nativeValues[key] = value; + writes = writes.catch(() => {}).then(() => invoke("config_set", { key, value })).catch(report); } } diff --git a/src/lib/preview-aspect.ts b/src/lib/preview-aspect.ts new file mode 100644 index 0000000..40574a3 --- /dev/null +++ b/src/lib/preview-aspect.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import type { Scanned } from "./types"; +const ratios = new Map(); +const useRevision = create<{ revision: number }>(() => ({ revision: 0 })); +let frame: number | null = null; +const key = (photo: Pick) => JSON.stringify([photo.display, photo.version]); +export const previewAspect = (photo: Pick | undefined) => photo ? ratios.get(key(photo)) : undefined; +export function usePreviewAspectRevision(enabled: boolean) { return useRevision((s) => enabled ? s.revision : 0); } +/** Preview JPEGs are already upright. Never substitute their dimensions for full-resolution metadata. */ +export function rememberPreviewAspect(photo: Pick, image: HTMLImageElement) { + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) return; + const ratio = image.naturalWidth / image.naturalHeight; + const id = key(photo); + if (ratios.get(id) === ratio) return; + ratios.set(id, ratio); + if (ratios.size > 20000) ratios.delete(ratios.keys().next().value!); + // Many previews finish together: update layout once per frame. + if (frame === null) frame = requestAnimationFrame(() => { frame = null; useRevision.setState((s) => ({ revision: s.revision + 1 })); }); +} diff --git a/src/lib/review-api.ts b/src/lib/review-api.ts new file mode 100644 index 0000000..cb9dcf6 --- /dev/null +++ b/src/lib/review-api.ts @@ -0,0 +1,51 @@ +import { Channel, invoke } from "@tauri-apps/api/core"; +import { inTauri, type Shipped } from "./api"; +import type { ReviewSession } from "./review"; +import type { Photo } from "./types"; +import type { RecentSession } from "./session-history"; +let saving = Promise.resolve(); +export async function saveReview(review: ReviewSession): Promise { + const snapshot = { ...JSON.parse(JSON.stringify(review)), updatedAt: Date.now() }; + if (!inTauri) { + const history = browserHistory().filter((r) => r.id !== snapshot.id); + localStorage.setItem("opencull.session.history", JSON.stringify([snapshot, ...history])); + localStorage.setItem("opencull.session.latest", JSON.stringify(snapshot)); + window.dispatchEvent(new Event("opencull:sessions-changed")); return; + } + saving = saving.catch(() => {}).then(() => invoke("review_save", { value: snapshot })); + await saving; window.dispatchEvent(new Event("opencull:sessions-changed")); +} +function browserHistory(): ReviewSession[] { + const history = JSON.parse(localStorage.getItem("opencull.session.history") ?? "[]"); + const latest = JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); + const values: ReviewSession[] = Array.isArray(history) ? history : []; + return latest && !values.some((v) => v.id === latest.id) ? [latest,...values] : values; +} +export async function listReviews(): Promise { + if (inTauri) return invoke("review_list"); + return browserHistory().filter((r) => r?.version === 1 && Array.isArray(r.items) && Array.isArray(r.sources)).map((r) => ({ + id:r.id ?? "latest",name:r.name ?? "Card session",sources:r.sources,count:r.items.length,updatedAt:r.updatedAt ?? (Number(r.id) || 0),stage:r.stage,cover:r.items[0] ?? null, + })).sort((a,b) => b.updatedAt-a.updatedAt).slice(0,24); +} +export async function removeReviews(ids: string[]): Promise { + if (inTauri) { + saving = saving.catch(() => {}).then(() => invoke("review_remove", { ids })); + await saving; + } else { + const kept = browserHistory().filter((r) => !ids.includes(r.id ?? "latest")).sort((a,b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + localStorage.setItem("opencull.session.history", JSON.stringify(kept)); + if (kept.length) localStorage.setItem("opencull.session.latest", JSON.stringify(kept[0])); + else localStorage.removeItem("opencull.session.latest"); + } + window.dispatchEvent(new Event("opencull:sessions-changed")); +} +export async function loadReview(id?: string): Promise { + const value: ReviewSession | null = inTauri ? await invoke("review_load", { id }) : id ? browserHistory().find((r) => (r.id ?? "latest") === id) ?? null : JSON.parse(localStorage.getItem("opencull.session.latest") ?? "null"); + if (value && (value.version !== 1 || !Array.isArray(value.items) || !Array.isArray(value.sources) || typeof value.decisions !== "object")) throw new Error("Saved card session is not supported"); + return value; +} +export async function copyReview(items: Photo[], destination: string, sources: string[], onProgress: (done: number) => void): Promise }> { + if (!inTauri) throw new Error("Copying requires the desktop application"); + const progress = new Channel(); progress.onmessage = onProgress; + return invoke("review_copy", { items: items.map((p) => ({ path: p.path, display: p.display, marks: { rating: p.rating, flag: p.flag, color: p.color }, framing: p.framing, upright: [p.fullWidth, p.fullHeight] })), destination, sources, progress }); +} diff --git a/src/lib/review-store.test.ts b/src/lib/review-store.test.ts new file mode 100644 index 0000000..f301afc --- /dev/null +++ b/src/lib/review-store.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const api = vi.hoisted(() => ({ setMarks: vi.fn(), setFraming: vi.fn(), moveToTrash: vi.fn().mockResolvedValue([]) })); +vi.mock("./api", () => ({ api, inTauri: false })); +import { useStore } from "./store"; +import { newReview } from "./review"; +import { PLAIN } from "./framing"; +import type { Photo } from "./types"; +const photo: Photo = { path: "/card/A.JPG", display: "/card/A.JPG", name: "A.JPG", subpath: null, version: "1", size: 10, modified: 0, index: 0, rating: 0, flag: "none", color: "none", exif: null, captureMs: null, fullWidth: 100, fullHeight: 100, framing: PLAIN, framable: true, group: null }; +beforeEach(() => { + vi.clearAllMocks(); + useStore.setState({ review: newReview(["/card"], [photo]), photos: [{ ...photo }], current: 0, order: [0], view: [0], loading: false, undoStack: [], redoStack: [], selected: new Set(), selfTest: false, embedInJpeg: true }); +}); +describe("source-safe review", () => { + it("keeps flags and undo local even when embedded JPEG writing is enabled", () => { + useStore.getState().setFlag("reject"); + expect(useStore.getState().review?.decisions[photo.path].flag).toBe("reject"); + expect(api.setMarks).not.toHaveBeenCalled(); + useStore.getState().undo(); + expect(useStore.getState().photos[0].flag).toBe("none"); + expect(api.setMarks).not.toHaveBeenCalled(); + }); + it("keeps crops local", () => { + useStore.getState().setFraming(0, { turn: 6, crop: null }); + expect(useStore.getState().review?.decisions[photo.path].framing.turn).toBe(6); + expect(api.setFraming).not.toHaveBeenCalled(); + }); + it("normal folder marks still write sidecars", () => { + useStore.setState({ review: null }); useStore.getState().setRating(3); + expect(api.setMarks).toHaveBeenCalledOnce(); + }); + it("removes explicitly deleted files from the saved session", async () => { + useStore.setState({ session: { id: 1, folder: "/card", name: "card", includeSubfolders: false, subfolderCount: 0, fileKinds: "both", leftOut: 0, timed: true } }); + useStore.getState().requestTrash([0]); await useStore.getState().confirmTrash(); + expect(useStore.getState().review?.items).toEqual([]); + }); + it("always confirms source deletion during card review", () => { + useStore.setState({ askBeforeTrash: false }); useStore.getState().requestTrash([0]); + expect(useStore.getState().trashPending).toEqual([0]); + }); +}); diff --git a/src/lib/review.test.ts b/src/lib/review.test.ts new file mode 100644 index 0000000..7244445 --- /dev/null +++ b/src/lib/review.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { initialSelection, selectedReview, sourceWritable } from "./review"; +const files = [{ path: "/card/A.RAW", flag: "none" }, { path: "/card/B.RAW", flag: "reject" }, { path: "/card/C.JPG", flag: "pick" }] as const; +describe("card review decisions", () => { + it("initially selects all non-rejects, including unmarked files", () => { + expect(initialSelection(files)).toEqual(["/card/A.RAW", "/card/C.JPG"]); + }); + it("imports only explicitly selected members, regardless of visible filters", () => { + expect(selectedReview(files, ["/card/C.JPG", "/elsewhere"])).toEqual([files[2]]); + }); + it("never permits source writes in review or in-place sessions", () => { + expect(sourceWritable(null)).toBe(true); + expect(sourceWritable({ stage: "review" })).toBe(false); + expect(sourceWritable({ stage: "in-place" })).toBe(false); + }); +}); diff --git a/src/lib/review.ts b/src/lib/review.ts new file mode 100644 index 0000000..0a473ea --- /dev/null +++ b/src/lib/review.ts @@ -0,0 +1,17 @@ +import type { Framing, Marks, Scanned } from "./types"; +export interface ReviewSession { + version: 1; id: string; name: string; sources: string[]; items: Scanned[]; + decisions: Record; + chosen: string[] | null; stage: "review" | "in-place"; + completed: Record; destination?: string; updatedAt?: number; +} +export function initialSelection(photos: readonly T[]): string[] { + return photos.filter((p) => p.flag !== "reject").map((p) => p.path); +} +export function selectedReview(photos: readonly T[], paths: readonly string[]): T[] { + const selected = new Set(paths); return photos.filter((p) => selected.has(p.path)); +} +export const sourceWritable = (review: { stage: string } | null) => review === null; +export function newReview(sources: string[], items: Scanned[]): ReviewSession { + return { version: 1, id: String(Date.now()), name: sources.map((s) => s.split(/[\\/]/).filter(Boolean).pop()).join(" + "), sources, items, decisions: {}, chosen: null, stage: "review", completed: {} }; +} diff --git a/src/lib/scene-frames.test.ts b/src/lib/scene-frames.test.ts new file mode 100644 index 0000000..66583df --- /dev/null +++ b/src/lib/scene-frames.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { sceneFrames } from "./scene-frames"; +describe("stack filmstrip membership", () => { + it("shows the current contiguous scene in visible order", () => { + expect(sceneFrames([4, 3, 2, 1, 0], 3, (i) => i > 1 ? 7 : 8)).toEqual([4, 3, 2]); + }); + it("does not join unrelated single frames or separated scenes", () => { + expect(sceneFrames([0, 1, 2], 1, () => null)).toEqual([1]); + expect(sceneFrames([0, 1, 2], 0, (i) => i === 1 ? 2 : 1)).toEqual([0]); + }); + it("does not include filtered-out frames", () => { + expect(sceneFrames([0, 2], 1, () => 7)).toEqual([]); + expect(sceneFrames([0, 2], 2, () => 7)).toEqual([0, 2]); + }); +}); diff --git a/src/lib/scene-frames.ts b/src/lib/scene-frames.ts new file mode 100644 index 0000000..03a1fe8 --- /dev/null +++ b/src/lib/scene-frames.ts @@ -0,0 +1,11 @@ +/** The contiguous scene containing the current frame, following the visible order. */ +export function sceneFrames(view: readonly number[], current: number, groupOf: (index: number) => number | null): number[] { + const at = view.indexOf(current); + if (at < 0) return []; + const group = groupOf(current); + if (group === null) return [current]; + let start = at, end = at + 1; + while (start > 0 && groupOf(view[start - 1]) === group) start--; + while (end < view.length && groupOf(view[end]) === group) end++; + return view.slice(start, end); +} diff --git a/src/lib/selftest.ts b/src/lib/selftest.ts index 5c4ff5f..bcf8d90 100644 --- a/src/lib/selftest.ts +++ b/src/lib/selftest.ts @@ -407,6 +407,7 @@ export async function runSelfTest(folder: string): Promise { say(`navigation ${moved ? "moves" : "DOES NOT MOVE"}; histogram ${(await api.histogram(sample[0].display, sample[0].version)).luma.length === 256 ? "ok" : "BAD"}`); if (!moved) throw new Error("the arrow keys' action did not change the photo"); + await (await import("./linux-features.selftest")).checkLinuxFeatures(folder, scratch); say("PASSED"); api.finishSelfTest(true); } catch (e) { diff --git a/src/lib/session-history.test.ts b/src/lib/session-history.test.ts new file mode 100644 index 0000000..71a9998 --- /dev/null +++ b/src/lib/session-history.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { latestSession, type RecentSession } from "./session-history"; +const card = (id: string, updatedAt: number): RecentSession => ({id,updatedAt,name:id,sources:["/card"],count:1,stage:"review",cover:null}); +describe("resume latest session", () => { + it("chooses the most recently used session regardless of kind or list order", () => { + expect(latestSession([card("a",1),card("b",3)],{path:"/folder",updatedAt:2})).toEqual({kind:"card",id:"b"}); + expect(latestSession([card("a",1)],{path:"/folder",updatedAt:2})).toEqual({kind:"folder",path:"/folder"}); + }); + it("supports existing recent folders and ignores malformed preferences", () => { + expect(latestSession([],null,"/old-folder")).toEqual({kind:"folder",path:"/old-folder"}); + expect(latestSession([], {path:"/bad",updatedAt:"wrong"})).toBeNull(); + expect(latestSession([],null)).toBeNull(); + }); +}); diff --git a/src/lib/session-history.ts b/src/lib/session-history.ts new file mode 100644 index 0000000..602444e --- /dev/null +++ b/src/lib/session-history.ts @@ -0,0 +1,12 @@ +import type { Scanned } from "./types"; +export interface RecentSession { id: string; name: string; sources: string[]; count: number; updatedAt: number; stage: string; cover: Scanned | null } +export interface FolderSession { path: string; updatedAt: number } +export type ResumeTarget = { kind: "card"; id: string } | { kind: "folder"; path: string }; +export function latestSession(cards: readonly RecentSession[], folder: unknown, fallbackFolder?: string): ResumeTarget | null { + const f = folder as Partial | null; + const validFolder = typeof f?.path === "string" && !!f.path && typeof f.updatedAt === "number" && Number.isFinite(f.updatedAt); + const card = [...cards].sort((a,b) => b.updatedAt - a.updatedAt)[0]; + if (card && (!validFolder || card.updatedAt >= f.updatedAt!)) return { kind: "card", id: card.id }; + if (validFolder) return { kind: "folder", path: f.path! }; + return fallbackFolder ? { kind: "folder", path: fallbackFolder } : null; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 0cafabf..31e6ff1 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,3 +1,4 @@ +import { isVideo } from "./media"; // The state of the app: which folder is open, what has been decided about // each photo in it, and what is on screen. @@ -5,7 +6,10 @@ import { create } from "zustand"; import { api, type UpdateFound } from "./api"; import { useCrop } from "./crop"; import { cropFromShare, isPlain, MIRROR_ACROSS, MIRROR_DOWN, PLAIN, QUARTER_LEFT, QUARTER_RIGHT, sameFraming, shareOf, turned, viewSize, type Share } from "./framing"; +import { sourceWritable, type ReviewSession } from "./review"; +import { saveReview } from "./review-api"; import { load, save } from "./prefs"; +import { openingView, validViewMode } from "./view-preferences"; import type { AiFace, AiResult, Color, Compared, FileKinds, Flag, FlagFilter, Framing, Marks, MetaEvent, Photo, Rank, SortKey, ViewMode } from "./types"; interface Session { @@ -37,6 +41,7 @@ interface State { version: string; session: Session | null; + review: ReviewSession | null; /** The folder is still being listed. */ loading: boolean; /** How much of the folder's ratings and EXIF has been read, 0–1. */ @@ -61,15 +66,22 @@ interface State { sortAscending: boolean; viewMode: ViewMode; + defaultViewMode: ViewMode; + rememberViewMode: boolean; + stackFilmstripAtBottom: boolean; gridColumns: number; showExif: boolean; + showHistogram: boolean; /** How wide the EXIF panel is, in pixels. */ exifPanelWidth: number; showFilmstrip: boolean; showInfo: boolean; showRatingBar: boolean; showFilterBar: boolean; + experimentalNativeFilmstrip: boolean; + experimentalSmoothZoom: boolean; filmstripHeight: number; + sceneStripHeight: number; /** The strip's edge is being dragged, and its height is changing every frame. */ stripResizing: boolean; /** A side panel's edge is being dragged, and the photo beside it changes size every frame. */ @@ -90,6 +102,7 @@ interface State { updateFound: UpdateFound | null; /** The offer to install it is on screen. */ updateOpen: boolean; + ignoreVideos: boolean; /** Move on to the next photo after marking one. Follows Caps Lock. */ speedCull: boolean; /** The same, left on whatever Caps Lock is doing. */ @@ -173,7 +186,7 @@ interface State { interface Actions { init(): Promise; - openFolder(path: string): Promise; + openFolder(path: string, review?: ReviewSession): Promise; closeFolder(): void; reloadForSubfolders(): void; @@ -335,17 +348,26 @@ export const speedCulling = (s: Pick) => /** Settings written back to storage whenever they change. */ const PERSISTED = [ + "viewMode", + "defaultViewMode", + "rememberViewMode", + "stackFilmstripAtBottom", "sort", "sortAscending", "gridColumns", "showExif", + "showHistogram", "exifPanelWidth", "showFilmstrip", "showInfo", "showRatingBar", "showFilterBar", + "experimentalNativeFilmstrip", + "experimentalSmoothZoom", "filmstripHeight", + "sceneStripHeight", "includeSubfolders", + "ignoreVideos", "fileKinds", "embedInJpeg", "askBeforeTrash", @@ -442,9 +464,10 @@ export const useStore = create((set, get) => { // them: the photo's own file if it is one, and the JPEG it is drawn from // if it is a RAW with one beside it. const jpegs = (p: Photo) => (s.embedInJpeg ? [...new Set([p.path, p.display])].filter((file) => /\.jpe?g$/i.test(file)) : []); - void api.setMarks(edit.map((e) => ({ path: photos[e.index].path, into: jpegs(photos[e.index]), ...e.after }))); + if (sourceWritable(s.review)) void api.setMarks(edit.map((e) => ({ path: photos[e.index].path, into: jpegs(photos[e.index]), ...e.after }))); set({ photos, + ...(s.review ? { review: { ...s.review, decisions: { ...s.review.decisions, ...Object.fromEntries(edit.map((e) => { const p = photos[e.index]; return [p.path, { rating: p.rating, flag: p.flag, color: p.color, framing: p.framing }]; })) } } } : {}), // A photo that no longer passes the filter leaves the view at once, // which is what filtering to "unrated" and rating your way through // the folder relies on. @@ -473,7 +496,7 @@ export const useStore = create((set, get) => { if (edit.length === 0) return; // A self-test frames nothing in anyone's folder. - if (!s.selfTest) { + if (!s.selfTest && sourceWritable(s.review)) { const jpegs = (p: Photo) => (s.embedInJpeg ? [...new Set([p.path, p.display])].filter((file) => /\.jpe?g$/i.test(file)) : []); void api.setFraming( edit.map((e) => { @@ -482,7 +505,7 @@ export const useStore = create((set, get) => { }), ); } - set({ photos, ...(record ? { undoStack: [...s.undoStack.slice(-MAX_UNDO + 1), edit], redoStack: [] } : {}) }); + set({ photos, ...(s.review ? { review: { ...s.review, decisions: { ...s.review.decisions, ...Object.fromEntries(edit.map((e) => { const p = photos[e.index]; return [p.path, { rating: p.rating, flag: p.flag, color: p.color, framing: p.framing }]; })) } } } : {}), ...(record ? { undoStack: [...s.undoStack.slice(-MAX_UNDO + 1), edit], redoStack: [] } : {}) }); } /** @@ -558,7 +581,7 @@ export const useStore = create((set, get) => { try { const failed = new Set(await api.moveToTrash(going.map((i) => ({ path: s.photos[i].path, display: s.photos[i].display })))); // The folder may have been closed, or another opened, while this ran. - if (get().session?.id === session) drop(new Set(going.filter((i) => !failed.has(s.photos[i].path)))); + if (get().session?.id === session) get().forget(going.filter((i) => !failed.has(s.photos[i].path)).map((i) => s.photos[i].path)); } finally { going.forEach((i) => trashing.delete(i)); } @@ -655,6 +678,7 @@ export const useStore = create((set, get) => { selfTest: false, version: "", session: null, + review: null, loading: false, metaProgress: 0, openError: null, @@ -671,18 +695,26 @@ export const useStore = create((set, get) => { sort: load("sort", "captureTime"), sortAscending: load("sortAscending", true), - viewMode: "loupe", + defaultViewMode: validViewMode(load("defaultViewMode", "loupe")), + rememberViewMode: load("rememberViewMode", true) === true, + viewMode: openingView(load("defaultViewMode", "loupe"), load("rememberViewMode", true), load("viewMode", null)), + stackFilmstripAtBottom: load("stackFilmstripAtBottom", false) === true, gridColumns: load("gridColumns", 4), showExif: load("showExif", false), + showHistogram: load("showHistogram", true), exifPanelWidth: load("exifPanelWidth", 250), showFilmstrip: load("showFilmstrip", true), showInfo: load("showInfo", false), showRatingBar: load("showRatingBar", true), showFilterBar: load("showFilterBar", false), + experimentalNativeFilmstrip: load("experimentalNativeFilmstrip", false) === true, + experimentalSmoothZoom: load("experimentalSmoothZoom", false) === true, + sceneStripHeight: load("sceneStripHeight", 116), filmstripHeight: load("filmstripHeight", 120), stripResizing: false, panelResizing: false, includeSubfolders: load("includeSubfolders", false), + ignoreVideos: load("ignoreVideos", true), fileKinds: load("fileKinds", "both"), // Off by default: rating should not rewrite original files unless asked. embedInJpeg: load("embedInJpeg", false), @@ -752,13 +784,14 @@ export const useStore = create((set, get) => { if (info.openPath) await get().openFolder(info.openPath); }, - async openFolder(path) { + async openFolder(path, review) { const before = get(); if (before.session) get().closeFolder(); const { includeSubfolders, fileKinds } = get(); set({ - session: { id: -1, folder: path, name: baseName(path), includeSubfolders, subfolderCount: 0, fileKinds, leftOut: 0, timed: false }, + review: review ?? null, + session: { id: -1, folder: path, name: review?.name ?? baseName(path), includeSubfolders, subfolderCount: 0, fileKinds, leftOut: 0, timed: false }, loading: true, openError: null, photos: [], @@ -769,7 +802,7 @@ export const useStore = create((set, get) => { metaProgress: 0, undoStack: [], redoStack: [], - viewMode: "loupe", + viewMode: openingView(get().defaultViewMode, get().rememberViewMode, get().viewMode), flagFilter: "all", ratingFilter: null, colorFilters: new Set(), @@ -777,7 +810,7 @@ export const useStore = create((set, get) => { let reply; try { - reply = await api.openFolder(path, includeSubfolders, fileKinds); + reply = await api.openFolder(path, includeSubfolders, fileKinds, review?.items.filter((p) => !get().ignoreVideos || !isVideo(p.path)), get().ignoreVideos); } catch (e) { set({ session: null, loading: false, openError: String(e) }); return; @@ -801,6 +834,7 @@ export const useStore = create((set, get) => { framing: PLAIN, framable: false, group: reply.groups?.[index] ?? null, + ...review?.decisions[im.path], })); // When every capture time came back with the listing, the folder opens // in the order it was shot and on its true first frame. That matters @@ -822,9 +856,10 @@ export const useStore = create((set, get) => { const resume = exact >= 0 || !remembered ? exact : photos.findIndex((p) => frame(p.path) === frame(remembered)); const current = photos.length === 0 ? -1 : resume >= 0 ? resume : order[0]; - const recents = [path, ...get().recents.filter((r) => r !== path)].slice(0, MAX_RECENTS); + const recents = review ? get().recents : [path, ...get().recents.filter((r) => r !== path)].slice(0, MAX_RECENTS); + if (!review) save("lastFolderSession", { path, updatedAt: Date.now() }); set({ - session: { id: reply.session, folder: path, name: baseName(path), includeSubfolders, subfolderCount: reply.subfolderCount, fileKinds, leftOut: reply.leftOut, timed: reply.timed }, + session: { id: reply.session, folder: path, name: review?.name ?? baseName(path), includeSubfolders, subfolderCount: reply.subfolderCount, fileKinds, leftOut: reply.leftOut, timed: reply.timed }, loading: false, photos, order, @@ -852,7 +887,7 @@ export const useStore = create((set, get) => { if (event.type === "batch") { const next = s.photos.slice(); for (const m of event.items) { - next[m.index] = { ...next[m.index], ...m.marks, exif: m.exif, captureMs: m.exif.captureMs ?? next[m.index].captureMs, fullWidth: m.fullWidth, fullHeight: m.fullHeight, framing: isPlain(m.framing) ? PLAIN : m.framing, framable: m.framable }; + next[m.index] = { ...next[m.index], ...m.marks, exif: m.exif, captureMs: m.exif.captureMs ?? next[m.index].captureMs, fullWidth: m.fullWidth, fullHeight: m.fullHeight, framing: isPlain(m.framing) ? PLAIN : m.framing, framable: m.framable, ...(get().review?.decisions[next[m.index].path] ?? {}) }; } read += event.items.length; set({ photos: next, view: filtered(s.order, next, s), metaProgress: read / next.length }); @@ -889,12 +924,12 @@ export const useStore = create((set, get) => { if (!session) return; rememberPosition(); if (session.id >= 0) void api.closeFolder(session.id); - set({ session: null, loading: false, photos: [], order: [], view: [], current: -1, selected: new Set(), trashPending: null, cropOpen: false, warmRemaining: 0, ai: [], compared: [], ranks: [], poses: [], aiDone: 0, aiRunning: false, faceZoom: null }); + set({ session: null, review: null, loading: false, photos: [], order: [], view: [], current: -1, selected: new Set(), trashPending: null, cropOpen: false, warmRemaining: 0, ai: [], compared: [], ranks: [], poses: [], aiDone: 0, aiRunning: false, faceZoom: null }); }, reloadForSubfolders() { const folder = get().session?.folder; - if (folder) void get().openFolder(folder); + if (folder) void get().openFolder(folder, get().review ?? undefined); }, select(index, mods) { @@ -1106,7 +1141,7 @@ export const useStore = create((set, get) => { const s = get(); const list = targets ?? (s.selected.size > 0 ? [...s.selected] : currentOnly()); if (list.length === 0) return; - if (s.askBeforeTrash) set({ trashPending: list }); + if (s.review || s.askBeforeTrash) set({ trashPending: list }); else void trash(list); }, @@ -1119,6 +1154,7 @@ export const useStore = create((set, get) => { async transfer(targets, moving) { const s = get(); + if (s.review) { set({ notice: { title: "Finish card review to import", body: "Use Finish review to copy selected files with their local marks, or import them in place. Sources are not moved during card review." } }); return; } if (!s.session || targets.length === 0) return; const count = `${targets.length} photo${targets.length === 1 ? "" : "s"}`; const destination = await api.pickFolder(moving ? `Move ${count} to:` : `Copy ${count} to:`); @@ -1175,6 +1211,7 @@ export const useStore = create((set, get) => { }, openShip(targets) { + if (get().review) { set({ notice: { title: "Finish card review to import", body: "Use Finish review to choose which card files to import." } }); return; } if (!get().session) return; // Ship works from the selection, so shipping these means selecting them. if (targets && targets.length > 0) set({ selected: new Set(targets) }); @@ -1185,6 +1222,8 @@ export const useStore = create((set, get) => { const gone = new Set(paths); const { photos, order } = get(); drop(new Set(order.filter((i) => gone.has(photos[i].path)))); + const review = get().review; + if (review) set({ review: { ...review, items: review.items.filter((p) => !gone.has(p.path)), chosen: review.chosen?.filter((path) => !gone.has(path)) ?? null } }); }, removeRecents: (paths) => set((s) => ({ recents: s.recents.filter((r) => !paths.includes(r)) })), @@ -1198,6 +1237,9 @@ useStore.subscribe((state) => { for (const key of PERSISTED) { if (state[key] !== last[key]) save(key, state[key]); } + if (state.review && (state.photos !== last.photos || state.review !== last.review) && !state.loading) { + void saveReview(state.review).catch((error) => useStore.setState({ notice: { title: "Card session could not be saved", body: String(error) } })); + } last = state; }); diff --git a/src/lib/strip-aspect.test.ts b/src/lib/strip-aspect.test.ts new file mode 100644 index 0000000..59a6bb1 --- /dev/null +++ b/src/lib/strip-aspect.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; +import { stripPhotoWidth } from "./strip-aspect"; +const photo = { fullWidth: 6000, fullHeight: 4000, framing: { turn: 1, crop: null } }; +it("respects portrait, rotated and cropped proportions in either strip", () => { + expect(stripPhotoWidth(photo, 120, true)).toBe(180); + expect(stripPhotoWidth({...photo,framing:{turn:6,crop:null}},120,true)).toBe(80); + expect(stripPhotoWidth({...photo,framing:{turn:1,crop:{cx:0,cy:0,w:2000,h:4000,angle:0}}},120,true)).toBe(60); + expect(stripPhotoWidth({...photo,framing:{turn:6,crop:null}},120,false)).toBe(180); +}); +it("uses the original layout until usable dimensions arrive", () => { + expect(stripPhotoWidth(undefined,120,true)).toBe(180); + expect(stripPhotoWidth({...photo,fullHeight:0},120,true)).toBe(180); + expect(stripPhotoWidth({...photo,fullWidth:NaN},120,true)).toBe(180); +}); + +it("uses decoded JPEG proportions before metadata, then trusts the full photo geometry", () => { + expect(stripPhotoWidth({...photo,fullWidth:0,fullHeight:0},120,true,2/3)).toBe(80); + expect(stripPhotoWidth(photo,120,true,2/3)).toBe(180); + expect(stripPhotoWidth(undefined,120,false,2/3)).toBe(180); + expect(stripPhotoWidth(undefined,120,true,Infinity)).toBe(180); +}); diff --git a/src/lib/strip-aspect.ts b/src/lib/strip-aspect.ts new file mode 100644 index 0000000..69b6fd6 --- /dev/null +++ b/src/lib/strip-aspect.ts @@ -0,0 +1,7 @@ +import { shownSize, type Framed } from "./framing"; +export function stripPhotoWidth(photo: Framed | undefined, height: number, native: boolean, previewRatio?: number): number { + const size = photo && shownSize(photo); + const measured = size && Number.isFinite(size.w) && Number.isFinite(size.h) && size.w > 0 && size.h > 0 ? size.w / size.h : previewRatio; + const ratio = native && measured && Number.isFinite(measured) && measured > 0 ? measured : 1.5; + return Math.max(1, Math.round(height * ratio)); +} diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts new file mode 100644 index 0000000..a634066 --- /dev/null +++ b/src/lib/theme.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { validTheme, GREY_SURROUND } from "./theme"; + +describe("appearance preferences", () => { + it("preserves dark as the default and accepts explicit modes", () => { + expect(validTheme(null)).toBe("dark"); + expect(validTheme("unknown")).toBe("dark"); + for (const mode of ["dark", "light", "grey"]) expect(validTheme(mode)).toBe(mode); + }); + it("uses the sRGB encoding of 18 percent linear luminance", () => { + const encoded = Math.round((1.055 * Math.pow(0.18, 1 / 2.4) - 0.055) * 255); + expect(GREY_SURROUND).toBe(`#${encoded.toString(16).repeat(3)}`); + }); +}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..03846a0 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,5 @@ +export type Theme = "dark" | "light" | "grey"; +export const GREY_SURROUND = "#767676"; +export function validTheme(value: unknown): Theme { + return value === "light" || value === "grey" ? value : "dark"; +} diff --git a/src/lib/view-preferences.test.ts b/src/lib/view-preferences.test.ts new file mode 100644 index 0000000..493bb12 --- /dev/null +++ b/src/lib/view-preferences.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { openingView, validViewMode } from "./view-preferences"; + +describe("opening view preferences", () => { + it("retains filmstrip/loupe as the first-run default", () => { + expect(openingView(undefined, true, undefined)).toBe("loupe"); + }); + it("restores each supported last view when enabled", () => { + for (const mode of ["loupe", "scene", "grid"]) expect(openingView("loupe", true, mode)).toBe(mode); + }); + it("uses the configured default when remembering is off", () => { + for (const mode of ["loupe", "scene", "grid"]) expect(openingView(mode, false, "grid")).toBe(mode); + }); + it("falls back to the chosen default for missing or corrupt last views", () => { + for (const value of [undefined, null, "unknown", {}, ["grid"], 3]) expect(openingView("scene", true, value)).toBe("scene"); + expect(openingView("bad", true, "bad")).toBe("loupe"); + expect(validViewMode("bad")).toBe("loupe"); + expect(openingView("scene", "false", "grid")).toBe("scene"); + }); +}); diff --git a/src/lib/view-preferences.ts b/src/lib/view-preferences.ts new file mode 100644 index 0000000..13b2a22 --- /dev/null +++ b/src/lib/view-preferences.ts @@ -0,0 +1,12 @@ +import type { ViewMode } from "./types"; + +export function validViewMode(value: unknown): ViewMode { + return value === "scene" || value === "grid" ? value : "loupe"; +} + +export function openingView(defaultMode: unknown, remember: unknown, lastMode: unknown): ViewMode { + const fallback = validViewMode(defaultMode); + return remember === true && typeof lastMode === "string" && ["loupe", "scene", "grid"].includes(lastMode) + ? validViewMode(lastMode) + : fallback; +} diff --git a/src/lib/visual-theme.test.ts b/src/lib/visual-theme.test.ts new file mode 100644 index 0000000..0947e00 --- /dev/null +++ b/src/lib/visual-theme.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { validVisualTheme, visualThemeFont } from "./visual-theme"; + +describe("visual theme presets", () => { + it("keeps the original design for absent or unsupported preferences", () => { + for (const value of [null, undefined, {}, "unknown", "dark"]) { + expect(validVisualTheme(value)).toBe("default"); + } + expect(validVisualTheme("rapidraw")).toBe("rapidraw"); + expect(validVisualTheme("wisp")).toBe("wisp"); + }); + + it("supplies the preset font only while explicitly enabled", () => { + expect(visualThemeFont("rapidraw", true)).toContain('"Space Grotesk"'); + expect(visualThemeFont("rapidraw", false)).toBeNull(); + expect(visualThemeFont("default", true)).toBeNull(); + expect(visualThemeFont("wisp", true)).toBeNull(); + }); +}); diff --git a/src/lib/visual-theme.ts b/src/lib/visual-theme.ts new file mode 100644 index 0000000..7b8c759 --- /dev/null +++ b/src/lib/visual-theme.ts @@ -0,0 +1,12 @@ +export type VisualTheme = "default" | "rapidraw" | "wisp"; + +export function validVisualTheme(value: unknown): VisualTheme { + return value === "rapidraw" || value === "wisp" ? value : "default"; +} + +/** A preset supplies typography without overwriting the user's interface font. */ +export function visualThemeFont(theme: VisualTheme, includedFont: boolean): string | null { + return theme === "rapidraw" && includedFont + ? '"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' + : null; +} diff --git a/src/lib/warm.ts b/src/lib/warm.ts index a8dbdb4..2270c81 100644 --- a/src/lib/warm.ts +++ b/src/lib/warm.ts @@ -1,3 +1,4 @@ +import { isVideo } from "./media"; // Prepares a folder's loupe previews in the background, so that any photo // opens at once and not only the ones next to the one being looked at. @@ -54,9 +55,9 @@ export function useWarmAhead(): void { const near: { path: string; version: string }[] = []; for (let d = 1; near.length < REACH && (at + d < view.length || at - Math.floor(d / 2) >= 0); d++) { const ahead = photos[view[at + d]]; - if (ahead) near.push({ path: ahead.display, version: ahead.version }); + if (ahead && !isVideo(ahead.path)) near.push({ path: ahead.display, version: ahead.version }); const behind = d % 2 === 0 ? photos[view[at - d / 2]] : undefined; - if (behind) near.push({ path: behind.display, version: behind.version }); + if (behind && !isVideo(behind.path)) near.push({ path: behind.display, version: behind.version }); } centre.current = { view, at, size: fitSize }; void api.warm(near, fitSize).then((n) => useStore.setState({ warmRemaining: n, warmTotal: n })); diff --git a/src/main.tsx b/src/main.tsx index 85a86f8..dca0dbe 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App"; +import { initializePreferences } from "./lib/prefs"; import "./index.css"; // The view's own right-click menu offers "Reload" and "Inspect Element", @@ -28,6 +28,8 @@ window.addEventListener( true, ); +await initializePreferences(); +const { default: App } = await import("./App"); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/visual-theme.css b/src/visual-theme.css new file mode 100644 index 0000000..31d84bc --- /dev/null +++ b/src/visual-theme.css @@ -0,0 +1,262 @@ +@font-face { + font-family: "Space Grotesk"; + src: url("/fonts/SpaceGrotesk.ttf") format("truetype"); + font-weight: 300 700; + font-style: normal; + font-display: swap; +} + +.rapidraw-intro, .rapidraw-footer { display: none; } + +:root[data-visual-theme="rapidraw"] { + color-scheme: dark; + --background: #181818; + --foreground: #e8eaed; + --card: #232323; + --card-foreground: #e8eaed; + --popover: #232323; + --popover-foreground: #e8eaed; + --primary: #e8eaed; + --primary-foreground: #181818; + --secondary: #2b2b2b; + --secondary-foreground: #e8eaed; + --muted: #232323; + --muted-foreground: #a6a6a6; + --accent: #353535; + --accent-foreground: #e8eaed; + --border: #3c3c3c; + --input: #4c4c4c; + --ring: #c6c6c6; + --opencull-canvas: #181818; + --opencull-toolbar: #232323; + --opencull-panel: #1c1c1c; + --opencull-strip: #181818; + --opencull-cell: #2b2b2b; + --opencull-accent: #dedede; + --opencull-select: #dedede; + font-family: var(--visual-theme-font, var(--app-font, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)); +} + +/* Colour mode is orthogonal to the layout/typography preset. */ +:root[data-visual-theme="rapidraw"][data-theme="light"] { + color-scheme: light; + --background: #f5f5f5; + --foreground: #141414; + --card: #fff; + --card-foreground: #141414; + --popover: #fff; + --popover-foreground: #141414; + --secondary: #e5e5e5; + --secondary-foreground: #141414; + --muted: #ededed; + --muted-foreground: #626262; + --accent: #dedede; + --accent-foreground: #141414; + --primary: #3c3c3c; + --primary-foreground: #fff; + --border: #d0d0d0; + --input: #b0b0b0; + --opencull-canvas: #e8e8e8; + --opencull-toolbar: #fff; + --opencull-panel: #f1f1f1; + --opencull-strip: #ededed; + --opencull-cell: #dedede; + --opencull-accent: #555; + --opencull-select: #555; +} +:root[data-visual-theme="rapidraw"][data-theme="grey"] { + --background: #303030; + --card: #383838; + --popover: #383838; + --secondary: #454545; + --muted: #383838; + --accent: #484848; + --border: #606060; + --input: #707070; + --opencull-canvas: #767676; + --opencull-toolbar: #383838; + --opencull-panel: #383838; + --opencull-strip: #303030; + --opencull-cell: #484848; +} + +:root[data-visual-theme="rapidraw"] .home-view { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(420px, 1fr); + padding: 8px; + background: var(--background); +} +:root[data-visual-theme="rapidraw"] .rapidraw-artwork { + position: relative; + min-height: 0; + overflow: hidden; + border-radius: 6px 0 0 6px; + background: var(--muted); +} +.rapidraw-artwork img { width: 100%; height: 100%; object-fit: cover; } +.rapidraw-artwork span { + position: absolute; + bottom: 24px; + left: 24px; + padding: 6px 10px; + color: #fff; + background: #0009; + border-radius: 4px; + font-size: 11px; +} +:root[data-visual-theme="rapidraw"] .home-content { + min-width: 0; + overflow-y: auto; + justify-content: center; + padding: 48px clamp(24px, 4vw, 64px) 24px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0 6px 6px 0; +} +:root[data-visual-theme="rapidraw"] .home-header { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 0; + align-items: center; +} +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 0 100%; } +:root[data-visual-theme="rapidraw"] .home-controls { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 16px; +} +:root[data-visual-theme="rapidraw"] .home-utilities { justify-content: flex-start; } +:root[data-visual-theme="rapidraw"] .home-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} +:root[data-visual-theme="rapidraw"] .home-heading h1 { + margin: 0 0 16px; + background: none; + color: var(--foreground); + font-size: 48px; + font-weight: 600; + letter-spacing: -2px; +} +:root[data-visual-theme="rapidraw"] .home-heading > p:first-of-type { display: none; } +:root[data-visual-theme="rapidraw"] .rapidraw-intro { + display: block; + max-width: 360px; + margin: 0 0 24px; + font-size: 18px; + line-height: 1.6; + color: var(--muted-foreground); +} +:root[data-visual-theme="rapidraw"] .home-header button { + min-height: 40px; + margin: 0; + border-radius: 8px; + color: var(--foreground); + background: var(--secondary); + box-shadow: none; +} +:root[data-visual-theme="rapidraw"] .home-header button:hover { background: var(--accent); } +:root[data-visual-theme="rapidraw"] .home-header .home-open { + justify-content: center; + flex: 1 0 100%; + color: var(--primary-foreground); + background: var(--primary); +} +:root[data-visual-theme="rapidraw"] .home-header .home-ingest { + flex: 1; + justify-content: center; +} +:root[data-visual-theme="rapidraw"] .home-header button[aria-label] { padding: 0; } +:root[data-visual-theme="rapidraw"] .home-header button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 3px; +} +:root[data-visual-theme="rapidraw"] .home-recents { flex: 0 0 auto; max-height: 40vh; padding: 28px 0 0; } +:root[data-visual-theme="rapidraw"] .home-empty { display: none; } +:root[data-visual-theme="rapidraw"] .home-recents h2 { color: var(--muted-foreground); } +:root[data-visual-theme="rapidraw"] .home-recents > .grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); gap: 16px; } +:root[data-visual-theme="rapidraw"] .home-recents .group { + background: var(--secondary); + border-color: var(--border); + box-shadow: none; + border-radius: 6px; +} +:root[data-visual-theme="rapidraw"] .home-recents .group:hover { transform: none; background: var(--accent); } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:first-child { display: none; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) { padding-left: 42px; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) > div { color: var(--foreground); } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) > div + div { color: var(--muted-foreground); } +:root[data-visual-theme="rapidraw"] .home-recents .group > button { display: block; top: 14px; } +:root[data-visual-theme="rapidraw"] .rapidraw-footer { + display: block; + margin-top: 32px; + font-size: 11px; + color: var(--muted-foreground); +} +@media (max-width: 800px) { + :root[data-visual-theme="rapidraw"] .home-view { grid-template-columns: minmax(0, 1fr); } + :root[data-visual-theme="rapidraw"] .rapidraw-artwork { display: none; } + :root[data-visual-theme="rapidraw"] .home-content { border-radius: 6px; padding: 40px 24px 24px; } +} + +/* Compact application header: actions share a row rather than filling columns. */ +:root[data-visual-theme="rapidraw"] .home-content { + justify-content: flex-start; + padding: 32px clamp(24px, 3vw, 48px) 24px; +} +:root[data-visual-theme="rapidraw"] .home-header { flex-wrap: wrap; gap: 16px; } +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 1 260px; min-width: 0; } +:root[data-visual-theme="rapidraw"] .home-heading h1 { + margin: 0 0 8px; + font-size: clamp(2.25rem, 1.75rem + 2cqi, 3.5rem); + line-height: 1.12; + letter-spacing: -0.025em; +} +:root[data-visual-theme="rapidraw"] .rapidraw-intro { + max-width: 34rem; + margin: 0; + font-size: 1rem; + line-height: 1.5; +} +:root[data-visual-theme="rapidraw"] .home-controls { + width: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 16px; +} +:root[data-visual-theme="rapidraw"] .home-actions { display: flex; flex-wrap: wrap; gap: 12px; } +:root[data-visual-theme="rapidraw"] .home-header button { flex: 0 0 auto; order: initial; } +:root[data-visual-theme="rapidraw"] .home-header .home-open, +:root[data-visual-theme="rapidraw"] .home-header .home-ingest { flex: 0 0 auto; } +:root[data-visual-theme="rapidraw"] .home-header button[aria-label] { order: initial; padding: 0; width: 40px; height: 40px; } +:root[data-visual-theme="rapidraw"] .home-recents { flex: 1 1 auto; max-height: none; min-height: 0; } +:root[data-visual-theme="rapidraw"] .rapidraw-footer { margin-top: 16px; } +:root[data-visual-theme="rapidraw"] .home-controls { gap: 8px; } +:root[data-visual-theme="rapidraw"] .home-actions { gap: 8px; } +:root[data-visual-theme="rapidraw"] .home-actions button { padding-inline: 12px; } +/* Identity always occupies its own row above the horizontal actions. */ +:root[data-visual-theme="rapidraw"] .home-heading { flex: 1 0 100%; } +:root[data-visual-theme="rapidraw"] .home-controls { flex: 1 0 100%; } + +/* Keep folder contact sheets; session history is denser in this preset. */ +:root[data-visual-theme="rapidraw"] .home-recents .group { max-width: 320px; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:first-child { display: block; height: auto; aspect-ratio: 16 / 9; } +:root[data-visual-theme="rapidraw"] .home-recents .group > div:nth-child(2) { padding: 12px; } +:root[data-visual-theme="rapidraw"] .home-recents .group > button { display: block; top: 12px; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); + gap: 16px; +} +:root[data-visual-theme="rapidraw"] .recent-sessions-grid > .recent-session-card { max-width: 320px; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:first-child { aspect-ratio: 16 / 9; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:nth-child(2) { padding: 12px; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid .recent-session-open > div:nth-child(2) > div { font-size: 0.875rem; } +:root[data-visual-theme="rapidraw"] .recent-sessions-grid p { font-size: 0.75rem; line-height: 1.5; } +/* Let the welcome block breathe before the first row of photographs. */ +:root[data-visual-theme="rapidraw"] .home-content { padding-top: 40px; } +:root[data-visual-theme="rapidraw"] .home-header { row-gap: 24px; padding-bottom: 32px; } diff --git a/src/wisp-theme.css b/src/wisp-theme.css new file mode 100644 index 0000000..860f15e --- /dev/null +++ b/src/wisp-theme.css @@ -0,0 +1,110 @@ +/* Wisp: readable work surfaces; neutral photo pixels and unmistakable controls. */ +:root[data-visual-theme="wisp"] { + color-scheme: dark; + --background: #111827; --foreground: #f3f6ff; + --card: #1d293d; --card-foreground: #f3f6ff; + --popover: #162033; --popover-foreground: #f3f6ff; + --primary: #b7a6ff; --primary-foreground: #21173e; + --secondary: #2b3b55; --secondary-foreground: #f3f6ff; + --muted: #26364b; --muted-foreground: #bac8df; + --accent: #354866; --accent-foreground: #fff; + --border: #71839f; --input: #8c9db8; --ring: #c8bbff; + --destructive: #ffb0b9; + --home-from: #111827; --home-to: #111827; + --opencull-toolbar: #162033; --opencull-panel: #1d293d; + --opencull-strip: #162033; --opencull-cell: #26364b; + --opencull-accent: #b7a6ff; --opencull-select: #b7a6ff; + --ui-text-4d4d4d: #a4b4cf; --ui-text-666666: #bac8df; + --ui-text-737373: #bac8df; --ui-text-808080: #bac8df; + --ui-text-8c8c8c: #bac8df; --ui-text-999999: #c3cde0; + --ui-text-a6a6a6: #c3cde0; --ui-text-b3b3b3: #d4dcef; + --ui-text-bdbdbd: #d4dcef; --ui-text-bfbfbf: #d4dcef; + --ui-text-cccccc: #e5ebf7; --ui-text-d9d9d9: #f3f6ff; --ui-text-e6e6e6: #f3f6ff; + --ui-line-2e2e2e: #71839f; --ui-line-333333: #71839f; --ui-line-383838: #71839f; + --ui-line-3a3a44: #8c9db8; --ui-line-595959: #8c9db8; --ui-line-8c8c8c: #8c9db8; + --ui-surface-101014: #111827; --ui-surface-131318: #111827; --ui-surface-141414: #111827; + --ui-surface-14141a: #111827; --ui-surface-17171c: #162033; --ui-surface-1a1a21: #162033; + --ui-surface-1f1f1f: #1d293d; --ui-surface-262626: #26364b; --ui-surface-28282e: #26364b; + --ui-surface-292929: #2b3b55; --ui-surface-2a2a30: #2b3b55; --ui-surface-2e2e2e: #2b3b55; + --ui-surface-333333: #354866; --ui-surface-383838: #354866; --ui-surface-404040: #455b7a; + --ui-surface-4d4d4d: #455b7a; --ui-surface-595959: #566c8b; --ui-surface-666666: #566c8b; +} +/* Keep the photographic surround neutral in every preset. */ +:root[data-visual-theme="wisp"] { --opencull-canvas: #111; } +:root[data-visual-theme="wisp"][data-theme="grey"] { --opencull-canvas: #767676; } +:root[data-visual-theme="wisp"][data-theme="light"] { + color-scheme: light; + --background: #f3f4fa; --foreground: #1c2440; + --card: #fff; --card-foreground: #1c2440; + --popover: #f3f4fa; --popover-foreground: #1c2440; + --primary: #6445a8; --primary-foreground: #fff; + --secondary: #e3e8f5; --secondary-foreground: #1c2440; + --muted: #e3e8f5; --muted-foreground: #455674; + --accent: #d7dcef; --accent-foreground: #1c2440; + --border: #64728a; --input: #64728a; --ring: #6445a8; --destructive: #a61d38; + --home-from: #f3f4fa; --home-to: #f3f4fa; + --opencull-toolbar: #e3e8f5; --opencull-panel: #fff; --opencull-strip: #e3e8f5; --opencull-cell: #d7dcef; + --opencull-canvas: #e8e8e8; +} +:root[data-visual-theme="wisp"] .setting-row { + padding: 16px; gap: 24px; border: 1px solid var(--border); border-radius: 8px; + background: var(--card); color: var(--foreground); +} +:root[data-visual-theme="wisp"] .setting-title { font-size: 0.875rem; line-height: 1.4; font-weight: 600; color: var(--foreground); } +:root[data-visual-theme="wisp"] .setting-detail { margin-top: 6px; font-size: 0.8125rem; line-height: 1.6; color: var(--muted-foreground); } +:root[data-visual-theme="wisp"] fieldset { padding: 16px; border-color: var(--border); background: var(--card); color: var(--foreground); } +:root[data-visual-theme="wisp"] [data-slot="tabs-list"] { gap: 4px; padding: 6px; background: var(--background); border: 1px solid var(--border); } +:root[data-visual-theme="wisp"] [role="tab"] { min-height: 40px; border: 1px solid transparent; color: var(--muted-foreground); } +:root[data-visual-theme="wisp"] [role="tab"][data-state="active"] { background: var(--secondary); color: var(--foreground); border-color: var(--ring); font-weight: 600; } +:root[data-visual-theme="wisp"] :is(button,input,select,textarea,a):focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; } +:root[data-visual-theme="wisp"] :is(select,input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),textarea) { border: 1px solid var(--input); color: var(--foreground); background: var(--background); } +:root[data-visual-theme="wisp"] [role="switch"] { border: 1px solid var(--input); background: var(--secondary); } +:root[data-visual-theme="wisp"] [role="switch"][data-state="checked"] { background: var(--primary); } +:root[data-visual-theme="wisp"] [data-slot="switch-thumb"] { background: var(--foreground); } +:root[data-visual-theme="wisp"] [role="switch"][data-state="checked"] [data-slot="switch-thumb"] { background: var(--primary-foreground); } +:root[data-visual-theme="wisp"] :is(input[type="checkbox"],input[type="radio"],input[type="range"]) { accent-color: var(--primary); } +:root[data-visual-theme="wisp"] .home-open { background: var(--primary); color: var(--primary-foreground); box-shadow: none; } +@media (forced-colors: active) { + :root[data-visual-theme="wisp"] :is(.setting-row,fieldset,[data-slot="tabs-list"]) { border-color: CanvasText; background: Canvas; color: CanvasText; } + :root[data-visual-theme="wisp"] [role="tab"][data-state="active"] { outline: 2px solid Highlight; } +} + +:root[data-visual-theme="wisp"][data-theme="light"] { + --ui-text-4d4d4d: var(--muted-foreground); + --ui-text-666666: var(--muted-foreground); + --ui-text-737373: var(--muted-foreground); + --ui-text-808080: var(--muted-foreground); + --ui-text-8c8c8c: var(--muted-foreground); + --ui-text-999999: var(--muted-foreground); + --ui-text-a6a6a6: var(--muted-foreground); + --ui-text-b3b3b3: var(--foreground); + --ui-text-bdbdbd: var(--foreground); + --ui-text-bfbfbf: var(--foreground); + --ui-text-cccccc: var(--foreground); + --ui-text-d9d9d9: var(--foreground); + --ui-text-e6e6e6: var(--foreground); + --ui-line-2e2e2e: var(--border); + --ui-line-333333: var(--border); + --ui-line-383838: var(--border); + --ui-line-3a3a44: var(--border); + --ui-line-595959: var(--border); + --ui-line-8c8c8c: var(--border); + --ui-surface-101014: var(--secondary); + --ui-surface-131318: var(--secondary); + --ui-surface-141414: var(--secondary); + --ui-surface-14141a: var(--secondary); + --ui-surface-17171c: var(--secondary); + --ui-surface-1a1a21: var(--secondary); + --ui-surface-1f1f1f: var(--secondary); + --ui-surface-262626: var(--secondary); + --ui-surface-28282e: var(--secondary); + --ui-surface-292929: var(--secondary); + --ui-surface-2a2a30: var(--secondary); + --ui-surface-2e2e2e: var(--secondary); + --ui-surface-333333: var(--secondary); + --ui-surface-383838: var(--secondary); + --ui-surface-404040: var(--secondary); + --ui-surface-4d4d4d: var(--secondary); + --ui-surface-595959: var(--secondary); + --ui-surface-666666: var(--secondary); +}