Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 4d5b1c2da4 WIP LR-PROF-CAMERACOLOR: generalise the camera-preview fit (interrupted by usage limit; unverified, do not land)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:12:23 -04:00
Brandon Thomas bd3185ef4d Merge pull request #145 from storytold/fix/issue-138-lightroom-dng
Issue #138: DNG: apply the file's own camera-profile look (HueSatMap, LookTable, ToneCurve)
2026-10-06 11:27:16 -04:00
Brandon Thomas 4cf883155a Merge pull request #144 from storytold/fix/issue-136-vulkan-startup-crash
Issue #136: Windows: use DX12 by default, honour WGPU_BACKEND, never let GPU init block startup
2026-10-06 11:27:03 -04:00
Brandon ThomasandClaude Opus 5.5 71eac70696 Issue #138: DNG: apply the file's own camera-profile look (HueSatMap, LookTable, ToneCurve)
Lightroom-converted DNGs carry their camera profile's ProfileHueSatMapData1/2
and ProfileLookTableData (and sometimes ProfileToneCurve). We used only the
colour matrices, so such DNGs rendered flat and muted.

- raw: new `profile` module reads the tags (DNG spec ch. 6: value-major,
  hue-middle, saturation-minor tables, wrap-around hue, tri-linear lookups,
  sRGB value encoding) from the raw IFD or IFD 0; malformed tags are ignored.
- engine: the raw loader applies the hue/sat map (interpolated by the same
  illuminant weight as the matrices) before BaselineExposure and the look
  table after it, in linear ProPhoto; a ProfileToneCurve becomes the source's
  camera tone. Without one the standard base tone is kept.
- dngwrite keeps the look with the data (conversions, smart previews, merges).
- Render cache version 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:36:30 -04:00
Brandon ThomasandClaude Opus 5.5 b66cdb23a3 Issue #136: Windows: use DX12 by default, honour WGPU_BACKEND, never let GPU init block startup
wgpu loads the driver of every backend in an instance's set while it
enumerates adapters. The compute device (crates/gpu) hard-coded
Backends::PRIMARY (Vulkan + DX12 + Metal) and ignored WGPU_BACKEND, and
was created from LightcraftApp::new on a background thread at launch, so
a crashing Vulkan driver (Intel UHD 630, igvk64.dll) killed the process
before the window appeared; the window (egui-wgpu) also listed Vulkan.

- lightcraft_gpu::backend: DX12 only on Windows (Metal on macOS, as before
  on Linux) for both the compute device and the window;
  LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|gl|auto|off, else WGPU_BACKEND;
  backends missing from the build fall back to the default.
- The device is created only when GPU rendering is enabled, after the
  first frame; the preference is applied before the window opens.
- Crash sentinel: gpu-init.marker in the settings folder around device
  creation; if left behind, the next launch starts with GPU rendering off
  and a notice saying how to turn it back on.
- Tests: backend parsing/selection, sentinel, window defaults, headless
  snapshot with LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off.
- Docs: gpu-pipeline.md (backends, env vars, troubleshooting), whats-new,
  parity LR-BEHAV-GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:16:05 -04:00
Brandon Thomas d2359ce095 Merge pull request #143 from storytold/fix/issue-134-trim-write-safety
Issue #134: Exports: atomic without fsync; Copy import verifies against the probe hash
2026-10-06 10:11:47 -04:00
Brandon ThomasandClaude Opus 5.5 7a2596cd43 Issue #134: Exports: atomic without fsync; Copy import verifies against the probe hash
- safe_file: write policy documented (durable for what can't be recreated, atomic-only for
  recreatable outputs); new write_atomic_nosync (temp + rename, temp removed on failure, no
  file/folder sync); per-thread sync counter for tests.
- export::write_file (exports, renders, screenshots, merge previews, CLI/MCP) uses the
  non-syncing writer; new write_file_durable keeps the sync for Edit a Copy (the TIFF becomes a
  library photo). Catalog, sidecars, settings, DNG conversion/merges and smart previews stay
  durable; the #93 original-overwrite guard is untouched.
- Import > Copy verifies the synced copy against the probe's content hash (SipHash-1-3/128 of the
  whole file) instead of re-reading the source; byte compare when there is no hash. A file changed
  since Review Import is now refused instead of imported with stale details. Move keeps its
  byte-for-byte check before deleting sources.
- check_write_target: skip building the library-wide guard when nothing is at the target.
- write_new_unique keeps its read-back (DNGs replace raws; cheap next to encoding).
- Tests: no sync on the export path, durable writers still sync, Copy caught by the probe hash
  (corrupt copy, stale hash, changed source); ignored timing test write_policy_timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:08:29 -04:00
Brandon Thomas 8bef0b1ef3 Merge pull request #141 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 09:59:27 -04:00
Brandon ThomasandClaude Opus 5.5 a2dbefd1ee Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:08 -04:00
Brandon ThomasandClaude Opus 5.5 f5bd3cc2db Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:47 -04:00
Brandon Thomas 1969b311cc Merge pull request #137 from benlukka/fix-flickering-preview-hover
fix(ui): eliminate preview flickering when hovering presets and profiles
2026-10-06 09:24:12 -04:00
Brandon Thomas fc3fa1a71b Merge pull request #140 from storytold/pr/release-0.2.1
Release: LightCraft v0.2.1
2026-10-06 08:01:39 -04:00
Brandon ThomasandClaude Opus 5.5 d0612b0b21 Release: LightCraft v0.2.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank, and the shortcut description was wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:01:29 -04:00
Brandon Thomas 66dc737ff5 Merge pull request #139 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:54:46 -04:00
Brandon ThomasandClaude Opus 5.5 fec3b15234 release.yml: explicit if for the wasm-bindgen install (shellcheck SC2015)
packaging-lint has failed on main since this step was added; same behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:53:44 -04:00
Brandon ThomasandClaude Opus 5.5 11ebb3bdb6 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what LightCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:50:38 -04:00
benlukka ad2525ece2 fix(ui): eliminate preview flickering when hovering presets and profiles
- Retain active preset preview when hovering within the scroll or panel area across headers and gaps.
- Prevent premature dropping of hover preview textures during asynchronous rerendering in detail view.
- Clear cached hover state when clicking an item or when the pointer leaves the panel.
- Add headless regression tests covering preset hover persistence and transitions.

Signed-off-by: benlukka <68825026+benlukka@users.noreply.github.com>
2026-10-06 13:18:47 +02:00
Brandon Thomas 65da36675f Merge pull request #115 from arata-1972/codex/sony-arw-initial-colour
Improve Sony ARW starting colour and tone, and preserve decoded source metadata
2026-10-05 19:47:57 -04:00
Brandon ThomasandClaude Opus 5.5 cbb6c3b8f7 Merge origin/main (#114) into land-115
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:41:46 -04:00
Brandon ThomasandClaude Opus 5.5 5b414ffb8e LR-PROF-CAMERACOLOR: record the ARW file-local fit's public-sample results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:34:01 -04:00
Brandon ThomasandClaude Opus 5.5 f7a7831380 LR-PROF-CAMERACOLOR: keep the ARW camera tone curve with smart previews
Smart previews store the decoded source with the fitted colour matrix
baked in, but the file-local tone curve lives in the decoder's
SourceInfo, which a proxy did not keep: an offline ARW rendered and
exported with the generic filmic curve instead (darker, a different
look). The proxy header now carries the curve (validated on load,
ignored when invalid), DecodedSource::info_or merges it into the header
facts, and is_valid reads the longer header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:30:43 -04:00
Brandon Thomas 2b2ade7a23 Merge pull request #114 from arata-1972/codex/lightcraft-japanese
Extend Japanese localization across editing, import and export UI
2026-10-05 19:29:49 -04:00
Brandon ThomasandClaude Opus 5.5 d6c7228dd4 M16: merge origin/main into Japanese localisation (PR #114)
Keep main's data-safety work (ui.json recovery and atomic writes, library-problem
window, off-thread file checks, import Cancel) and wrap its new strings: import
cancel/progress messages, the library-problem window and banner, and the new
Back Up / Restore Library menu items now have Japanese translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:28:34 -04:00
Brandon ThomasandClaude Opus 5.5 4b3048c48f LR-PROF-CAMERACOLOR: accept ARW camera-look fits up to 0.10 held-out RMS
The 0.055 cap rejected half of the public Sony samples tried (ILCE-6400,
ILCE-6700, ILCE-7M4) although their fits were 1.5-3x closer to the
camera JPEG than the fallback and visibly better (mean dE vs the embedded
preview 19-26 -> 5-10). Unrelated previews stay rejected (synthetic test
lands at 0.115).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:28:22 -04:00
Brandon ThomasandClaude Opus 5.5 bc4799ca42 Merge origin/main into land-115 (PR #115)
Resolve SourceRef::load_source against #104's offline fallback: a File
source keeps the decoder's SourceInfo; a smart-preview fallback has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:37 -04:00
Brandon ThomasandClaude Opus 5.5 586479a309 M16: drop fork-only Japanese packaging from PR #114
Remove scripts/package-japanese-macos.py, the japanese-local feature and its
config/library/startup overrides, the settings.language migration, and the
local-build notes in the docs. Upstream library and config defaults (incl.
library::default_dir, which already honours LIGHTCRAFT_LIBRARY) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:11:34 -04:00
Brandon Thomas 87f275b58e Merge pull request #132 from storytold/fix/issue-104-ui-fs-v2
Issue #104: keep file-system calls off the UI thread
2026-10-05 19:08:15 -04:00
Brandon Thomas 894759e6d8 Merge main into fix/issue-104-ui-fs-v2
# Conflicts:
#	docs/parity.md
#	docs/whats-new.md
2026-10-05 19:00:37 -04:00
Brandon Thomas 1952f7c960 Merge pull request #131 from storytold/fix/issue-107-web-safety
Issue #107: Web build: data-safety fixes for libraries kept in the browser
2026-10-05 18:59:14 -04:00
Brandon Thomas 7bbdbc03ef Merge main into fix/issue-107-web-safety
# Conflicts:
#	apps/lightcraft-web/src/web.rs
#	docs/parity.md
#	docs/whats-new.md
2026-10-05 18:58:53 -04:00
Brandon Thomas 0f34a65a8c Merge pull request #130 from storytold/fix/issue-97-folder-undo
Issue #97: Rename Folder / Move Folder are undo steps
2026-10-05 18:58:38 -04:00
Brandon ThomasandClaude Opus 5.5 7b59081d22 Issue #104: keep file-system calls off the UI thread
Re-applies fix/issue-104-ui-fs on top of the data-safety fixes (integrate/safety):
- availability cache: originals and smart previews are checked on a worker thread; the UI
  answers from the cache (grid, Info panel, photo menu, Missing Photos view)
- import split into ImportJob::prepare (worker) + commit_prepared, with Cancel; Copy keeps
  #96's verified copies
- Find Missing Photos: find_candidates (snapshot, no I/O) + plan_find_missing (all file I/O,
  on a worker, never the session) with #105's matching rules unchanged (name+size+hash;
  renamed by size+hash; single name+size; ambiguous look-alikes skipped and reported; files
  already in use excluded). The plan is applied with library.findMissing {found, missing,
  ambiguous}, re-checked against the catalog then: photos relinked meanwhile are left alone,
  files now in use are not taken. library.findMissing {folder} still runs synchronously.
- Build / Discard Smart Previews with background: true runs on a worker with progress and
  Stop, keeping #106's atomic writes, is_valid checks and `repaired` count (also in
  library.previewProgress); the render worker's smart-preview fallback uses is_valid
- Rename preview planned on a worker (plan_rename_photos) on the same planning core as
  Session::plan_rename_with (MoveFs, case-only respelling by file identity, #95)
- Local folder listing via fs_cached; tests_offline (slow injected probe, frames < 300 ms)

Tests: worker-planned Find Missing equals the synchronous command for a renamed + ambiguous
library; a plan is re-checked when applied; background smart previews repair a damaged proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:58:04 -04:00
Brandon Thomas 50cd8dc6dc Merge branch 'fix/issue-97-folder-undo' into integrate/safety
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/parity.md
#	docs/whats-new.md
2026-10-05 18:38:55 -04:00
Brandon Thomas cc8b3eb3bb Merge branch 'fix/issue-94-control-close' into integrate/safety
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:38:13 -04:00
Brandon Thomas a6b402981b Merge branch 'fix/issue-103-settings-files' into integrate/safety
# Conflicts:
#	crates/engine/src/library.rs
#	docs/whats-new.md
2026-10-05 18:26:38 -04:00
Brandon Thomas 4deb5eff04 Merge branch 'fix/issue-100-open-failure' into integrate/safety 2026-10-05 18:26:38 -04:00
Brandon Thomas 6e2408ff7d Merge branch 'fix/issue-99-library-lock' into integrate/safety
# Conflicts:
#	crates/catalog/src/lib.rs
2026-10-05 18:26:38 -04:00
Brandon Thomas 66be31e3d8 Merge branch 'fix/issue-102-format-version' into integrate/safety 2026-10-05 18:26:19 -04:00
Brandon Thomas 332f37a5c7 Merge branch 'fix/issue-101-torn-append' into integrate/safety
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:26:19 -04:00
Brandon Thomas aa1af6d5e9 Merge branch 'fix/issue-92-xmp-merge' into integrate/safety
# Conflicts:
#	crates/engine/src/sidecar.rs
#	docs/whats-new.md
2026-10-05 18:26:19 -04:00
Brandon Thomas 963912b231 Merge branch 'fix/issue-92-rename-shared-sidecar' into integrate/safety 2026-10-05 18:25:06 -04:00
Brandon Thomas 4174ce8c57 Merge branch 'fix/issue-105-rename-rollback' into integrate/safety
# Conflicts:
#	docs/parity.md
2026-10-05 18:24:59 -04:00
Brandon Thomas f28e1c362e Merge branch 'fix/issue-95-case-only-rename' into integrate/safety 2026-10-05 18:24:34 -04:00
Brandon Thomas 70d800ba68 Merge branch 'fix/issue-98-thumb-cache-own-files' into integrate/safety 2026-10-05 18:24:33 -04:00
Brandon Thomas 33ef91a123 Merge branch 'fix/issue-96-verified-copy-import' into integrate/safety
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:24:33 -04:00
Brandon Thomas f1b29cfe4a Merge branch 'fix/issue-106-dng-smart-previews' into integrate/safety 2026-10-05 18:24:23 -04:00
Brandon Thomas 130b807e1b Merge branch 'fix/issue-93-export-overwrite' into integrate/safety 2026-10-05 18:24:22 -04:00
arata1972andCodex 43f4e24355 M11: separate Sony ARW colour and tone and preserve decoded source facts
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 07:16:15 +09:00
Brandon ThomasandClaude Opus 5.5 ebc73bb7b4 Issue #107: Web build: data-safety fixes for libraries kept in the browser
- File > Back Up Library... (web): a zip of the catalog files and every
  stored original, built entry by entry; Restore Library from Backup...
  reads it into a new library folder and switches only when complete
  (the previous library stays in storage).
- Failed flushes to browser storage make the catalog Store refuse
  writes, so changes show as unsaved (top-bar warning) and are retried
  every 2 s instead of being lost silently.
- A picked/dropped photo whose bytes can't be stored is not added.
- One tab per library (Web Locks); a second tab shows a message.
- Notices: storage not persistent, library failed to open (temporary
  session), experimental build; ?reset asks first; a panic shows a
  message instead of a frozen page; long toasts wrap.
- Hosting: _headers/.htaccess no longer mark the unhashed .js/.wasm
  immutable; HOSTING.md rewritten for the actual build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:46:21 -04:00
arata1972andCodex 509d5843dd M16: integrate expanded Japanese UI with upstream language settings
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 06:20:23 +09:00
arata1972andCodex 05f242c445 M11: estimate missing ARW colour from guarded camera-preview correspondences
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 05:55:20 +09:00
arata1972andCodex 87e0181690 M16: preserve upstream defaults with an opt-in Japanese local build
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 05:36:19 +09:00
arata1972andCodex 7c799a9acf M16: add Japanese UI localization and isolated local macOS packaging
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 05:15:04 +09:00
115 changed files with 7833 additions and 1481 deletions

No files matched your search

+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
+6 -2
View File
@@ -112,6 +112,9 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -221,8 +224,9 @@ jobs:
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: lightcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'LightCraft'
foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'lightcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok lightcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
Write-Output 'ok uninstalled'
Generated
+23 -22
View File
@@ -2073,7 +2073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -2091,7 +2091,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -2105,7 +2105,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -2120,7 +2120,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"bytemuck",
"image",
@@ -2145,7 +2145,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2165,7 +2165,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"ab_glyph",
"libc",
@@ -2193,7 +2193,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -2201,7 +2201,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -2217,7 +2217,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2228,7 +2228,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2244,7 +2244,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2256,7 +2256,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"image",
"lightcraft-color",
@@ -2272,7 +2272,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -2284,7 +2284,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"jpeg-encoder",
"lightcraft-color",
@@ -2315,7 +2315,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"image",
"lightcraft-color",
@@ -2326,11 +2326,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.2.0"
version = "0.2.1"
[[package]]
name = "lightcraft-tiff"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -2339,7 +2339,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -2359,8 +2359,9 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"crc32fast",
"eframe",
"egui",
"js-sys",
@@ -5127,7 +5128,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"brotli",
"flate2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+8 -7
View File
@@ -24,19 +24,19 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|---|
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
@@ -46,7 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once.
matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own
JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@@ -81,12 +82,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; accessibility, localisation ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -175,7 +176,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
+36
View File
@@ -183,6 +183,42 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
assert_eq!((d.width, d.height), (640, 480));
}
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
fn snapshot_starts_without_a_gpu() {
let script = tmp("nogpu.jsonl");
std::fs::write(
&script,
format!(
"{}\n{}\n{}\n",
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
json!({"method": "engine.execute", "params": {"command": "app.gpu"}}),
json!({"method": "ui.screenshot"}),
),
)
.unwrap();
for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] {
let out = tmp(&format!("nogpu-{var}.png"));
let o = Command::new(BIN)
.env_remove("LIGHTCRAFT_GPU")
.env_remove("LIGHTCRAFT_GPU_BACKEND")
.env(var, value)
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"])
.output()
.unwrap();
assert!(o.status.success(), "{var}: {}", String::from_utf8_lossy(&o.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
let gpu = &replies[1]["result"];
assert_eq!(gpu["available"], false, "{var}: {gpu}");
assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}");
assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}");
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
assert_eq!((d.width, d.height), (480, 320));
}
}
fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
if let Some(text) = stdin {
+1
View File
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
crc32fast = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["default_fonts", "glow"] }
+33 -10
View File
@@ -232,6 +232,18 @@ impl Backend {
}
}
/// Is there a file at `path`?
pub async fn exists(&self, path: &str) -> Result<bool, String> {
match self {
Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()),
Backend::Idb(db) => {
let (_tx, store) = Self::idb_store(db, false)?;
let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?;
Ok(n.as_f64().is_some_and(|n| n > 0.0))
}
}
}
pub async fn remove(&self, path: &str) -> Result<(), String> {
match self {
Backend::Opfs(root) => {
@@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result<Vec<String>, String
Ok(out)
}
/// Ask the browser not to evict our storage under pressure (best effort; fire and forget).
pub fn request_persistence() {
if let Some(sm) = storage_manager()
&& let Ok(p) = sm.persist()
{
wasm_bindgen_futures::spawn_local(async move {
let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy());
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" });
});
}
/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known
/// (`false` also when the browser has no storage manager).
pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
let Some(sm) = storage_manager() else {
on_result(false);
return;
};
wasm_bindgen_futures::spawn_local(async move {
// already persistent: nothing to ask
let already = match sm.persisted() {
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
Err(_) => false,
};
let granted = already
|| match sm.persist() {
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
Err(_) => false,
};
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
on_result(granted);
});
}
+306
View File
@@ -0,0 +1,306 @@
//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos
//! don't shrink) of the library files and every stored original, built one entry at a time so
//! only one file is in wasm memory at once, and read back the same way for a restore.
//!
//! Layout of a backup:
//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see
//! [`crate::files::LIBRARY_FILES`]);
//! - `originals/<content hash>/<file name>`: each imported photo's original bytes, under its own
//! name so an unzipped backup is browsable;
//! - `README.txt`.
//!
//! Restoring never deletes anything: the library files go to a new library folder in browser
//! storage, originals that aren't stored yet are added (they're named by content, so existing
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
//! the restored library. The library that was there before stays in storage.
//!
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
/// Storage key of the file naming the library folder in use (absent: `library`).
pub const ACTIVE_LIBRARY: &str = "active-library";
/// The default library folder.
pub const DEFAULT_LIBRARY_DIR: &str = "library";
/// Largest backup (offsets are 32-bit without zip64).
pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20);
const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const END_SIG: u32 = 0x0605_4b50;
/// General-purpose flag bit 11: names are UTF-8.
const UTF8: u16 = 0x0800;
/// 1980-01-01 00:00 in MS-DOS format (date, time).
const DOS_DATE: u16 = (1 << 5) | 1;
/// The readme inside every backup.
pub const README: &str = "LightCraft library backup (browser version)\n\n\
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
pub fn valid_library_dir(name: &str) -> bool {
name == DEFAULT_LIBRARY_DIR
|| name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
}
/// Is `h` a plausible content hash (used as a storage key segment)?
pub fn valid_hash(h: &str) -> bool {
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Zip entry name for an original.
pub fn original_entry(hash: &str, file_name: &str) -> String {
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name };
format!("originals/{hash}/{name}")
}
/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or
/// `None` to skip it (README, thumbnails, anything unknown or unsafe).
pub fn restore_key(entry: &str, dir: &str) -> Option<String> {
if let Some(name) = entry.strip_prefix("library/") {
return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}"));
}
let rest = entry.strip_prefix("originals/")?;
let (hash, _) = rest.split_once('/').unwrap_or((rest, ""));
valid_hash(hash).then(|| format!("originals/{hash}"))
}
fn u16le(v: &mut Vec<u8>, x: u16) {
v.extend_from_slice(&x.to_le_bytes());
}
fn u32le(v: &mut Vec<u8>, x: u32) {
v.extend_from_slice(&x.to_le_bytes());
}
struct Central {
name: String,
crc: u32,
size: u32,
offset: u32,
}
/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local
/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory.
#[derive(Default)]
pub struct ZipWriter {
entries: Vec<Central>,
offset: u64,
}
impl ZipWriter {
/// The local header for `name` holding `data` (emit it, then `data`).
pub fn entry(&mut self, name: &str, data: &[u8]) -> Result<Vec<u8>, String> {
let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?;
let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?;
if self.entries.len() >= usize::from(u16::MAX) {
return Err("too many files for one backup".into());
}
let header_len = 30 + u64::from(name_len);
if self.offset + header_len + u64::from(size) > MAX_BYTES {
return Err("the library is too large for one backup file (4 GB)".into());
}
let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
let crc = crc32fast::hash(data);
let mut h = Vec::with_capacity(header_len as usize);
u32le(&mut h, LOCAL_SIG);
u16le(&mut h, 20); // version needed
u16le(&mut h, UTF8);
u16le(&mut h, 0); // stored
u16le(&mut h, 0); // time
u16le(&mut h, DOS_DATE);
u32le(&mut h, crc);
u32le(&mut h, size);
u32le(&mut h, size);
u16le(&mut h, name_len);
u16le(&mut h, 0); // extra
h.extend_from_slice(name.as_bytes());
self.offset += header_len + u64::from(size);
self.entries.push(Central { name: name.to_string(), crc, size, offset });
Ok(h)
}
/// The central directory and end record (emit last).
pub fn finish(self) -> Result<Vec<u8>, String> {
let mut cd = Vec::new();
for e in &self.entries {
u32le(&mut cd, CENTRAL_SIG);
u16le(&mut cd, 20); // made by
u16le(&mut cd, 20); // needed
u16le(&mut cd, UTF8);
u16le(&mut cd, 0);
u16le(&mut cd, 0);
u16le(&mut cd, DOS_DATE);
u32le(&mut cd, e.crc);
u32le(&mut cd, e.size);
u32le(&mut cd, e.size);
u16le(&mut cd, e.name.len() as u16);
u16le(&mut cd, 0); // extra
u16le(&mut cd, 0); // comment
u16le(&mut cd, 0); // disk
u16le(&mut cd, 0); // internal attrs
u32le(&mut cd, 0); // external attrs
u32le(&mut cd, e.offset);
cd.extend_from_slice(e.name.as_bytes());
}
let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?;
let n = self.entries.len() as u16;
let mut out = cd;
u32le(&mut out, END_SIG);
u16le(&mut out, 0);
u16le(&mut out, 0);
u16le(&mut out, n);
u16le(&mut out, n);
u32le(&mut out, cd_size);
u32le(&mut out, cd_offset);
u16le(&mut out, 0); // comment
Ok(out)
}
}
/// One entry of a zip's central directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ZipEntry {
pub name: String,
pub crc: u32,
pub size: u64,
/// Offset of the entry's local header.
pub header: u64,
/// 0 = stored (the only method a restore reads).
pub method: u16,
}
fn rd16(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
}
fn rd32(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
}
/// How many bytes from the end of a zip to read to find its end record.
pub fn tail_len(file_len: u64) -> u64 {
file_len.min(22 + 65_535)
}
/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip.
pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> {
let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?;
let size = rd32(tail, at + 12).ok_or("truncated zip")?;
let offset = rd32(tail, at + 16).ok_or("truncated zip")?;
if size == u32::MAX || offset == u32::MAX {
return Err("zip64 backups are not supported".into());
}
Ok((u64::from(offset), u64::from(size)))
}
/// Parse a central directory.
pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
let mut out = Vec::new();
let mut i = 0;
while i + 46 <= cd.len() {
if rd32(cd, i) != Some(CENTRAL_SIG) {
return Err("damaged zip directory".into());
}
let bad = || "damaged zip directory".to_string();
let method = rd16(cd, i + 10).ok_or_else(bad)?;
let crc = rd32(cd, i + 16).ok_or_else(bad)?;
let size = rd32(cd, i + 24).ok_or_else(bad)?;
let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?);
let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?);
let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?);
let header = rd32(cd, i + 42).ok_or_else(bad)?;
let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?;
out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method });
i += 46 + n + extra + comment;
}
Ok(out)
}
/// Offset of an entry's data from its local header's first 30 bytes.
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
if rd32(local, 0) != Some(LOCAL_SIG) {
return Err(format!("{}: damaged entry", entry.name));
}
let n = rd16(local, 26).ok_or("truncated entry")?;
let extra = rd16(local, 28).ok_or("truncated entry")?;
Ok(entry.header + 30 + u64::from(n) + u64::from(extra))
}
/// Check an entry's bytes.
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
if entry.method != 0 {
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
}
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
}
Ok(())
}
/// A name for the restored library's folder.
pub fn restored_dir_name(now_ms: f64) -> String {
format!("library-restored-{}", now_ms.max(0.0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
fn build(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut w = ZipWriter::default();
let mut out = Vec::new();
for (n, d) in files {
out.extend(w.entry(n, d).unwrap());
out.extend_from_slice(d);
}
out.extend(w.finish().unwrap());
out
}
#[test]
fn written_backups_read_back() {
let files: [(&str, &[u8]); 3] =
[("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])];
let zip = build(&files);
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
let (off, size) = find_central(tail).unwrap();
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
assert_eq!(entries.len(), 3);
for (e, (name, data)) in entries.iter().zip(files) {
assert_eq!(e.name, name);
let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize;
let got = &zip[start..start + e.size as usize];
assert_eq!(got, data);
verify(e, got).unwrap();
}
// damage is caught
let e = &entries[2];
assert!(verify(e, &[0, 1, 2, 3, 254]).is_err());
assert!(find_central(b"not a zip at all, just some text").is_err());
}
#[test]
fn restore_keys_are_safe() {
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
assert_eq!(restore_key("library/../../x", "library-r"), None);
assert_eq!(restore_key("library/evil.bin", "library-r"), None);
assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123"));
assert_eq!(restore_key("originals/../library/catalog.log", "d"), None);
assert_eq!(restore_key("README.txt", "d"), None);
assert_eq!(restore_key("thumbs/x.jpg", "d"), None);
assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg");
assert_eq!(original_entry("abc", ".."), "originals/abc/original");
assert!(valid_library_dir("library") && valid_library_dir("library-restored-17"));
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
}
#[test]
fn size_limits_are_errors_not_panics() {
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
assert!(w.entry("x", &[0; 64]).is_err());
}
}
+58
View File
@@ -49,6 +49,8 @@ struct Mirror {
files: BTreeMap<String, Vec<u8>>,
/// Dirty files, least recently modified first.
dirty: Vec<(String, Dirty)>,
/// Why the last flush to browser storage failed (quota, eviction…), until one succeeds.
error: Option<String>,
}
impl Mirror {
@@ -138,6 +140,18 @@ impl Files {
}
}
/// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once
/// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the
/// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`,
/// the top bar's warning) instead of pretending they were saved.
pub fn set_error(&self, e: Option<String>) {
self.lock().error = e;
}
pub fn error(&self) -> Option<String> {
self.lock().error.clone()
}
/// A catalog [`Store`] over these files.
pub fn store(&self) -> FilesStore {
FilesStore(self.clone())
@@ -147,19 +161,32 @@ impl Files {
/// [`Store`] implementation over [`Files`].
pub struct FilesStore(Files);
impl FilesStore {
/// Writes fail while browser storage does (see [`Files::set_error`]).
fn writable(&self) -> io::Result<()> {
match self.0.error() {
Some(e) => Err(io::Error::other(format!("browser storage: {e}"))),
None => Ok(()),
}
}
}
impl Store for FilesStore {
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
Ok(self.0.get(name))
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
self.writable()?;
self.0.write(name, data);
Ok(())
}
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
self.writable()?;
self.0.append(name, data);
Ok(())
}
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
self.writable()?;
self.0.truncate(name, len as usize);
Ok(())
}
@@ -224,6 +251,37 @@ mod tests {
assert_eq!(names, ["catalog.snap", "catalog.log"]);
}
/// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change
/// as unsaved and the engine keeps it queued; once storage works again it is written.
#[test]
fn flush_failures_show_as_unsaved_and_are_retried() {
let mut disk = BTreeMap::new();
let files = Files::default();
let mut s = Session::new();
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
s.open_library_in(stores, true).unwrap();
flush(&files, &mut disk);
files.set_error(Some("QuotaExceededError".into()));
let r = s.execute("photo.rate", &json!({"rating": 5}));
assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}");
let (ops, why) = s.unsaved().unwrap();
assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}");
files.set_error(None);
s.persist_if_dirty();
s.persist().unwrap();
assert!(s.unsaved().is_none(), "written once storage works again");
flush(&files, &mut disk);
let id = s.selection.active.unwrap();
let files2 = Files::default();
for (k, v) in &disk {
files2.preload(k, v.clone());
}
let mut s2 = Session::new();
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false };
s2.open_library_in(stores, true).unwrap();
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
}
#[test]
fn library_survives_reload_through_flushes() {
let mut disk = BTreeMap::new();
+3
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backup;
pub mod bench;
pub mod files;
pub mod store;
@@ -24,6 +25,8 @@ pub mod wire;
#[cfg(target_arch = "wasm32")]
mod backend;
#[cfg(target_arch = "wasm32")]
mod safety;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
mod workers;
+256
View File
@@ -0,0 +1,256 @@
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
//! instead of a dead page after a panic, and backup / restore of the whole library
//! ([`crate::backup`]).
use std::cell::RefCell;
use std::collections::HashMap;
use js_sys::{Reflect, Uint8Array};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::backend::Backend;
use crate::backup::{
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
valid_hash, verify,
};
use crate::files::{Files, LIBRARY_FILES};
use crate::store::storage_key;
thread_local! {
/// Messages for the user, shown by the app on its next frame.
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
/// Queue a message for the user (shown for several seconds; also logged).
pub fn notice(text: impl Into<String>) {
let text = text.into();
log::warn!("{text}");
NOTICES.with(|n| n.borrow_mut().push(text));
crate::web::request_repaint();
}
/// Messages queued since the last call.
pub fn take_notices() -> Vec<String> {
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
}
fn document() -> Option<web_sys::Document> {
web_sys::window()?.document()
}
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
pub fn confirm(text: &str) -> bool {
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
}
/// Cover the page with a message (the app can't continue, or must not start).
pub fn show_blocking(text: &str) {
let Some(doc) = document() else { return };
let el = match doc.get_element_by_id("lightcraft_blocking") {
Some(el) => el,
None => {
let Ok(el) = doc.create_element("div") else { return };
el.set_id("lightcraft_blocking");
let _ = el.set_attribute(
"style",
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
);
if let Some(body) = doc.body() {
let _ = body.append_child(&el);
}
el
}
};
el.set_text_content(Some(text));
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
loading.remove();
}
}
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
/// frozen canvas, and say that the stored library is intact.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
(Some(s), _) => (*s).to_string(),
(_, Some(s)) => s.clone(),
_ => "unknown error".into(),
};
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
log::error!("LightCraft panicked{at}: {msg}");
show_blocking(&format!(
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
));
}));
}
/// The name of the lock that keeps a library to one tab.
const TAB_LOCK: &str = "lightcraft-library";
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
pub async fn acquire_tab_lock() -> Option<bool> {
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
let opts = js_sys::Object::new();
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
let ours = !lock.is_null() && !lock.is_undefined();
let _ = resolve.call1(&JsValue::NULL, &ours.into());
// keep the lock until the page goes away: a promise that never settles
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
});
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
log::warn!("Web Locks request failed: {e:?}");
}
});
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
}
/// Offer a blob as a download named `name`.
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
let e = |e: JsValue| format!("download failed: {e:?}");
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
let doc = document().ok_or("no document")?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
a.set_href(&url);
a.set_download(name);
a.click();
// revoke once the download has started
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
}
Ok(())
}
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
/// heap) and downloaded. `names`: content hash → the photo's file name.
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
let parts = js_sys::Array::new();
let mut zip = ZipWriter::default();
let mut bytes = 0f64;
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
let header = zip.entry(name, data)?;
parts.push(&Uint8Array::from(&header[..]));
parts.push(&Uint8Array::from(data));
bytes += (header.len() + data.len()) as f64;
Ok(())
};
add(&mut zip, "README.txt", README.as_bytes())?;
for name in LIBRARY_FILES {
if let Some(d) = files.get(name) {
add(&mut zip, &format!("library/{name}"), &d)?;
}
}
let mut originals = 0;
for hash in backend.list("originals").await? {
if !valid_hash(&hash) {
continue;
}
match backend.read(&storage_key(&hash)).await {
Ok(Some(data)) => {
let name = names.get(&hash).map_or("original", String::as_str);
add(&mut zip, &original_entry(&hash, name), &data)?;
originals += 1;
}
Ok(None) => {}
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
}
}
let tail = zip.finish()?;
parts.push(&Uint8Array::from(&tail[..]));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/zip");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
Ok((originals, bytes + tail.len() as f64))
}
/// Bytes `start..end` of a picked file.
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
Ok(Uint8Array::new(&buf).to_vec())
}
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
let len = file.size() as u64;
let tail = read_range(&file, len - tail_len(len), len).await?;
let (cd_offset, cd_size) = find_central(&tail)?;
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
let entries = parse_central(&cd)?;
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
}
let dir = restored_dir_name(js_sys::Date::now());
let mut written = 0usize;
for e in &entries {
let Some(key) = restore_key(&e.name, &dir) else { continue };
// originals are named by their content: one already stored is the same file
if key.starts_with("originals/") && backend.exists(&key).await? {
continue;
}
let local = read_range(&file, e.header, e.header + 30).await?;
let start = data_offset(e, &local)?;
let data = read_range(&file, start, start + e.size).await?;
verify(e, &data)?;
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
written += 1;
}
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
log::info!("lightcraft: restored {written} files into {dir}");
Ok(dir)
}
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
let ok = confirm(
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
The current library is not deleted: it stays in browser storage.",
);
if !ok {
return;
}
let Some(doc) = document() else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".zip,application/zip");
let inp = input.clone();
let mut before_reload = Some(before_reload);
let on_change = Closure::<dyn FnMut()>::new(move || {
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
let backend = backend.clone();
let before = before_reload.take();
notice(format!("Restoring {}…", file.name()));
wasm_bindgen_futures::spawn_local(async move {
match restore(file, backend).await {
Ok(_) => {
if let Some(f) = before {
f();
}
if let Some(w) = web_sys::window() {
let _ = w.location().reload();
}
}
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
}
});
});
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
on_change.forget();
input.click();
}
+142 -21
View File
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
use crate::bench::Bench;
use crate::files::{Files, FlushOp, LIBRARY_FILES};
use crate::safety::{self, notice};
use crate::store::{Originals, content_hash, download_name, storage_key};
use crate::wire::ThumbIndex;
use crate::workers::{THUMB_INDEX, Workers};
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
/// Where the library files live in browser storage.
const LIBRARY_DIR: &str = "library";
/// After a failed save, wait this long before trying again (ms).
const SAVE_RETRY_MS: f64 = 2000.0;
/// How long a notice stays on screen (s).
const NOTICE_SECS: f64 = 8.0;
/// How often view state, UI prefs and the thumbnail index are saved (ms).
const SAVE_EVERY_MS: f64 = 1000.0;
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
}
}
fn library_key(name: &str) -> String {
format!("{LIBRARY_DIR}/{name}")
/// Storage key of library file `name` in library folder `dir`.
fn library_key(dir: &str, name: &str) -> String {
format!("{dir}/{name}")
}
/// Repaint the app (notices from async tasks).
pub(crate) fn request_repaint() {
CTX.with(|c| {
if let Some(c) = c.borrow().as_ref() {
c.request_repaint();
}
});
}
/// URL options (`?bench&store=idb&workers=2&reset`).
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
if let Some(b) = &backend
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
{
// still importable for this session; it won't survive a reload
log::error!("storing {name} in browser storage failed: {e}");
// a photo whose bytes aren't stored would be gone after a reload: don't add it
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
return;
}
originals.added(&name, &hash, Arc::from(bytes));
ctx.request_repaint();
@@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(())
}
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
let (backup_backend, backup_files) = (backend.clone(), files);
let restore_backend = backend.clone();
Services {
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
let names: std::collections::HashMap<String, String> = session
.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
_ => None,
})
.collect();
let files = backup_files.clone();
notice("Backing up the library… (the download starts when it's ready)");
wasm_bindgen_futures::spawn_local(async move {
match safety::backup(files, b, names).await {
Ok((n, bytes)) => notice(format!(
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
if n == 1 { "" } else { "s" },
bytes / 1e6
)),
Err(e) => notice(format!("Backup failed: {e}")),
}
});
Ok(json!({"started": true}))
})),
restore_library: Some(Box::new(move |_session: &mut Session| {
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
let frozen = frozen.clone();
// once the backup is in place, stop saving the old library (the page reloads)
safety::pick_and_restore(b, move || frozen.set(true));
Ok(json!({"started": true}))
})),
pick_files: Some(Box::new(move || {
open_picker(originals.clone(), backend.clone(), ctx.clone());
Vec::new() // files arrive asynchronously and are imported on a later frame
@@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
}
}
/// Write dirty library files to storage, in order, until none are left.
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
'outer: loop {
let ops = files.take_dirty();
if ops.is_empty() {
if files.error().is_some() {
log::info!("lightcraft: saving works again");
}
files.set_error(None);
break;
}
for (i, op) in ops.iter().enumerate() {
let r = match op {
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
};
if let Err(e) = r {
log::error!("saving {} failed: {e}", op.name());
files.failed(&ops[i..]);
files.set_error(Some(e));
failed_at.set(perf_now());
break 'outer;
}
}
@@ -204,6 +262,8 @@ struct Boot {
backend: Option<Backend>,
files: Files,
index: ThumbIndex,
/// The library folder in storage (`library`, or a restored backup's).
lib_dir: String,
}
async fn boot(opts: Options) -> Boot {
@@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot {
};
let files = Files::default();
let mut index = ThumbIndex::default();
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
if let Some(b) = &backend {
if opts.reset {
match b.clear().await {
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
Err(e) => log::error!("clearing storage: {e}"),
// everything this site keeps in the browser, photos included: never without asking
let ok = safety::confirm(
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
This can't be undone. Delete it?",
);
if ok {
match b.clear().await {
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
Err(e) => log::error!("clearing storage: {e}"),
}
} else {
log::info!("lightcraft: ?reset declined; storage kept");
}
}
// a restored backup lives in its own folder (see `backup`)
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
let d = String::from_utf8_lossy(&d).trim().to_string();
if valid_library_dir(&d) {
lib_dir = d;
}
}
for name in LIBRARY_FILES {
match b.read(&library_key(name)).await {
match b.read(&library_key(&lib_dir, name)).await {
Ok(Some(data)) => files.preload(name, data),
Ok(None) => {}
Err(e) => log::error!("reading {name}: {e}"),
@@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot {
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
index = ThumbIndex::from_json(&j);
}
crate::backend::request_persistence();
crate::backend::request_persistence(|granted| {
if !granted {
notice(
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
);
}
});
// thumbnails stored without an index entry (index saved late, or lost): delete them
let b2 = b.clone();
let known: std::collections::HashSet<String> = b
@@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
index.len(),
index.total() as f64 / 1e6
);
Boot { opts, backend, files, index }
Boot { opts, backend, files, index, lib_dir }
}
struct WebApp {
@@ -276,6 +360,11 @@ struct WebApp {
backend: Option<Backend>,
workers: Option<Workers>,
flushing: Rc<Cell<bool>>,
/// When the last save to storage failed (ms; 0 = never).
flush_failed_at: Rc<Cell<f64>>,
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
frozen: Rc<Cell<bool>>,
lib_dir: String,
bench: Option<Bench>,
first_frame_logged: bool,
last_save: f64,
@@ -284,13 +373,13 @@ struct WebApp {
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
let Boot { opts, backend, files, index } = boot;
let Boot { opts, backend, files, index, lib_dir } = boot;
let originals = Originals::default();
let t = perf_now();
let mut session = Session::new();
originals.install(&mut session);
let stores = LibraryStores {
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
catalog: Box::new(files.store()),
files: Box::new(files.store()),
on_disk: false,
@@ -319,9 +408,16 @@ impl WebApp {
});
}
}
if backend.is_some() {
notice(
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
elsewhere and back up with File ▸ Back Up Library….",
);
}
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
let frozen = Rc::new(Cell::new(false));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
app.ui = ui;
@@ -347,6 +443,9 @@ impl WebApp {
backend,
workers,
flushing: Rc::new(Cell::new(false)),
flush_failed_at: Rc::new(Cell::new(0.0)),
frozen,
lib_dir,
bench: opts.bench.then(|| Bench::new(origin)),
first_frame_logged: false,
last_save: 0.0,
@@ -408,6 +507,9 @@ impl WebApp {
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
fn save(&mut self) {
if self.frozen.get() {
return;
}
let now = perf_now();
if now - self.last_save >= SAVE_EVERY_MS {
self.last_save = now;
@@ -429,12 +531,16 @@ impl WebApp {
});
}
}
// after a failure, retry every couple of seconds rather than every frame
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
if let Some(b) = &self.backend
&& self.files.is_dirty()
&& !self.flushing.get()
&& !backing_off
{
self.flushing.set(true);
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
}
}
}
@@ -464,8 +570,10 @@ impl WebApp {
let (orig_n, orig_bytes) = self.originals.usage();
json!({
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
"library": self.lib_dir,
"dirty": self.files.is_dirty(),
"flushing": self.flushing.get(),
"saveError": self.files.error(),
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
"originalsInMemory": orig_n,
"originalBytesInMemory": orig_bytes,
@@ -490,6 +598,10 @@ impl WebApp {
impl eframe::App for WebApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for text in safety::take_notices() {
let now = ctx.input(|i| i.time);
self.app.ui.toast = Some((text, now + NOTICE_SECS));
}
self.run_inbox();
self.import_dropped(ctx);
self.load_originals(ctx);
@@ -527,9 +639,18 @@ impl eframe::App for WebApp {
#[wasm_bindgen]
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
// one tab per library: two would each keep their own copy and overwrite each other's saves
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
safety::show_blocking(
"LightCraft is already open in another tab or window of this browser.\n\n\
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
);
return;
}
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
+24 -10
View File
@@ -15,10 +15,9 @@ use std::sync::Arc;
use lightcraft_engine::catalog::Source;
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::Lru;
use lightcraft_raster::Rgb32f;
use serde::{Deserialize, Serialize};
use crate::store::hash_of_path;
@@ -31,6 +30,8 @@ pub struct WireJob {
/// Decode the source at most this long.
pub max_edge: usize,
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
@@ -55,7 +56,7 @@ impl WireJob {
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Loaded(img) => img.width.max(img.height),
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
SourceRef::Smart { .. } => 2560,
};
WireJob {
@@ -63,6 +64,7 @@ impl WireJob {
origin: job.origin.clone(),
max_edge,
raw: job.info.raw,
relative_wb: job.info.relative_wb,
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
@@ -79,7 +81,14 @@ impl WireJob {
}
pub fn info(&self) -> SourceInfo {
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
SourceInfo {
raw: self.raw,
as_shot_temp: self.as_shot_temp,
as_shot_tint: self.as_shot_tint,
lens: self.lens,
relative_wb: self.relative_wb,
..Default::default()
}
}
pub fn request(&self) -> RenderRequest {
@@ -119,7 +128,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
/// The synchronous part of a worker: decoded-source cache + rendering.
pub struct WorkerCore {
sources: Lru<String, Arc<Rgb32f>>,
sources: Lru<String, DecodedSource>,
media: MediaCache,
stages: HashMap<String, Arc<StageCache>>,
}
@@ -150,21 +159,25 @@ impl WorkerCore {
Some(s) => s,
None => {
let src = match (&job.origin, original) {
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
(Source::File { .. }, Some(bytes)) => {
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
DecodedSource::new(Arc::new(image), Some(info))
}
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
};
let cost = src.width * src.height * 12 + 64;
let cost = src.image.width * src.image.height * 12 + 64;
self.sources.insert(key, src.clone(), cost);
src
}
};
let info = src.info_or(job.info());
Ok(match &job.stages {
Some(view) => {
let st = self.stages.entry(view.clone()).or_default().clone();
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
}
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
})
}
}
@@ -295,6 +308,7 @@ mod tests {
origin,
max_edge: 512,
raw: false,
relative_wb: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
+102 -12
View File
@@ -71,6 +71,43 @@ impl eframe::App for App {
}
}
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12).
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
}
c
}
/// Written while the GPU compute device is created, removed once that returned
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
fn gpu_marker_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("gpu-init.marker"))
}
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
/// driver while creating the device), the notice to show — GPU rendering then starts off.
fn gpu_crash_check() -> Option<String> {
let marker = gpu_marker_path();
let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
lightcraft_engine::gpu::backend::set_init_marker(marker);
left.map(|what| gpu_crash_notice(&what))
}
fn gpu_crash_notice(what: &str) -> String {
format!(
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
)
}
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft"))
@@ -202,7 +239,9 @@ impl PrefsWriter {
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
@@ -254,7 +293,7 @@ fn services() -> Services {
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
"Photos",
lightcraft_ui_egui::i18n::tr("Photos"),
&[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
"jxl", "gif", "bmp",
@@ -268,8 +307,11 @@ fn services() -> Services {
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -278,24 +320,24 @@ fn services() -> Services {
})),
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Auto-Tag from Tracklog")
.add_filter("GPS Track Log", &["gpx"])
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Presets")
.add_filter("LightCraft Preset", &["lcpreset"])
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve", "json"])
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -304,8 +346,8 @@ fn services() -> Services {
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve"])
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -316,6 +358,9 @@ fn services() -> Services {
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
}
}
@@ -366,6 +411,10 @@ OPTIONS:
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
see docs/control-protocol.md)
--version, --help
ENVIRONMENT:
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
";
fn main() -> eframe::Result {
@@ -408,6 +457,11 @@ fn main() -> eframe::Result {
.map(Into::into)
});
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
let gpu_crash = gpu_crash_check();
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
@@ -420,6 +474,7 @@ fn main() -> eframe::Result {
.with_icon(app_icon())
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
.with_app_id(APP_ID),
wgpu_options: window_wgpu_options(),
..Default::default()
};
eframe::run_native(
@@ -431,6 +486,7 @@ fn main() -> eframe::Result {
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
// what's on disk now: only changes are written
@@ -440,6 +496,11 @@ fn main() -> eframe::Result {
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -497,6 +558,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
#[test]
fn gpu_crash_marker_is_reported_once() {
let d = dir("gpu-marker");
let m = d.join("gpu-init.marker");
assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None);
std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap();
let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap();
let notice = gpu_crash_notice(&what);
assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}");
assert!(!m.exists());
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default.
#[test]
fn window_backends_follow_the_platform_default() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
let b = n.instance_descriptor.backends;
if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() {
if cfg!(windows) {
assert_eq!(b, eframe::wgpu::Backends::DX12);
} else if cfg!(target_os = "macos") {
assert_eq!(b, eframe::wgpu::Backends::METAL);
}
}
assert!(!b.is_empty());
}
#[test]
fn ui_json_is_written_atomically() {
let d = dir("atomic");
+40 -22
View File
@@ -148,7 +148,7 @@ fn yields_to_text(sc: &str) -> bool {
/// `&` marks a mnemonic in muda labels.
fn label_text(s: &str) -> String {
s.replace('&', "&&")
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
}
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
out.push(';');
}
}
let mut s = String::new();
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -210,30 +210,30 @@ impl NativeMenu {
// the application menu
let app_menu = Submenu::new("LightCraft", true);
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
&PredefinedMenuItem::separator(),
&quit,
]);
let _ = self.menu.append(&app_menu);
for (title, nodes) in &bar {
let sub = Submenu::new(title, true);
if title == app.ui.language.tr("Window") {
let sub = Submenu::new(label_text(title), true);
if title == "Window" {
let _ = sub.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(Some("Zoom")),
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
&PredefinedMenuItem::separator(),
]);
}
@@ -245,15 +245,21 @@ impl NativeMenu {
.collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == app.ui.language.tr("File") {
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
let _ = sub.append_items(&[
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
]);
}
if title == app.ui.language.tr("Window") {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
if title == "Window" {
let _ = sub.append_items(&[
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
]);
sub.set_as_windows_menu_for_nsapp();
}
if title == app.ui.language.tr("Help") {
if title == "Help" {
sub.set_as_help_menu_for_nsapp();
}
let _ = self.menu.append(&sub);
@@ -282,12 +288,23 @@ impl NativeMenu {
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
let handle = match checked {
Some(c) => {
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
let it = CheckMenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
*c,
accel,
);
let _ = sub.append(&it);
Handle::Check(it)
}
None => {
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
let it = MenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
accel,
);
let _ = sub.append(&it);
Handle::Plain(it)
}
@@ -325,6 +342,7 @@ impl NativeMenu {
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
/// accelerators with the app state.
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
lightcraft_ui_egui::i18n::set_language(app.ui.language);
while let Ok(key) = self.rx.try_recv() {
if key == QUIT {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -352,8 +370,8 @@ impl NativeMenu {
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
if it.label != *label {
match &it.handle {
Handle::Plain(h) => h.set_text(label_text(label)),
Handle::Check(h) => h.set_text(label_text(label)),
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
}
it.label = label.clone();
}
+2
View File
@@ -21,6 +21,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
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:
https://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.
+2 -1
View File
@@ -45,6 +45,7 @@
//! | (none) | early builds: a log without a snapshot | |
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | 2026-10 | `Photo.fallback_matrix` (raw without a colour matrix of its own: relative WB) |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -69,7 +70,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 2;
pub const VERSION: u32 = 3;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+41 -1
View File
@@ -276,6 +276,13 @@ pub struct Photo {
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_baseline: Option<u64>,
/// Raw files: the decoder had no colour matrix for this file (`matrix_is_fallback`: every
/// non-DNG raw today), so its colour is estimated from its own embedded JPEG and its white
/// balance controls are relative to the as-shot look ([`Photo::relative_wb`]). Recorded at
/// import (catalog format v3). `None`: imported before it was recorded — see
/// [`Photo::relative_wb`] for how those photos keep the white balance they were edited with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_matrix: Option<bool>,
}
/// What assisted culling measured on a photo.
@@ -324,6 +331,7 @@ impl Photo {
analysis: None,
preview_only: None,
local_baseline: None,
fallback_matrix: None,
}
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
@@ -331,6 +339,14 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// White balance controls are relative to the camera's as-shot look, as for rendered
/// photographs: the raw has vendor WB multipliers but no colour matrix
/// ([`Photo::fallback_matrix`]), so no measured illuminant to put an absolute Kelvin on.
/// Photos imported before that fact was recorded keep the rule they were edited under: Sony
/// ARW relative, every other raw absolute (so a saved Custom white balance reads the same).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && self.fallback_matrix.unwrap_or_else(|| self.format.eq_ignore_ascii_case("ARW"))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings {
@@ -342,7 +358,8 @@ impl Photo {
/// The built-in defaults for this photo, before any user default preset: raws start from
/// their as-shot white balance; embedded lens corrections on when the file has them.
pub fn camera_defaults(&self) -> DevelopSettings {
let mut d = match self.as_shot_wb {
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),
};
@@ -415,4 +432,27 @@ mod edited_tests {
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn relative_wb_follows_the_decoder_fact_and_keeps_old_photos_rule() {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.nef", "NEF", 10, 10, "");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
// imported before the fact was recorded: only ARW was relative
assert!(!p.relative_wb());
assert_eq!(p.camera_defaults().wb.temp, 5200.0);
p.fallback_matrix = Some(true);
assert!(p.relative_wb());
assert_eq!(p.camera_defaults().wb.temp, 6500.0);
p.format = "ARW".into();
p.fallback_matrix = None;
assert!(p.relative_wb());
p.fallback_matrix = Some(false);
assert!(!p.relative_wb(), "a raw with a matrix of its own has absolute WB");
p.fallback_matrix = Some(true);
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb(), "shown from its embedded JPEG");
let json = serde_json::to_string(&Photo::new(PhotoId(2), Source::Demo { scene: 0 }, "b.jpg", "JPEG", 1, 1, "")).unwrap();
assert!(!json.contains("fallback_matrix"), "{json}");
}
}
+79 -8
View File
@@ -1,11 +1,23 @@
//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and
//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]).
//!
//! **Write policy** (issue #134): what can't be recreated — originals, XMP sidecars, the catalog,
//! settings, DNGs that replace raws, smart previews (the only stand-in while an original is
//! offline), an edit copy that becomes a library photo — is written **durably**: synced to disk
//! before it counts as written. What can always be made again from the originals — exports,
//! renders, screenshots, merge previews — is written **atomically** only: a half-written file
//! never replaces a good one, but there is no per-file sync (on a USB drive or a NAS that sync
//! dominates a large export).
//!
//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive
//! either the old or the complete new content is there — never a truncated mix. The content
//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and
//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp
//! file and leaves the target as it was.
//! - [`write_atomic_nosync`] is the same temp file + rename without the two syncs, for
//! recreatable outputs only: a failed write still removes the temp file and leaves the target as
//! it was, but after a power cut the new file may be lost (on some file systems left empty) —
//! it is then simply exported again.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
@@ -21,6 +33,19 @@ use std::sync::atomic::{AtomicU64, Ordering};
thread_local! {
static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
static SYNCS: Cell<u64> = const { Cell::new(0) };
}
/// How many file and folder syncs this module made **on the calling thread** (for tests: a
/// durable write path syncs, a recreatable output doesn't).
#[doc(hidden)]
pub fn syncs_on_this_thread() -> u64 {
SYNCS.with(Cell::get)
}
fn sync_file(f: &File) -> io::Result<()> {
SYNCS.with(|c| c.set(c.get() + 1));
f.sync_all()
}
/// Fault injection for tests (in any crate): while the returned guard lives, every write made by
@@ -78,7 +103,7 @@ fn parent_of(path: &Path) -> &Path {
/// where NTFS journals the rename itself, so errors are ignored.
pub fn sync_dir(dir: &Path) {
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
let _ = sync_file(&d);
}
}
@@ -100,9 +125,9 @@ fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display())))
}
/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp
/// file is removed. Returns its path and length.
fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
/// Write a temp file next to `path` with what `fill` writes, synced to disk when `sync`. On
/// failure the temp file is removed. Returns its path and length.
fn write_temp(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
let (tmp, file) = create_temp(path)?;
let r = (|| {
let limit = FAIL_AFTER.with(Cell::get);
@@ -111,7 +136,9 @@ fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()
w.flush()?;
let n = w.n;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
f.sync_all()?;
if sync {
sync_file(&f)?;
}
Ok(n)
})();
match r {
@@ -132,12 +159,26 @@ pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old
/// file). Returns the length written.
pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
let (tmp, n) = write_temp(path, fill)?;
replace_with(path, true, fill)
}
/// Replace (or create) `path` with `data` atomically but **without syncing** it to disk — only for
/// outputs that can always be recreated (exports, renders, screenshots); see the module docs.
/// Anything that can't be made again goes through [`write_atomic`].
pub fn write_atomic_nosync(path: &Path, data: &[u8]) -> io::Result<()> {
replace_with(path, false, &mut |w| w.write_all(data)).map(|_| ())
}
/// Temp file (synced when `sync`) renamed over `path`, then the folder synced when `sync`.
fn replace_with(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
let (tmp, n) = write_temp(path, sync, fill)?;
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(parent_of(path));
if sync {
sync_dir(parent_of(path));
}
Ok(n)
}
@@ -155,8 +196,11 @@ pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u
let Some(first) = candidates.peek().cloned() else {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write"));
};
let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?;
let (tmp, _) = write_temp(&first, true, &mut |w| w.write_all(data))?;
let r = (|| {
// Kept (issue #134 point 4): these files replace raws (Convert / Copy as DNG) or become
// library photos (merges). On a local disk the read usually comes from the cache, but on a
// network volume it can go back to the server, and next to encoding a DNG it costs little.
if !same_content(&tmp, data)? {
return Err(io::Error::other("the written file reads back different (failing drive or connection?)"));
}
@@ -287,6 +331,33 @@ mod tests {
let _ = fs::remove_dir_all(&d);
}
#[test]
fn recreatable_outputs_are_atomic_without_a_sync() {
let d = temp("nosync");
let p = d.join("export.jpg");
let before = syncs_on_this_thread();
write_atomic_nosync(&p, b"one").unwrap();
write_atomic_nosync(&p, b"two two").unwrap();
assert_eq!(syncs_on_this_thread(), before, "no file or folder sync");
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["export.jpg"], "no temp file left");
// still atomic: a failure part-way leaves the previous file and no temp file
{
let _f = fail_writes_after(3);
assert!(write_atomic_nosync(&p, &[9u8; 10_000]).is_err());
}
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["export.jpg"]);
// the durable writers do sync (the file; the folder too where it can be opened)
let before = syncs_on_this_thread();
write_atomic(&p, b"three").unwrap();
let mid = syncs_on_this_thread();
assert!(mid > before);
write_new(&d.join("new.dng"), b"dng").unwrap();
assert!(syncs_on_this_thread() > mid);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn new_files_never_replace_anything() {
let d = temp("new");
+26 -1
View File
@@ -47,6 +47,28 @@ fn v1_library_loads_and_is_upgraded() {
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
/// v3 records whether a raw lacks a colour matrix; v2 photos load without it and keep the white
/// balance rule they were edited under (only ARW relative).
#[test]
fn v2_raws_load_without_the_matrix_fact() {
let mut c = Catalog::new();
for (name, format) in [("a.nef", "NEF"), ("b.arw", "ARW")] {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/p/{name}") }, name, format, 4, 3, "2026-01-01");
p.kind = MediaKind::Raw;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let snap = c.to_snapshot();
assert!(!snap.contains("fallback_matrix"));
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"seq\":2,\"catalog\":{snap}}}\n").into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(r.upgraded_from, Some(2));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
let rel: Vec<_> = loaded.photos().map(|p| (p.format.clone(), p.fallback_matrix, p.relative_wb())).collect();
assert!(rel.contains(&("NEF".into(), None, false)) && rel.contains(&("ARW".into(), None, true)), "{rel:?}");
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
@@ -147,6 +169,8 @@ fn bad_crc_is_still_a_torn_tail() {
/// variant here under the new version.
#[test]
fn op_variants_are_versioned() {
/// The newest format that added serialized fields but no op.
const FIELDS_ONLY_SINCE: u32 = 3;
fn since(op: &Op) -> u32 {
match op {
Op::AddPhoto { .. }
@@ -182,5 +206,6 @@ fn op_variants_are_versioned() {
}
}
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
// v3 added a field only (`Photo.fallback_matrix`); a new op takes the next version.
assert_eq!(newest.max(FIELDS_ONLY_SINCE), VERSION, "the newest op's (or field's) version must be the current format version");
}
@@ -0,0 +1,69 @@
//! Evaluate raw starting looks against each file's own embedded JPEG (docs/camera-preview-colour.md):
//!
//! - `camera_look_eval preview RAW OUT.jpg [EDGE]` writes the raw's embedded camera JPEG, oriented,
//! as sRGB (≤ EDGE px, default 1200), for side-by-side viewing with a render of the raw;
//! - `camera_look_eval delta REFERENCE.jpg RENDER.jpg… [--edge N]` prints, for each render, the
//! mean CIE76 ΔE and mean L* against the reference, both reduced to ≤ N px (default 200).
//!
//! Renders come from `lightcraft-cli render RAW -o OUT.jpg --size 1200`; `LIGHTCRAFT_PROFILE=1`
//! makes the render print the fit's alignment, held-out error and verdict.
use lightcraft_codecs::{DecodeOptions, decode};
use lightcraft_raster::resample::{Filter, fit};
fn lab(rgb2020: [f32; 3]) -> [f64; 3] {
// linear Rec.2020 → XYZ (D65) → CIELAB (D65 white)
let m = [[0.636958, 0.144617, 0.168881], [0.262700, 0.677998, 0.059302], [0.0, 0.028073, 1.060985]];
let p = rgb2020.map(|v| f64::from(v).max(0.0));
let xyz: [f64; 3] = std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2]);
let white = [0.95047, 1.0, 1.08883];
let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (24389.0 / 27.0 * t + 16.0) / 116.0 };
let [fx, fy, fz] = std::array::from_fn(|i| f(xyz[i] / white[i]));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
fn load(path: &str, edge: usize) -> Result<lightcraft_raster::Rgb32f, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let d = decode(&bytes, DecodeOptions::fit(edge as u32 * 2, edge as u32 * 2)).map_err(|e| format!("{path}: {e}"))?;
Ok(fit(&d.to_working(), edge, edge, Filter::Box))
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("preview") if args.len() >= 3 => {
let bytes = std::fs::read(&args[1]).map_err(|e| e.to_string())?;
let edge = args.get(3).and_then(|e| e.parse().ok()).unwrap_or(1200);
let img = lightcraft_engine::files::embedded_preview_srgb(&bytes, edge).ok_or("no embedded preview")?;
let jpeg = lightcraft_preview::encode_jpeg(&img).ok_or("can't encode")?;
std::fs::write(&args[2], jpeg).map_err(|e| e.to_string())
}
Some("delta") if args.len() >= 3 => {
let mut edge = 200;
let mut files = Vec::new();
let mut it = args[1..].iter();
while let Some(a) = it.next() {
if a == "--edge" {
edge = it.next().and_then(|v| v.parse().ok()).ok_or("--edge N")?;
} else {
files.push(a.clone());
}
}
let reference = load(&files[0], edge)?;
for path in &files[1..] {
let render = load(path, edge)?;
let render = lightcraft_raster::resample::resize(&render, reference.width, reference.height, Filter::Box);
let (mut de, mut l_ref, mut l_out) = (0.0, 0.0, 0.0);
for (a, b) in reference.data.iter().zip(&render.data) {
let (a, b) = (lab(*a), lab(*b));
de += ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
l_ref += a[0];
l_out += b[0];
}
let n = reference.data.len().max(1) as f64;
println!("{path}\tdE76 {:.2}\tL* {:.1} (reference {:.1})", de / n, l_out / n, l_ref / n);
}
Ok(())
}
_ => Err("usage: camera_look_eval preview RAW OUT.jpg [EDGE] | delta REFERENCE.jpg RENDER.jpg… [--edge N]".into()),
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Is a file there? — answered without blocking the caller.
//!
//! On a sleeping NAS, a dropped share or a USB drive spinning up, one `stat` can take as long as
//! the OS or SMB timeout. The UI asks about files every frame (grid cells, the Info panel, the
//! Missing Photos view), so in the app the answers come from a cache that a worker thread fills
//! ([`Availability::run_in_background`]): an unknown file is reported as `None` ("assume it is
//! there"; the render worker finds out if it isn't), known answers are rechecked after
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
//! fresh check, as before.
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
/// Checks whether a path exists (injectable: tests use a slow one).
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
pub const RECHECK: Duration = Duration::from_secs(15);
fn fs_exists(path: &str) -> bool {
// the browser build has no file system: its originals are always "there"
cfg!(target_arch = "wasm32") || std::path::Path::new(path).exists()
}
#[derive(Default)]
struct State {
/// path → (exists, when checked)
known: HashMap<String, (bool, std::time::Instant)>,
queue: VecDeque<String>,
queued: HashSet<String>,
worker: bool,
/// Bumped whenever an answer changes (callers recompute what depends on it).
generation: u64,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// File availability, synchronous or cached + background-checked. Cloning shares the cache.
#[derive(Clone)]
pub struct Availability {
probe: ExistsProbe,
background: Option<Waker>,
state: Arc<Mutex<State>>,
}
impl Default for Availability {
fn default() -> Self {
Self { probe: Arc::new(fs_exists), background: None, state: Arc::default() }
}
}
impl std::fmt::Debug for Availability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Availability").field("background", &self.background.is_some()).finish()
}
}
impl Availability {
/// Replace the existence check (tests: a slow or counting probe). Forgets cached answers.
pub fn set_probe(&mut self, probe: ExistsProbe) {
self.probe = probe;
let mut st = lock(&self.state);
st.known.clear();
st.generation += 1;
}
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
/// answer changed. No effect in the browser build (no threads, no file system).
pub fn run_in_background(&mut self, wake: Waker) {
if cfg!(target_arch = "wasm32") {
return;
}
self.background = Some(wake);
}
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
pub fn probe(&self) -> ExistsProbe {
self.probe.clone()
}
pub fn is_background(&self) -> bool {
self.background.is_some()
}
/// Does `path` exist? Synchronous mode: checked now. Background mode: the cached answer
/// (`None` while unknown); unknown or stale answers are queued for the worker.
pub fn exists(&self, path: &str) -> Option<bool> {
let Some(wake) = &self.background else {
return Some((self.probe)(path));
};
let mut st = lock(&self.state);
let now = std::time::Instant::now();
let cached = st.known.get(path).copied();
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
st.queued.insert(path.to_string());
st.queue.push_back(path.to_string());
if !st.worker {
st.worker = true;
drop(st);
self.spawn_worker(wake.clone());
return cached.map(|c| c.0);
}
}
cached.map(|c| c.0)
}
/// Known to be missing (unknown counts as present).
pub fn is_offline(&self, path: &str) -> bool {
self.exists(path) == Some(false)
}
/// Changes whenever a cached answer changes.
pub fn generation(&self) -> u64 {
lock(&self.state).generation
}
/// Paths waiting for the worker.
pub fn pending(&self) -> usize {
lock(&self.state).queue.len()
}
fn spawn_worker(&self, wake: Waker) {
let (state, probe) = (self.state.clone(), self.probe.clone());
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
loop {
let path = {
let mut st = lock(&state);
let Some(p) = st.queue.pop_front() else {
st.worker = false;
return;
};
p
};
let ok = probe(&path);
let changed = {
let mut st = lock(&state);
st.queued.remove(&path);
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
let changed = prev.map(|p| p.0) != Some(ok);
if changed {
st.generation += 1;
}
changed
};
if changed {
wake();
}
}
});
if started.is_err() {
// try again on the next question
lock(&self.state).worker = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn synchronous_by_default() {
let mut a = Availability::default();
let n = Arc::new(AtomicUsize::new(0));
let c = n.clone();
a.set_probe(Arc::new(move |p| {
c.fetch_add(1, Ordering::SeqCst);
p == "here"
}));
assert_eq!(a.exists("here"), Some(true));
assert_eq!(a.exists("gone"), Some(false));
assert_eq!(a.exists("gone"), Some(false));
assert_eq!(n.load(Ordering::SeqCst), 3, "every question is a fresh check");
}
#[test]
fn background_answers_from_the_cache_without_blocking() {
let mut a = Availability::default();
let caller = std::thread::current().id();
let on_caller = Arc::new(AtomicUsize::new(0));
let c = on_caller.clone();
a.set_probe(Arc::new(move |p| {
if std::thread::current().id() == caller {
c.fetch_add(1, Ordering::SeqCst);
}
std::thread::sleep(Duration::from_millis(200)); // a sleeping NAS
p == "here"
}));
let woke = Arc::new(AtomicUsize::new(0));
let w = woke.clone();
a.run_in_background(Arc::new(move || {
w.fetch_add(1, Ordering::SeqCst);
}));
let t0 = std::time::Instant::now();
assert_eq!(a.exists("here"), None, "unknown at first");
assert_eq!(a.exists("gone"), None);
assert!(t0.elapsed() < Duration::from_millis(150), "the caller never waits for the probe");
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while (a.exists("here").is_none() || a.exists("gone").is_none()) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(a.exists("here"), Some(true));
assert_eq!(a.exists("gone"), Some(false));
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
assert!(woke.load(Ordering::SeqCst) >= 2);
assert!(a.generation() >= 2);
}
}
+423
View File
@@ -0,0 +1,423 @@
//! Estimate a raw's starting look from its own embedded JPEG, for raws whose decoder has no colour
//! matrix (`matrix_is_fallback`: every non-DNG raw today, whatever the make). Colour and luminance
//! are fitted separately; the JPEG supplies correspondences only, never output pixels or a
//! replacement for raw editing. Each file's result (also "no usable fit") is cached by a content
//! fingerprint and [`FIT_VERSION`], so thumbnails, previews and exports of a file fit it once.
use std::sync::Mutex;
use lightcraft_color::{Mat3, luminance_2020};
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
use lightcraft_preview::{Hash128, Hasher128, Lru};
use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit},
};
use lightcraft_raw::{RawImage, color::CameraTransform};
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CameraLook {
pub matrix: Mat3,
pub tone: CameraTone,
}
/// Bump whenever the fit or its gates change what a file gets: cached results of older versions
/// are then never used (the version is part of the cache key).
pub(crate) const FIT_VERSION: u64 = 2;
/// Results kept: one per recently decoded file (a few hundred bytes each).
const CACHE_ENTRIES: usize = 512;
static CACHE: Mutex<Option<Lru<Hash128, Option<CameraLook>>>> = Mutex::new(None);
/// The file's look, from the cache or fitted now.
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !transform.matrix_is_fallback {
return None;
}
let jpeg = lightcraft_raw::embedded_preview(bytes);
let key = cache_key(raw, bytes, jpeg.as_deref(), transform);
if let Some(hit) = CACHE.lock().ok().and_then(|mut c| c.as_mut().and_then(|c| c.get(&key).copied())) {
return hit;
}
let t0 = std::time::Instant::now();
let look = jpeg.and_then(|jpeg| fit_file(raw, &jpeg, transform));
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look ({:?}) fitted in {:.1} ms: {}", raw.format, t0.elapsed().as_secs_f64() * 1e3, if look.is_some() { "accepted" } else { "rejected" });
}
if let Ok(mut c) = CACHE.lock() {
c.get_or_insert_with(|| Lru::new(CACHE_ENTRIES)).insert(key, look, 1);
}
look
}
/// A fingerprint of everything the fit reads: the file's length, its whole embedded JPEG, a
/// strided sample of the file (the mosaic), the decoder's crop and colour transform, and
/// [`FIT_VERSION`]. Hashing all ~25 MB of a raw would cost a third of the fit it saves.
fn cache_key(raw: &RawImage, bytes: &[u8], jpeg: Option<&[u8]>, t: &CameraTransform) -> Hash128 {
let mut h = Hasher128::new();
h.u64(FIT_VERSION).u64(bytes.len() as u64).u64(jpeg.map_or(u64::MAX, |j| j.len() as u64)).update(jpeg.unwrap_or_default());
let step = (bytes.len() / 4096).max(64);
for chunk in bytes.chunks(step) {
h.update(chunk.get(..32).unwrap_or(chunk));
}
let c = raw.crop;
for v in [c.x, c.y, c.width, c.height, raw.active_area.width, raw.active_area.height] {
h.u64(v as u64);
}
for v in t.matrix.0.iter().flatten().copied().chain(t.wb.map(f64::from)).chain([t.baseline_exposure]) {
h.u64(v.to_bits());
}
h.finish()
}
fn reject<T>(why: &str) -> Option<T> {
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look rejected: {why}");
}
None
}
fn fit_file(raw: &RawImage, jpeg: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
let Ok(decoded) = lightcraft_codecs::decode(jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }) else {
return reject("embedded JPEG does not decode");
};
let reference = decoded.to_working();
let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
return reject("empty crop or preview");
}
// Previews are stored in sensor orientation (EXIF orientation applies to both alike). A
// preview of a different shape — a 16:9 or 1:1 picture-style crop, a rotated thumbnail —
// can't be paired pixel for pixel.
let aspect = crop.width as f64 / crop.height as f64;
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > MAX_ASPECT_MISMATCH {
return reject("preview aspect differs from the raw crop");
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2;
let Some(sensor) = raw.develop_binned(k, 0.99).ok().flatten() else {
return reject("mosaic can't be binned");
};
let mut sensor = fit(&sensor, 96, 96, Filter::Box);
// Same shape within MAX_ASPECT_MISMATCH: pair the pixels exactly (a fit could round one edge
// differently, e.g. a 3:2 preview of a 4950×3280 crop).
let reference = lightcraft_raster::resample::resize(&reference, sensor.width, sensor.height, Filter::Box);
let gain = 2f32.powf(transform.baseline_exposure as f32);
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
// Same aspect is not the same framing: a preview cropped or shifted differently (some bodies'
// previews leave out sensor borders the raw crop keeps) would pair unrelated pixels.
let alignment = alignment(&sensor, &reference)?;
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look alignment: correlation {:.3} at (0, 0), best {:.3} at {:?}", alignment.at_zero, alignment.best, alignment.shift);
}
if alignment.at_zero < MIN_ALIGNMENT || alignment.shift.0.abs() > 1 || alignment.shift.1.abs() > 1 {
return reject("preview framing doesn't match the raw");
}
let look = fit_pairs(&sensor, &reference)?;
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look: {:?}, {:?}", look.matrix.0, look.tone);
}
Some(look)
}
/// Previews whose width/height ratio differs from the raw crop's by more than this are not used.
const MAX_ASPECT_MISMATCH: f64 = 0.02;
/// Correlation of log luminance (sensor proxy vs preview, both ~96 px) the unshifted pairing must
/// reach; the best of the ±3 px shifts must also be within ±1 px of it.
const MIN_ALIGNMENT: f64 = 0.85;
struct Alignment {
at_zero: f64,
best: f64,
shift: (i32, i32),
}
/// How well the two proxies line up: Pearson correlation of log luminance for whole-pixel shifts
/// of up to ±3 px (the camera's tone curve is monotone, so aligned images correlate strongly).
fn alignment(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Alignment> {
let (w, h) = (sensor.width, sensor.height);
if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h {
return reject("proxies differ in size");
}
// Log luminance, box-blurred (5×5): fine texture (foliage at ~96 px) and the small geometric
// differences of in-camera distortion correction must not read as misalignment; a crop,
// shift or rotation of the framing still does.
let log = |img: &Rgb32f| -> Vec<f64> {
let l: Vec<f64> = img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect();
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let (mut sum, mut n) = (0.0, 0.0);
for yy in (y - 2).max(0)..(y + 3).min(h as i64) {
for xx in (x - 2).max(0)..(x + 3).min(w as i64) {
if let Some(v) = l.get(yy as usize * w + xx as usize) {
sum += v;
n += 1.0;
}
}
}
sum / f64::max(n, 1.0)
})
.collect()
};
let (a, b) = (log(sensor), log(reference));
const M: i32 = 3;
let corr = |dx: i32, dy: i32| -> f64 {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for y in M..h as i32 - M {
for x in M..w as i32 - M {
let (Some(&u), Some(&v)) = (a.get((y * w as i32 + x) as usize), b.get(((y + dy) * w as i32 + x + dx) as usize)) else {
continue;
};
n += 1.0;
sa += u;
sb += v;
saa += u * u;
sbb += v * v;
sab += u * v;
}
}
let cov = sab - sa * sb / n;
let var = (saa - sa * sa / n) * (sbb - sb * sb / n);
if n < 2.0 || var <= 1e-12 { 0.0 } else { cov / var.sqrt() }
};
let at_zero = corr(0, 0);
let mut best = (at_zero, (0, 0));
for dy in -M..=M {
for dx in -M..=M {
let c = corr(dx, dy);
if c > best.0 + 1e-9 {
best = (c, (dx, dy));
}
}
}
Some(Alignment { at_zero, best: best.0, shift: best.1 })
}
/// A fit must cut the held-out squared error to below this share of the fallback's.
const MIN_IMPROVEMENT: f64 = 0.7;
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
/// cannot follow): visibly better renders that 0.055 rejected.
const MAX_HOLDOUT_RMS: f64 = 0.10;
fn luma(p: [f64; 3]) -> f64 {
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
}
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
let scene = scene.map(|v| v.max(0.0));
let y = luma(scene);
if y <= 0.0 {
return [0.0; 3];
}
scene.map(|v| v * f64::from(tone.apply(y as f32)) / y)
}
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
let mut pairs = Vec::new();
let mut colour = 0;
for (input, output) in sensor.data.iter().zip(&reference.data) {
let y = luminance_2020(*output);
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|| !output.iter().all(|v| v.is_finite() && *v > 0.004 && *v < 0.98)
|| !(0.015..0.85).contains(&y)
{
continue;
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
let max = output.iter().copied().fold(0.0, f32::max);
colour += usize::from(max - min > 0.05);
pairs.push((input.map(f64::from), output.map(f64::from)));
}
if pairs.len() < 256 || colour < pairs.len() / 20 {
return reject("too few usable or coloured pixels");
}
let mut gram = [[0.0; 3]; 3];
let mut cross = [[0.0; 3]; 3];
for (i, (x, y)) in pairs.iter().enumerate() {
if i % 3 == 0 {
continue;
}
let (lx, ly) = (luma(*x), luma(*y));
for row in 0..3 {
for col in 0..3 {
// Normalising by luminance prevents a camera S-curve from corrupting colour.
gram[row][col] += x[row] * x[col] / (lx * lx);
cross[row][col] += y[row] * x[col] / (ly * lx);
}
}
}
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
let inverse = Mat3(gram).inverse()?;
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
return reject("ill-conditioned colour samples");
}
let regularization = trace * 1e-4;
for i in 0..3 {
gram[i][i] += regularization;
cross[i][i] += regularization;
}
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) {
return reject("unbounded colour matrix");
}
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let curve = fit_tone(tone_pairs)?;
let tone = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
let mut before = 0.0;
let mut after = 0.0;
let mut samples = 0;
for (i, (x, target)) in pairs.iter().enumerate() {
if i % 3 != 0 {
continue;
}
let corrected = displayed(matrix.apply(*x), &tone);
let original = displayed(*x, &original_tone);
for c in 0..3 {
before += (original[c] - target[c]).powi(2);
after += (corrected[c] - target[c]).powi(2);
samples += 1;
}
}
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
}
if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
return reject("held-out error gate");
}
Some(CameraLook { matrix, tone: curve })
}
fn median(values: &mut [f64]) -> Option<f64> {
values.sort_by(f64::total_cmp);
values.get(values.len() / 2).copied()
}
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
return None;
}
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut knots = [[0.0; 2]; 32];
for (i, knot) in knots.iter_mut().enumerate() {
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
}
if knots[31][0] < knots[0][0] * 1.5 {
return None;
}
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
let mut blocks: Vec<(f32, usize)> = Vec::new();
for knot in knots {
blocks.push((knot[1], 1));
while blocks.len() >= 2 {
let (a, an) = *blocks.get(blocks.len() - 2)?;
let (b, bn) = *blocks.last()?;
if a <= b {
break;
}
blocks.truncate(blocks.len() - 2);
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
}
}
let mut i = 0;
for (y, n) in blocks {
for knot in knots.get_mut(i..i + n)? {
knot[1] = y;
}
i += n;
}
CameraTone::new(knots)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 31) as f32 * 0.017;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
}
let original = sensor.clone();
let fit = fit_pairs(&sensor, &reference).unwrap();
assert_eq!(sensor.data, original.data);
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
let error: f64 = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.025, "{error}");
// The colour transform is homogeneous; tone mapping happens only after exposure.
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
assert!(luma(p) > 1.0);
assert!(tone.apply(0.2) < tone.apply(0.4));
}
#[test]
fn accepts_a_much_better_fit_despite_local_camera_processing() {
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
let mut seed = 0x2545_f491_u32;
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 31) as f32 * 0.017;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
});
}
assert!(fit_pairs(&sensor, &reference).is_some());
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
let mut reference = sensor.clone();
for (i, p) in sensor.data.iter_mut().enumerate() {
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
}
reference.data.fill([0.2; 3]);
assert!(fit_pairs(&sensor, &reference).is_none());
reference.data.fill([f32::NAN; 3]);
assert!(fit_pairs(&sensor, &reference).is_none());
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
}
assert!(fit_pairs(&sensor, &reference).is_none());
sensor.data.fill([0.1, 0.15, 0.12]);
assert!(fit_pairs(&sensor, &reference).is_none());
reference.data.truncate(8);
assert!(fit_pairs(&sensor, &reference).is_none());
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ const PROBE_EDGE: usize = 384;
pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
neutral(&mut d);
let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE);
+8 -2
View File
@@ -26,7 +26,12 @@ fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
/// written to a temp file, synced, read back identical and only then given its name — never
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match &s.media.file_bytes {
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
}
/// [`write_dng_for`] without the session (imports convert on a worker thread).
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match file_bytes {
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
@@ -144,7 +149,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
crate::export::write_file(path, bytes)?;
// durable: the TIFF becomes a library photo the external editor then changes
crate::export::write_file_durable(path, bytes)?;
written.push(path.to_string());
Ok(())
};
+38 -12
View File
@@ -98,7 +98,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings| {
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
for (k, v) in &vals {
controls::set(d, k, *v);
if k == "wb.temp" || k == "wb.tint" {
@@ -113,15 +117,18 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
let mut d = (*d).clone();
apply(&mut d);
let info = s.source_info(id);
apply(&mut d, &info);
s.develop_op(id, d, &label)
})
.collect();
s.commit(&label, Op::Batch { ops })?;
return ok();
}
let id = active(s, "develop.set")?;
let info = s.source_info(id);
edit(s, "develop.set", &label, |d| {
apply(d);
apply(d, &info);
Ok(())
})
}
@@ -130,7 +137,15 @@ pub fn specs() -> Vec<CommandSpec> {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
let info = s.source_info(active(s, "develop.adjust")?);
edit(s, "develop.adjust", spec.label, |d| {
if c == "wb.temp" || c == "wb.tint" {
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
d.wb.mode = WbMode::Custom;
}
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
Ok(())
@@ -149,12 +164,18 @@ pub fn specs() -> Vec<CommandSpec> {
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
.filter_map(|(id, _d)| {
// back to the photo's import defaults when a default preset gave it its look
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
let fresh = match look {
Some(l) => (*l).clone(),
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
None => {
let info = s.source_info(id);
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
}
}
};
s.develop_op(id, fresh, "Reset")
})
@@ -203,7 +224,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let id = active(s, "develop.autoBwMix")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
@@ -218,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
let id = active(s, "develop.auto")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
edit(s, "develop.auto", "Auto", |d| {
@@ -245,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mode: WbMode =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
let id = active(s, "develop.wb")?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let (mut t, mut tint) = match mode {
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
WbMode::Auto => {
@@ -256,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
};
@@ -277,7 +298,7 @@ pub fn specs() -> Vec<CommandSpec> {
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
let id = active(s, "develop.wbPick")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
// Sample a small neighbourhood in the oriented source.
let o = src.oriented(d.orientation);
@@ -405,7 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let id = active(s, "crop.autoStraighten")?;
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
@@ -501,7 +522,7 @@ pub fn specs() -> Vec<CommandSpec> {
Upright::Off | Upright::Guided => None,
m => {
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
}
};
@@ -657,6 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
let info = s.source_info(id);
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
if ctl == "wb.temp" || ctl == "wb.tint" {
+117 -76
View File
@@ -7,6 +7,105 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
Ok(rd
.flatten()
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.filter_map(|e| {
let m = std::fs::metadata(e.path()).ok()?;
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
})
.collect())
}
/// `library.import`'s params, checked (no file-system calls).
pub struct ImportRequest {
pub paths: Vec<String>,
pub opts: crate::import::ImportOptions,
pub album: Option<u64>,
pub album_name: Option<String>,
}
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
/// worker thread, with the same options).
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
let paths = strs(p, "paths");
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
"move" => crate::import::ImportMode::Move,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
};
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
None => None,
};
let album = p.get("album").and_then(Value::as_u64);
if let Some(a) = album
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
{
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"))
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
convert_dng: bool_or(p, "dng", false),
local: bool_or(p, "local", false),
};
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
Ok(ImportRequest { paths, opts, album, album_name })
}
/// After a batch of an import was committed: the album (a new one named `album_name` when there
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
/// `album`).
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
let mut report = serde_json::to_value(report).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if album.is_none()
&& let Some(name) = album_name
&& !imported.is_empty()
{
let r = s.execute("album.create", &json!({"name": name}))?;
album = r["id"].as_u64();
}
if let Some(a) = album
&& !imported.is_empty()
{
// (album.addPhotos wants a selection: the new photos are about to be it)
s.selection = Selection::single(PhotoId(imported[0]));
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
report["album"] = json!(a);
}
Ok(report)
}
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
}
@@ -657,9 +756,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Now",
[],
None,
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
always,
|s, _| {
|s, p| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
// only files that stopped growing: a file still being written is left for later
let known: std::collections::HashSet<String> = s
@@ -670,18 +769,20 @@ pub fn specs() -> Vec<CommandSpec> {
_ => None,
})
.collect();
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
};
let mut fresh = Vec::new();
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
let path = e.path();
let ps = path.to_string_lossy().to_string();
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
for (ps, size) in listing {
if known.contains(&ps) {
continue;
}
// each file is tried once (a non-photo isn't retried every scan)
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
continue;
}
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
let seen = s.auto_import_seen.insert(ps.clone(), size);
if size > 0 && seen == Some(size) {
fresh.push(ps);
@@ -701,6 +802,10 @@ pub fn specs() -> Vec<CommandSpec> {
for f in &fresh {
s.auto_import_seen.insert(f.clone(), u64::MAX);
}
if p.get("start").and_then(Value::as_bool) == Some(false) {
// the caller imports them (the app: on a worker thread)
return Ok(json!({"imported": [], "folder": folder, "import": params}));
}
let sel = s.selection.clone();
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
@@ -751,79 +856,15 @@ pub fn specs() -> Vec<CommandSpec> {
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
always,
|s, p| {
let paths = strs(p, "paths");
if paths.is_empty() {
let req = import_params(s, p)?;
if req.paths.is_empty() {
return Err(bad("library.import", "no paths"));
}
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
"move" => crate::import::ImportMode::Move,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
};
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
Some(id) => {
Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?)
}
None => None,
};
let mut album = p.get("album").and_then(Value::as_u64);
if let Some(a) = album
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
{
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad(
"library.import",
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
)
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
convert_dng: super::bool_or(p, "dng", false),
local: super::bool_or(p, "local", false),
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if album.is_none()
&& let Some(name) = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty())
&& !imported.is_empty()
{
let r = s.execute("album.create", &json!({"name": name}))?;
album = r["id"].as_u64();
}
if let Some(a) = album
&& !imported.is_empty()
{
// (album.addPhotos wants a selection: the new photos are about to be it)
s.selection = Selection::single(PhotoId(imported[0]));
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
report["album"] = json!(a);
}
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
// one undo step for the whole import
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
let n = s.undo.len().saturating_sub(undo0);
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
if let Some(f) = imported.first() {
+2 -2
View File
@@ -185,7 +185,7 @@ pub fn specs() -> Vec<CommandSpec> {
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
@@ -646,7 +646,7 @@ fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spo
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let info = crate::media::source_info(s.catalog.photo(id)?);
let info = s.source_info(id);
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
+184 -42
View File
@@ -45,9 +45,10 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
/// the photos its query already narrowed to).
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
/// the photos its query already narrowed to). In the app `avail` answers from its cache (files
/// not checked yet count as present until the background check finds them gone).
pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
}
fn relink_op(id: PhotoId, path: &str) -> Op {
@@ -94,8 +95,112 @@ fn file_hash(path: &str) -> Option<String> {
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
}
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
/// undoable step.
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
/// content hash the library knows (0 / `None` = unknown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FindCandidate {
pub id: PhotoId,
pub path: String,
pub size: u64,
pub hash: Option<String>,
}
/// Every in-scope photo, for [`plan_find_missing`]: the missing ones are looked for, and no
/// photo's file is used for another. No file-system calls, so the app takes this snapshot on the
/// UI thread and plans on a worker without holding the session.
pub fn find_candidates(cat: &Catalog) -> Vec<FindCandidate> {
cat.photos()
.filter_map(|p| {
checked_path(p).map(|f| FindCandidate { id: p.id, path: f.to_string(), size: p.file_size, hash: stored_hash(p).map(str::to_string) })
})
.collect()
}
/// How a missing photo's file was recognised.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MatchBy {
/// Same name and size (and content, when the library knows its hash).
Name,
/// Renamed: same size and content hash.
Content,
}
impl MatchBy {
pub fn as_str(self) -> &'static str {
match self {
MatchBy::Name => "name",
MatchBy::Content => "content",
}
}
}
/// A missing photo and the file found for it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FoundFile {
pub id: PhotoId,
pub from: String,
pub to: String,
pub by: MatchBy,
}
/// A missing photo with several look-alike files (same name and size, no hash to tell them
/// apart): skipped, never guessed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AmbiguousFile {
pub id: PhotoId,
pub from: String,
pub candidates: Vec<String>,
}
/// What Find Missing Photos found ([`plan_find_missing`]), before relinking.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FindPlan {
pub found: Vec<FoundFile>,
/// In-scope photos that stay missing (the ambiguous ones included).
pub missing: usize,
pub ambiguous: Vec<AmbiguousFile>,
}
impl FindPlan {
/// `{found: [{id, from, to, by}], missing, ambiguous: [{id, from, candidates}]}` — the
/// command's result, and what `library.findMissing {found, …}` takes back.
pub fn to_json(&self) -> Value {
let found: Vec<Value> = self.found.iter().map(|f| json!({"id": f.id.0, "from": f.from, "to": f.to, "by": f.by.as_str()})).collect();
let ambiguous: Vec<Value> = self.ambiguous.iter().map(|a| json!({"id": a.id.0, "from": a.from, "candidates": a.candidates})).collect();
json!({"found": found, "missing": self.missing, "ambiguous": ambiguous})
}
/// The inverse of [`FindPlan::to_json`] (entries it can't read are dropped).
pub fn from_json(v: &Value) -> FindPlan {
let list = |k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
let found = list("found")
.iter()
.filter_map(|f| {
let by = if f.get("by").and_then(Value::as_str) == Some("content") { MatchBy::Content } else { MatchBy::Name };
Some(FoundFile {
id: PhotoId(f.get("id")?.as_u64()?),
from: str_param(f, "from")?.to_string(),
to: str_param(f, "to")?.to_string(),
by,
})
})
.collect();
let ambiguous = list("ambiguous")
.iter()
.filter_map(|a| {
let candidates = a.get("candidates")?.as_array()?.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
Some(AmbiguousFile { id: PhotoId(a.get("id")?.as_u64()?), from: str_param(a, "from")?.to_string(), candidates })
})
.collect();
let missing = v.get("missing").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
FindPlan { found, missing, ambiguous }
}
}
/// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in
/// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app
/// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes
/// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`.
///
/// Matching, per photo:
/// 1. a file with the same name (any letter case), the same size and the same content hash;
@@ -107,20 +212,21 @@ fn file_hash(path: &str) -> Option<String> {
///
/// Files that are already some photo's original, or that another photo was just matched with,
/// are not used.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::result::Result<FindPlan, String> {
if !Path::new(folder).is_dir() {
return Err(bad(C, format!("{folder}: not a folder")));
return Err(format!("{folder}: not a folder"));
}
let lost = missing(s);
if cfg!(target_arch = "wasm32") {
return Ok(FindPlan::default());
}
let lost: Vec<&FindCandidate> = candidates.iter().filter(|c| !Path::new(&c.path).exists()).collect();
if lost.is_empty() {
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
return Ok(FindPlan::default());
}
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
let in_use: HashSet<&str> = candidates.iter().map(|c| c.path.as_str()).collect();
// file name (lower case) → candidate paths; size → paths (filled lazily)
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect();
for f in &files {
if let Some(n) = Path::new(f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
@@ -131,24 +237,21 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
let mut used: HashSet<String> = HashSet::new();
let mut ops = Vec::new();
let mut found = Vec::new();
let mut ambiguous = Vec::new();
for (id, old) in &lost {
let Some(ph) = s.catalog.photo(*id) else { continue };
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = ph.file_size;
let hash = stored_hash(ph).map(str::to_string);
let mut plan = FindPlan::default();
for c in &lost {
let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = c.size;
let hash = c.hash.as_deref();
let named: Vec<&String> = by_name
.get(&name)
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
.map(|v| v.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
.unwrap_or_default();
// 1. same name and size, confirmed by the content hash
let mut hit: Option<(String, &str)> =
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
let mut hit: Option<(String, MatchBy)> =
hash.and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), MatchBy::Name));
// 2. renamed: same size and content
if hit.is_none()
&& let Some(h) = &hash
&& let Some(h) = hash
&& size > 0
{
let sizes = by_size.get_or_insert_with(|| {
@@ -162,36 +265,75 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
});
hit = sizes
.get(&size)
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
.map(|f| (f.clone(), "content"));
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
.map(|f| (f.clone(), MatchBy::Content));
}
// 3. same name and size only (no hash, or the file was edited in place): one candidate
// is taken, several are ambiguous and skipped rather than guessed
if hit.is_none() {
match named.len() {
1 => hit = Some((named[0].clone(), "name")),
0 => {}
match named.as_slice() {
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
[] => {}
_ => {
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
plan.ambiguous.push(AmbiguousFile { id: c.id, from: c.path.clone(), candidates: named.iter().map(|f| (*f).clone()).collect() });
continue;
}
}
}
let Some((path, by)) = hit else { continue };
ops.push(relink_op(*id, &path));
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
used.insert(path);
used.insert(path.clone());
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
}
let still = lost.len() - found.len();
if !ops.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops })?;
plan.missing = lost.len() - plan.found.len();
Ok(plan)
}
/// Relink what [`plan_find_missing`] found, in one undoable step, re-checked against the
/// catalog as it is now (the search may have run on a worker while the library changed): a
/// photo that no longer points at the path it was missing from (relinked, removed) is left
/// alone, and a file that is now some photo's original — or that the plan names twice — is not
/// used (that photo stays missing). `missing` is the plan's count, corrected for what changed
/// among the photos the plan names.
fn commit_find(s: &mut Session, plan: FindPlan) -> Result<Value> {
let still_at = |cat: &Catalog, id: PhotoId, from: &str| cat.photo(id).and_then(|p| checked_path(p)) == Some(from);
let mut in_use: HashSet<String> = candidates(&s.catalog).into_iter().collect();
let mut found = Vec::new();
let mut missing = plan.missing;
for f in plan.found {
if !still_at(&s.catalog, f.id, &f.from) {
continue;
}
if in_use.contains(&f.to) {
missing += 1;
continue;
}
in_use.insert(f.to.clone());
found.push(f);
}
let planned = plan.ambiguous.len();
let ambiguous: Vec<AmbiguousFile> = plan.ambiguous.into_iter().filter(|a| still_at(&s.catalog, a.id, &a.from)).collect();
// ambiguous photos relinked meanwhile aren't missing any more
missing = missing.saturating_sub(planned - ambiguous.len());
if !found.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops: found.iter().map(|f| relink_op(f.id, &f.to)).collect() })?;
for f in &found {
if let Some(id) = f["id"].as_u64() {
s.media.forget(PhotoId(id));
}
s.media.forget(f.id);
}
}
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
Ok(FindPlan { found, missing, ambiguous }.to_json())
}
/// Search `folder` (recursively) for each missing photo's file ([`plan_find_missing`]) and
/// relink every match in one undoable step. With `found` instead (what a search on a worker
/// thread returned, as `{found, missing, ambiguous}`), only the relinking is done.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
if p.get("found").is_some_and(Value::is_array) {
return commit_find(s, FindPlan::from_json(p));
}
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
let plan = plan_find_missing(&find_candidates(&s.catalog), folder).map_err(|e| bad(C, e))?;
commit_find(s, plan)
}
pub fn specs() -> Vec<CommandSpec> {
@@ -213,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Missing Photos",
[],
None,
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content; or {found, missing, ambiguous} — relink what a search already found (the app searches on a worker thread; photos relinked meanwhile and files now in use are skipped) → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
always,
find_missing
),
+1 -1
View File
@@ -15,7 +15,7 @@ mod edit;
mod export;
pub mod filters;
pub mod keywords;
mod library;
pub mod library;
pub mod lut_profiles;
pub mod manage;
mod masks;
+131 -38
View File
@@ -23,6 +23,12 @@ pub struct PreviewBuild {
pub failed: AtomicUsize,
pub cancel: AtomicBool,
pub finished: AtomicBool,
/// Damaged smart previews rebuilt (Build Smart Previews only).
pub repaired: AtomicUsize,
/// What is built ("" = previews; "smart previews" — Build Smart Previews runs the same way).
pub what: &'static str,
/// Why the whole build failed (e.g. the smart previews folder isn't there).
pub error: std::sync::Mutex<Option<String>>,
}
impl PreviewBuild {
@@ -33,8 +39,15 @@ impl PreviewBuild {
"failed": self.failed.load(Ordering::Relaxed),
"running": !self.finished.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
"what": if self.what.is_empty() { "previews" } else { self.what },
"repaired": self.repaired.load(Ordering::Relaxed),
"error": self.error(),
})
}
pub fn error(&self) -> Option<String> {
self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
}
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
@@ -94,56 +107,136 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state.json())
}
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
/// how to load the original at preview size).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
/// What Build / Discard Smart Previews did.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct SmartCounts {
/// Proxies there now (complete ones kept, missing or damaged ones written).
built: usize,
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
repaired: usize,
removed: usize,
/// `[[id, why]]`
failed: Vec<Value>,
}
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
/// recreated. With `state`, progress and cancel.
#[cfg(not(target_arch = "wasm32"))]
fn smart_run(
dir: &std::path::Path,
custom: bool,
discard: bool,
jobs: Vec<SmartJob>,
state: Option<&PreviewBuild>,
) -> std::result::Result<SmartCounts, String> {
let mut n = SmartCounts::default();
if !discard {
// a chosen folder on another drive must be there: never recreate it on this one
if custom {
crate::smart::check_writable(dir)?;
} else {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
for (id, path, source) in jobs {
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
break;
}
if discard {
if std::fs::remove_file(&path).is_ok() {
n.removed += 1;
}
} else {
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
n.built += 1;
} else {
// atomic: a failed write leaves no partial proxy that would pass for a built one;
// and synced (issue #134): built so the photo can be edited while its original
// is offline, when it is the only copy — the sync is small next to the decode
let r = source
.ok_or_else(|| "nothing to build from".to_string())
.and_then(|s| s.load_source())
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
match r {
Ok(()) => {
n.built += 1;
if damaged {
n.repaired += 1;
if let Some(st) = state {
st.repaired.fetch_add(1, Ordering::Relaxed);
}
}
}
Err(e) => {
n.failed.push(json!([id.0, e]));
if let Some(st) = state {
st.failed.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
if let Some(st) = state {
st.done.fetch_add(1, Ordering::Relaxed);
}
}
Ok(n)
}
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
/// photos editable while their originals are offline.
/// photos editable while their originals are offline. With `background` (the app) the files are
/// read and written on a worker thread; progress like Build Previews (`library.previewProgress`).
#[cfg(not(target_arch = "wasm32"))]
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.smartPreviews";
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
if !discard {
// a chosen folder on another drive must be there: never recreate it on this one
if s.smart_previews_dir.is_some() {
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
} else {
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let mut jobs: Vec<SmartJob> = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
continue;
}
let path = dir.join(crate::smart::file_name(&ph));
if discard {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
continue;
}
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
built += 1;
continue;
}
// atomic: a failed write leaves no partial proxy that would pass for a built one
let r = s
.source_now(id, crate::media::SourceLevel::Preview)
.and_then(|src| crate::smart::encode(&src))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
match r {
Ok(()) => {
built += 1;
repaired += usize::from(damaged);
}
Err(e) => failed.push(json!([id.0, e])),
}
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source));
}
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
let custom = s.smart_previews_dir.is_some();
if background {
let what = if discard { "discarding smart previews" } else { "smart previews" };
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
s.preview_build = Some(state.clone());
let st = state.clone();
std::thread::Builder::new()
.name("lc-smart-previews".into())
.spawn(move || {
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
log::warn!("{C}: {e}");
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
}
st.finished.store(true, Ordering::Relaxed);
})
.map_err(|e| bad(C, format!("could not start: {e}")))?;
return Ok(state.json());
}
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
}
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
@@ -225,7 +318,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Build Smart Previews",
[],
None,
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
"{ids?, discard?: bool, background?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}; with background (the app's menu) the files are read and written on a worker thread → {total, done, failed, repaired, running, what} like library.buildPreviews (progress: library.previewProgress, stop: library.cancelPreviews)",
always,
smart
),
@@ -256,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
build
),
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | null", always, |s, _| {
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
+16 -4
View File
@@ -1131,15 +1131,27 @@ pub fn export_batch(
}
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then
/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The
/// native writer behind exports, renders and screenshots (app, CLI, MCP).
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file
/// renamed into place), so a failure part-way leaves any previous file intact and no truncated
/// one. Not synced to disk (issue #134): an export can always be made again from the original.
/// The native writer behind exports, renders and screenshots (app, CLI, MCP).
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
write_with(path, bytes, false)
}
/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy)
/// and so can't simply be exported again once the catalog points at it.
pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> {
write_with(path, bytes, true)
}
fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> {
use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync};
let p = std::path::Path::new(path);
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}"))
if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}"))
}
/// The path of the sidecar with extension `ext` of the exported file `main`.
+103 -10
View File
@@ -131,9 +131,13 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
let as_shot_wb = Some((t.round(), tint.round()));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration
// (the same decoder fact as `CameraTransform::matrix_is_fallback`).
let relative = !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
fallback_matrix: Some(relative),
embedded_lens,
width: w,
height: h,
@@ -176,6 +180,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
embedded_lens: None,
xmp: None,
preview_only: None,
fallback_matrix: None,
})
}
@@ -221,6 +226,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
}
Err(e) => return Err(e.to_string()),
};
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
drop(bytes);
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
let lens = embedded_lens(&raw.info());
@@ -242,21 +250,25 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
// the samples aren't needed any more (the colour model below reads only the tags)
raw.data = lightcraft_raw::RawData::U16(Vec::new());
let mut stages = vec![("develop", t0.elapsed())];
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
stages.push(("transform", t0.elapsed()));
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
stages.push(("highlights", t0.elapsed()));
let m = t.matrix.to_f32();
let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
let gain = 2f32.powf(t.baseline_exposure as f32);
let wb = t.wb;
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
[
((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0),
((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0),
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
]
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
match &tables {
Some(tables) => tables.apply(rgb, gain).map(|v| v.max(0.0)),
None => rgb.map(|v| (v * gain).max(0.0)),
}
});
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
@@ -276,7 +288,12 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let (temp, tint) = xy_to_temp_tint(xy);
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
// The catalog's record of this fact decides how saved WB settings are read (see
// `DecodedSource::info_or`); this is the decoder's view of a file nobody catalogued.
let relative = t.matrix_is_fallback;
let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
}
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
drop(bytes);
@@ -285,6 +302,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
}
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white
/// the camera tone's shoulder continues it.
pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option<lightcraft_pipeline::tone::CameraTone> {
let knots: [[f32; 2]; 32] = std::array::from_fn(|i| {
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0);
[x, curve.eval(x).min(0.9995)]
});
lightcraft_pipeline::tone::CameraTone::new(knots)
}
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
if jpeg_orientation > 1 {
@@ -390,6 +418,41 @@ mod tests {
b
}
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
#[test]
fn dng_profile_look_is_applied() {
use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve};
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&plain).unwrap();
let sat = |img: &Rgb32f| {
img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::<f32>()
/ img.data.len() as f32
};
let (before, info) = load_bytes(&plain, 64).unwrap();
assert!(sat(&before) > 0.05, "the synthetic scene is coloured");
assert!(info.camera_tone.is_none());
// a map that removes all saturation, and a tone curve
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
raw.color.profile =
ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) };
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let (after, info) = load_bytes(&with, 64).unwrap();
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
// a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB)
let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::<f32>() / img.data.len() as f32;
assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before));
let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone");
assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18));
// a look table alone changes the render too (applied after exposure)
raw.color.profile = ProfileLook {
look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }),
..Default::default()
};
let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
}
#[test]
fn unsupported_raw_falls_back_to_embedded_preview() {
let b = cr3_with_preview(48, 32);
@@ -458,6 +521,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Whether a raw's white balance is relative comes from the decoder (no colour matrix), not
/// from its extension, and import records it on the photo.
#[test]
fn import_records_whether_the_raw_has_a_colour_matrix() {
let with = crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default());
let mut raw = lightcraft_raw::decode(&with).unwrap();
raw.color.color_matrix = [None, None];
raw.color.as_shot_neutral = None;
raw.wb_multipliers = Some([2.0, 1.0, 1.5]);
let without = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let p = probe_bytes("a.dng", &with).unwrap();
assert_eq!(p.fallback_matrix, Some(false));
assert_ne!(p.as_shot_wb, Some((6500.0, 0.0)));
let p = probe_bytes("a.dng", &without).unwrap();
assert_eq!((p.fallback_matrix, p.as_shot_wb), (Some(true), Some((6500.0, 0.0))));
let (_, info) = load_bytes(&without, 64).unwrap();
assert!(info.relative_wb);
assert!(!load_bytes(&with, 64).unwrap().1.relative_wb);
let dir = std::env::temp_dir().join(format!("lc-fallback-matrix-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("no-matrix.dng"), &without).unwrap();
let mut s = crate::Session::new().with_fs();
let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.fallback_matrix, Some(true));
assert!(p.relative_wb() && crate::media::source_info(p).relative_wb);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+378 -186
View File
@@ -375,13 +375,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
/// synced and compared byte for byte); a bad one is removed and the photo reported as failed.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
/// byte for byte with the source when there is none); a bad one is removed and the photo
/// reported as failed.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name
.map(str::to_string)
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.to_string_lossy().to_string())
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
}
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
@@ -517,205 +520,393 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
}
/// Import files/folders with options (preset, keywords). See the module docs.
///
/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars,
/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can
/// run the first on a worker thread; here both run in turn.
pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result<ImportReport> {
let mode = opts.mode;
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
// added file in its own storage already, so "copy" there means "add".
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
if mode == ImportMode::Move && opts.local {
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut seq = opts.rename_start.max(1);
let files = expand(paths, lib_dir.as_deref());
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
let mut job = ImportJob::new(s, opts.clone())?;
let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false));
// probes a scan left for files not imported now stay for a later import
s.import_probes.extend(std::mem::take(&mut job.cache));
commit_prepared(s, &job.opts, &job.now, prepared)
}
// existing paths and content hashes
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.as_str(), p.id);
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let mut todo = Vec::new();
// a file that was only browsed (Local) joins the library when it is imported for real
let mut promote = Vec::new();
for f in files {
match by_path.get(f.as_str()) {
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
promote.push(Op::SetLocal { id: *id, local: false });
report.imported.push(id.0);
/// Everything the file-system half of an import needs, detached from the session so it can run
/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`];
/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting,
/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session.
pub struct ImportJob {
pub opts: ImportOptions,
/// The mode in effect (Copy / Move become Add in a library without its own folder).
mode: ImportMode,
lib_dir: Option<PathBuf>,
copy_root: Option<PathBuf>,
/// path → (photo, a Local browse record?)
by_path: HashMap<String, (PhotoId, bool)>,
/// content hash → the photo that has it (`None`: a file earlier in this import)
by_hash: HashMap<String, Option<PhotoId>>,
probe: Option<crate::media::FileProbe>,
file_bytes: Option<crate::merge::ByteReader>,
/// Probes from a preceding scan (not read again).
cache: HashMap<String, ProbeInfo>,
naming: crate::sidecar::SidecarNaming,
now: String,
seq: usize,
/// Files found so far (folders expanded) and handled so far, for progress.
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
#[derive(Debug, Default)]
pub struct Prepared {
pub scanned: usize,
items: Vec<PreparedItem>,
}
#[derive(Debug)]
enum PreparedItem {
/// A Local (browsed) record imported for real.
Promote(PhotoId),
Duplicate {
path: String,
existing: Option<PhotoId>,
reason: &'static str,
hash: Option<String>,
},
Failed(String, String),
Kept(Kept),
Ready(Box<ReadyFile>),
}
#[derive(Debug)]
struct ReadyFile {
path: String,
stored: String,
info: ProbeInfo,
sidecar: Option<crate::sidecar::SidecarData>,
placed: Option<crate::import_move::Placed>,
}
impl Prepared {
/// Undo what the batch did on disk that only a commit would make permanent: files placed by a
/// Move (their sources were never touched). Copies stay, like copies of a failed commit.
pub fn rollback(&self) {
for it in &self.items {
if let PreparedItem::Ready(r) = it
&& let Some(pl) = &r.placed
{
crate::import_move::rollback(pl);
}
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
None => todo.push(f),
}
}
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let now = (s.clock)();
let mut ops = promote;
// Move: placed at the destination, sources untouched until the records are committed
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
for (path, info) in todo.into_iter().zip(probed) {
let info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
report.failed.push((path, e));
/// Entries in the batch (after expanding folders).
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl ImportJob {
/// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes
/// the probes a preceding scan left ([`Session::import_probes`]).
pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result<Self> {
let mode = opts.mode;
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of
// every added file in its own storage already, so "copy" there means "add".
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
if mode == ImportMode::Move && opts.local {
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut by_path = HashMap::new();
let mut by_hash = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.clone(), (p.id, p.local));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), Some(p.id));
}
}
Ok(ImportJob {
seq: opts.rename_start.max(1),
opts,
mode,
lib_dir,
copy_root,
by_path,
by_hash,
probe: s.media.file_probe.clone(),
file_bytes: s.media.file_bytes.clone(),
cache: std::mem::take(&mut s.import_probes),
naming: s.xmp.naming,
now: (s.clock)(),
total: Default::default(),
done: Default::default(),
})
}
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
/// catalog changes. Stops before the next file once `cancel` is set.
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
let files = self.expand(paths);
self.prepare_files(files, cancel)
}
/// Expand `paths` (folders recursively, the library's own folder skipped) into the files an
/// import would handle; counted in [`ImportJob::total`].
pub fn expand(&self, paths: &[String]) -> Vec<String> {
let files = expand(paths, self.lib_dir.as_deref());
self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed);
files
}
/// When the import started ([`Session::clock`]), for [`commit_prepared`].
pub fn now(&self) -> &str {
&self.now
}
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
use std::sync::atomic::Ordering::Relaxed;
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
let mut todo = Vec::new();
for f in files {
match self.by_path.get(f.as_str()).copied() {
Some((id, true)) if !opts.local => {
// a file that was only browsed (Local) joins the library when it is imported for real
self.by_path.insert(f, (id, false));
out.items.push(PreparedItem::Promote(id));
self.done.fetch_add(1, Relaxed);
}
Some((id, _)) => {
out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });
self.done.fetch_add(1, Relaxed);
}
None => todo.push(f),
}
}
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let progress = ScanProgress::default();
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
for (path, info) in todo.into_iter().zip(probed) {
if cancel.load(Relaxed) {
break;
}
self.done.fetch_add(1, Relaxed);
let mut info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
out.items.push(PreparedItem::Failed(path, e));
continue;
}
};
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(existing) = self.by_hash.get(h)
{
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
continue;
}
};
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(id) = by_hash.get(h)
{
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, self.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
Ok(sc) => Some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
}
});
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
});
let mut info = info;
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
let stored = match (mode, &copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
let mut placed = None;
let stored = match (mode, &self.copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
&& !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
let n = crate::rename::expand(t, &q, self.seq);
self.seq += 1;
n
});
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed = Some(pl);
dst
}
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
}
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
}
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
Ok(p) => p,
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
}
}
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
path.clone()
}
_ => path.clone(),
};
if let Some(h) = &info.content_hash {
self.by_hash.insert(h.clone(), None);
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
if opts.convert_dng
&& mode == ImportMode::Copy
&& stored != path
&& info.kind == MediaKind::Raw
&& !info.format.eq_ignore_ascii_case("DNG")
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
}
out
}
}
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
/// taken back.
pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result<ImportReport> {
let now = now.to_string();
let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() };
let mut ops = Vec::new();
// Move: placed at the destination, sources untouched until the records are committed
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
for it in prepared.items {
match it {
PreparedItem::Promote(id) => {
if s.catalog.photo(id).is_some_and(|p| p.local) {
ops.push(Op::SetLocal { id, local: false });
}
report.imported.push(id.0);
}
PreparedItem::Duplicate { path, existing, reason, hash } => {
let existing = existing.or_else(|| {
let h = hash?;
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
});
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed.push(pl);
dst
}
Err(e) => {
report.failed.push((path, e));
continue;
}
}
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
}
match copy_into(root, &path, &folders, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
continue;
}
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
}
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
PreparedItem::Kept(k) => report.kept.push(k),
PreparedItem::Ready(r) => {
let ReadyFile { path, stored, info, sidecar, placed: pl } = *r;
placed.extend(pl);
let id = s.catalog.alloc_photo_id();
if let Some(h) = &info.content_hash {
new_hash.insert(h.clone(), id);
}
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.fallback_matrix = info.fallback_matrix;
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
p.meta.keywords.push(k);
}
}
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
path.clone()
}
_ => path.clone(),
};
let id = s.catalog.alloc_photo_id();
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
if let Some(preset) = &opts.preset {
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
let label = format!("Preset: {}", preset.name);
p.develop = d.clone();
p.edited = Some(now.clone());
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
report.imported.push(id.0);
p.local = opts.local;
if opts.local {
// the state as browsed: a later change keeps the record from being forgotten
p.set_local_baseline();
}
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
}
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
p.meta.keywords.push(k);
}
}
if let Some(preset) = &opts.preset {
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
let label = format!("Preset: {}", preset.name);
p.develop = d.clone();
p.edited = Some(now.clone());
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
}
report.imported.push(id.0);
p.local = opts.local;
if opts.local {
// the state as browsed: a later change keeps the record from being forgotten
p.set_local_baseline();
}
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let log0 = s.pending_log.len();
if !ops.is_empty()
@@ -796,6 +987,7 @@ impl Session {
p.as_shot_wb = info.as_shot_wb;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.fallback_matrix = info.fallback_matrix;
}
p.develop = std::sync::Arc::new(p.import_defaults());
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
+37 -11
View File
@@ -171,9 +171,15 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
/// is never wiped on the strength of it. Returns the new path.
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, String> {
/// disk and verified; a bad copy is removed and reported, so a card is never wiped on the strength
/// of it. Returns the new path.
///
/// `expect` is the content hash the import's probe computed from its full read of the source
/// ([`lightcraft_preview::hash_bytes`]): the copy is read back and checked against it, which spares
/// a third read of the card (issue #134). It also catches a source that changed since the probe
/// (whose recorded metadata and hash would be stale). Without one the copy is compared byte for
/// byte with the source.
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str, expect: Option<lightcraft_preview::Hash128>) -> Result<PathBuf, String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
@@ -181,7 +187,7 @@ pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, St
};
for i in 0..100_000u32 {
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
match copy_verified(src, &d) {
match copy_verified(src, &d, expect) {
Ok(()) => return Ok(d),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())),
@@ -262,12 +268,14 @@ fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> {
Err(_) => {}
}
}
copy_verified(src, dst)
// Move keeps the byte-for-byte comparison: the source is deleted on the strength of it
copy_verified(src, dst, None)
}
/// Copy into a new file, sync it to disk and compare it with the original; on any failure the
/// partial copy is removed.
fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
/// Copy into a new file, sync it to disk and verify it: against `expect` (the probe's hash of the
/// source) when given, else byte for byte against the source. On any failure the partial copy is
/// removed.
pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_preview::Hash128>) -> io::Result<()> {
let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?;
let r = (|| {
let mut inp = File::open(src)?;
@@ -283,10 +291,14 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
let _ = out.set_modified(m);
}
if !same_bytes(src, dst)? {
return Err(io::Error::other("the copy differs from the original"));
match expect {
Some(h) if hash_file(dst)? != h => {
Err(io::Error::other("the copy differs from the original as it was read for the import (a bad copy, or the file changed meanwhile)"))
}
Some(_) => Ok(()),
None if !same_bytes(src, dst)? => Err(io::Error::other("the copy differs from the original")),
None => Ok(()),
}
Ok(())
})();
drop(out);
if r.is_err() {
@@ -295,6 +307,20 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
r
}
/// [`lightcraft_preview::hash_bytes`] of a file's content, read in chunks.
fn hash_file(path: &Path) -> io::Result<lightcraft_preview::Hash128> {
let mut f = File::open(path)?;
let mut h = lightcraft_preview::Hasher128::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = fill(&mut f, &mut buf)?;
if n == 0 {
return Ok(h.finish());
}
h.update(buf.get(..n).unwrap_or(&[]));
}
}
/// Do two files hold the same bytes?
pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result<bool> {
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
+25 -5
View File
@@ -10,6 +10,8 @@
//! run them off the UI thread.
#![forbid(unsafe_code)]
pub mod availability;
mod camera_preview;
pub mod cmd;
pub mod crs;
pub mod crs_masks;
@@ -272,11 +274,22 @@ impl Session {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
}
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
self.run_command(id, None, f)
}
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
let log_start = self.pending_log.len();
let was_active = self.active();
self.depth += 1;
// last-resort guard: a panic in a command is that command's error, not a crash
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e)));
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
@@ -284,7 +297,10 @@ impl Session {
self.auto_version(left);
}
}
if r.is_ok() && spec.journal && self.depth == 0 {
if r.is_ok()
&& let Some(params) = journal
&& self.depth == 0
{
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
@@ -560,7 +576,11 @@ impl Session {
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
@@ -595,8 +615,8 @@ impl Session {
}
if self.source == LibrarySource::Missing {
// only the photos the query kept (library photos, not Local browse records) are checked
let cat = &self.catalog;
visible.retain(|id| cmd::missing::is_missing(cat, *id));
let (cat, avail) = (&self.catalog, &self.media.availability);
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
}
if self.source != LibrarySource::RecentlyDeleted {
visible = self.catalog.arrange_stacks(&visible);
+200 -43
View File
@@ -25,8 +25,9 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 4;
/// Bump when the pipeline's output changes, to invalidate cached thumbnails. A new camera-look fit
/// ([`crate::camera_preview::FIT_VERSION`]) invalidates them by itself.
pub const RENDER_CACHE_VERSION: u64 = 8 | (crate::camera_preview::FIT_VERSION << 32);
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -71,9 +72,40 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
/// `max_edge` (set by the app). `None`: no usable preview.
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
/// Pixels and the source interpretation learned while decoding; they must be cached together.
#[derive(Clone)]
pub struct DecodedSource {
pub image: Arc<Rgb32f>,
pub info: Option<SourceInfo>,
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
/// catalog's header facts lack (used when `info` is `None`).
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
}
impl DecodedSource {
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
DecodedSource { image, info, camera_tone: None }
}
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
/// with any stored camera tone curve. For a catalogued raw (`header.raw`) the catalog keeps
/// deciding how white balance settings are read — relative or absolute, around which as-shot
/// white — because the photo's saved settings were made under that rule
/// ([`lightcraft_catalog::Photo::relative_wb`]).
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
match self.info {
Some(info) if info.raw && header.raw => {
SourceInfo { relative_wb: header.relative_wb, as_shot_temp: header.as_shot_temp, as_shot_tint: header.as_shot_tint, ..info }
}
Some(info) => info,
None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header },
}
}
}
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
Loaded(Box<DecodedSource>),
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
@@ -82,6 +114,9 @@ pub enum SourceRef {
path: String,
max_edge: usize,
loader: Option<FileLoader>,
/// The photo's smart preview, used when the original can't be read (offline drive). The
/// UI thread never checks whether the original is there; the render worker finds out.
fallback: Option<std::path::PathBuf>,
},
/// A smart preview standing in for a missing original.
Smart {
@@ -91,28 +126,41 @@ pub enum SourceRef {
impl SourceRef {
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
match self {
SourceRef::Loaded(a) => Ok(a.clone()),
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
SourceRef::File { path, max_edge, loader } => match loader {
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
None => Err(format!("no decoder available for {path}")),
},
self.load_source().map(|s| s.image)
}
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
SourceRef::File { path, max_edge, loader, fallback } => {
let r = match loader {
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
None => Err(format!("no decoder available for {path}")),
};
// An offline original renders from its smart preview (no decoder facts: header ones).
return match (r, fallback) {
#[cfg(not(target_arch = "wasm32"))]
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
(r, _) => r,
};
}
#[cfg(not(target_arch = "wasm32"))]
SourceRef::Smart { path } => crate::smart::load(path),
SourceRef::Smart { path } => return crate::smart::load(path),
#[cfg(target_arch = "wasm32")]
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
}
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
};
Ok(DecodedSource::new(image, None))
}
}
pub struct MediaCache {
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
thumbs: Lru<PhotoId, DecodedSource>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
previews: Vec<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
full: Option<(PhotoId, DecodedSource, u64)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -125,6 +173,9 @@ pub struct MediaCache {
pub file_bytes: Option<crate::merge::ByteReader>,
/// The library's smart previews folder (originals offline: render from the proxy).
pub smart_dir: Option<std::path::PathBuf>,
/// Whether originals (and smart previews) are on disk: cached and checked off the UI thread
/// in the app (see [`crate::availability`]).
pub availability: crate::availability::Availability,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -144,6 +195,7 @@ impl Default for MediaCache {
preview_loader: None,
file_bytes: None,
smart_dir: None,
availability: Default::default(),
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
}
@@ -156,7 +208,7 @@ fn rendered_budget(share: usize) -> usize {
}
fn source_bytes(img: &Rgb32f) -> usize {
img.data.len() * 12 + 64
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
}
impl MediaCache {
@@ -207,8 +259,8 @@ impl MediaCache {
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
let freed = match which {
0 => self.thumbs.pop_oldest(),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
2 => self.full.take().map(|e| source_bytes(&e.1)),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
_ => self.rendered.evict_oldest(),
};
match freed {
@@ -219,6 +271,10 @@ impl MediaCache {
}
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
self.get_source(id, level).map(|s| s.image)
}
fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option<DecodedSource> {
match level {
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| {
@@ -232,11 +288,31 @@ impl MediaCache {
}
}
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
/// (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
let cached = || {
self.thumbs
.peek(&id)
.into_iter()
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
};
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
Some(s) => s.info_or(header),
None => header,
}
}
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
self.insert_source(id, level, DecodedSource::new(img, None));
}
fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) {
let tick = lightcraft_preview::next_tick();
match level {
SourceLevel::Thumb => {
let cost = source_bytes(&img);
let cost = source_bytes(&img.image);
self.thumbs.insert(id, img, cost);
}
SourceLevel::Preview => {
@@ -270,29 +346,36 @@ impl MediaCache {
/// Decoded sources held: (thumbnail level, preview level, full size).
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
use crate::memory::Usage;
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
if let Some(a) = self.get_source(p.id, level) {
return SourceRef::Loaded(Box::new(a));
}
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
let max_edge = level.max_edge().min(match (&p.source, level) {
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
_ => usize::MAX,
});
// the original is offline: its smart preview, when there is one
// the original is offline: its smart preview, when there is one. Only cached answers are
// used here (no file-system call on the UI thread in the app); an original not known to
// be offline is tried first and its smart preview is the render worker's fallback.
#[cfg(not(target_arch = "wasm32"))]
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
&& !std::path::Path::new(path).exists()
{
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
let sp = dir.join(crate::smart::file_name(p));
if crate::smart::is_valid(&sp) {
// (a proxy cut short fails to load, like the offline original; the worker's fallback
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
return SourceRef::Smart { path: sp };
}
let mut r = self.origin_ref(&p.source, max_edge);
if let SourceRef::File { fallback, .. } = &mut r {
*fallback = Some(sp);
}
return r;
}
self.origin_ref(&p.source, max_edge)
}
@@ -307,10 +390,10 @@ impl MediaCache {
}
match self.scenes.iter().find(|s| s.id == *scene) {
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
}
}
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
}
}
}
@@ -344,7 +427,7 @@ pub struct RenderResult {
pub key: u64,
pub rendered: Result<Rendered, String>,
/// A source that was loaded by this job (to be inserted into the cache).
pub loaded: Option<Arc<Rgb32f>>,
pub loaded: Option<DecodedSource>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
pub quick: Option<QuickSource>,
}
@@ -410,12 +493,14 @@ impl RenderJob {
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
Ok(src) => {
match self.source.load_source() {
Ok(source) => {
let src = &source.image;
let info = source.info_or(self.info);
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
if let Some((cache, key)) = &self.cache {
cache.put(*key, Arc::new(rendered.image.clone()));
}
@@ -429,7 +514,7 @@ impl RenderJob {
level: self.level,
key: self.key,
rendered: Ok(rendered),
loaded: (!was_loaded).then_some(src),
loaded: (!was_loaded).then_some(source),
quick: None,
}
}
@@ -521,11 +606,16 @@ pub fn content_key(p: &Photo) -> String {
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
return SourceInfo { raw: true, ..Default::default() };
}
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
// balance and the display tone curve, like any other rendered file.
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
if p.develops_raw() {
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
} else {
SourceInfo::default()
}
}
impl crate::Session {
@@ -680,7 +770,7 @@ impl crate::Session {
/// Accept a finished job's loaded source into the cache.
pub fn accept(&mut self, r: &RenderResult) {
if let Some(src) = &r.loaded {
self.media.insert(r.photo, r.level, src.clone());
self.media.insert_source(r.photo, r.level, src.clone());
}
}
@@ -723,12 +813,19 @@ impl crate::Session {
Ok(job)
}
/// Prefer decoder facts to header-only metadata for pixel-statistics commands.
pub fn source_info(&self, id: PhotoId) -> SourceInfo {
let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default();
self.media.source_facts(id, header)
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
let r = self.media.source_ref(&p, level).load()?;
self.media.insert(id, level, r.clone());
Ok(r)
let r = self.media.source_ref(&p, level).load_source()?;
let image = r.image.clone();
self.media.insert_source(id, level, r);
Ok(image)
}
}
@@ -743,7 +840,9 @@ pub struct ProbeInfo {
pub captured: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection.
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
/// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it),
/// Import → Copy verifies each copy against it instead of reading the source again.
pub content_hash: Option<String>,
/// Lens corrections embedded in the file (DNG opcodes).
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
@@ -752,6 +851,8 @@ pub struct ProbeInfo {
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
pub preview_only: Option<String>,
/// Raws: the decoder has no colour matrix for the file ([`lightcraft_catalog::Photo::fallback_matrix`]).
pub fallback_matrix: Option<bool>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
@@ -760,6 +861,62 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
mod tests {
use super::*;
/// The decoder says a NEF has no matrix (relative WB), but a photo catalogued before that
/// fact was recorded was edited under absolute WB: the catalog's rule wins, the decoder's
/// pixels and camera tone are used.
#[test]
fn catalog_decides_how_raw_white_balance_is_read() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| [0.01 * (i + 1) as f32, 0.02 * (i + 1) as f32])).unwrap();
let decoded = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let source = DecodedSource::new(Arc::new(Rgb32f::new(2, 2)), Some(decoded));
let mut p = Photo::new(PhotoId(1), Source::File { path: "/x/a.nef".into() }, "a.nef", "NEF", 2, 2, "");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5100.0, 3.0));
let info = source.info_or(source_info(&p));
assert_eq!((info.relative_wb, info.as_shot_temp, info.as_shot_tint, info.camera_tone), (false, 5100.0, 3.0, Some(tone)));
p.fallback_matrix = Some(true);
let info = source.info_or(source_info(&p));
assert_eq!((info.relative_wb, info.as_shot_temp, info.as_shot_tint), (true, 6500.0, 0.0));
// A file with no catalog record (header not raw) keeps the decoder's view.
assert_eq!(source.info_or(SourceInfo::default()), decoded);
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.01 * (i + 1) as f32;
[x, (x * 2.0).min(0.9)]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
job.source = SourceRef::File {
path: "synthetic.arw".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| {
let mut image = Rgb32f::new(64, 64);
image.data.fill([0.1; 3]);
Ok((image, info))
})),
fallback: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());
let r = job.clone().run();
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
s.accept(&r);
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
let again = job.run();
assert!(again.loaded.is_none());
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
s.media.forget(id);
assert!(s.media.get_source(id, r.level).is_none());
}
#[test]
fn budget_evicts_least_recently_used_across_caches() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
+7 -2
View File
@@ -68,9 +68,14 @@ impl crate::Session {
OriginalGuard::new(&self.catalog)
}
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar.
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar. A free
/// path (the usual case) is answered without building the guard over the whole library.
pub fn check_write_target(&self, path: &str) -> Result<(), String> {
self.original_guard().check(Path::new(path))
let path = Path::new(path);
if std::fs::symlink_metadata(path).is_err() {
return Ok(()); // nothing there to replace
}
self.original_guard().check(path)
}
}
+87 -64
View File
@@ -538,6 +538,92 @@ fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
}
}
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
/// rename once. No file-system calls.
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
let mut seen_paths: HashSet<String> = HashSet::new();
ids.iter()
.filter_map(|id| cat.photo(*id))
.filter(|p| match &p.source {
Source::File { path } => seen_paths.insert(path.clone()),
_ => true,
})
.cloned()
.collect()
}
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
/// drive). Whether a case-only change names this very file is decided on the disk.
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
}
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
/// two spellings name one file (see [`MoveFs`]).
fn plan_rename_core(
photos: &[std::sync::Arc<Photo>],
template: &str,
start: usize,
exists: &dyn Fn(&Path) -> bool,
same_file: &dyn Fn(&Path, &Path) -> bool,
) -> Vec<RenamePlan> {
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
for (seq, p) in (start..).zip(photos) {
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
let candidate = |k: usize| {
if k == 0 {
want.clone()
} else if ext.is_empty() {
format!("{stem}-{k}")
} else {
format!("{stem}-{k}.{ext}")
}
};
let (to, to_path) = match &p.source {
Source::File { path } => {
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut k = 0;
loop {
let name = candidate(k);
let tp = dir.join(&name).to_string_lossy().to_string();
let key = tp.to_lowercase();
// this very file, maybe spelled differently (case-insensitive volume)? By
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
let (from, to) = (Path::new(path), Path::new(&tp));
let same = tp == *path || (from != to && key == path.to_lowercase() && exists(to) && same_file(from, to));
// free: not claimed in this batch and not on disk (unless it is this very file).
// A file this batch moves away still counts as taken: simple and safe.
if !taken.contains(&key) && (same || !exists(to)) {
taken.insert(key);
break (name, Some(tp));
}
k += 1;
}
}
Source::Demo { .. } => {
let mut k = 0;
loop {
let name = candidate(k);
if taken.insert(format!("demo:{}", name.to_lowercase())) {
break (name, None);
}
k += 1;
}
}
};
let from_path = match &p.source {
Source::File { path } => Some(path.clone()),
_ => None,
};
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
}
out
}
impl Session {
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
@@ -546,70 +632,7 @@ impl Session {
/// [`Session::plan_rename`] on the file system `fs`.
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
// photos sharing one file (virtual copies) rename once
let mut seen_paths: HashSet<String> = HashSet::new();
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
let mut seq = start;
for id in ids {
let Some(p) = self.catalog.photo(*id) else { continue };
if let Source::File { path } = &p.source
&& !seen_paths.insert(path.clone())
{
continue;
}
let want = expand(template, p, seq);
seq += 1;
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
let candidate = |k: usize| {
if k == 0 {
want.clone()
} else if ext.is_empty() {
format!("{stem}-{k}")
} else {
format!("{stem}-{k}.{ext}")
}
};
let (to, to_path) = match &p.source {
Source::File { path } => {
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut k = 0;
loop {
let name = candidate(k);
let tp = dir.join(&name).to_string_lossy().to_string();
let key = tp.to_lowercase();
// this very file, maybe spelled differently (case-insensitive volume)? By
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
// free: not claimed in this batch and not on disk (unless it is this very file).
// A file this batch moves away still counts as taken: simple and safe.
let on_disk = fs.exists(Path::new(&tp)) && !same;
if !taken.contains(&key) && !on_disk {
taken.insert(key);
break (name, Some(tp));
}
k += 1;
}
}
Source::Demo { .. } => {
let mut k = 0;
loop {
let name = candidate(k);
if taken.insert(format!("demo:{}", name.to_lowercase())) {
break (name, None);
}
k += 1;
}
}
};
let from_path = match &p.source {
Source::File { path } => Some(path.clone()),
_ => None,
};
out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path });
}
out
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
}
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
+60 -13
View File
@@ -1,13 +1,16 @@
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
//! at render time and is not baked into the pixels.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::Photo;
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_pipeline::tone::CameraTone;
use lightcraft_raster::{Rgb32f, Rgba8};
const MAGIC: &[u8] = b"LCSP1\n";
@@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String {
format!("{:032x}.lcsp", h.0)
}
/// Encode a source image as a smart preview.
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
const HEADER_MAX: u64 = 4096;
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
// scale so all but the brightest 0.05 % fit into 0..1
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
let scale = if lum.is_empty() {
@@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
)
.map_err(|e| e.to_string())?;
let mut out = MAGIC.to_vec();
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
out.extend_from_slice(head.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&jpg);
Ok(out)
}
/// Decode a smart preview back into a source image.
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
/// curve is ignored, like a missing one).
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
// the decoder undoes the sRGB encoding; the values are the source's own primaries
let mut img = d.image;
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
Ok(img)
Ok((img, tone))
}
/// Where a library keeps its smart previews.
@@ -157,8 +170,8 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
pub fn is_valid(path: &Path) -> bool {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut f) = std::fs::File::open(path) else { return false };
let mut head = Vec::with_capacity(256);
if (&mut f).take(256).read_to_end(&mut head).is_err() {
let mut head = Vec::with_capacity(1024);
if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() {
return false;
}
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
@@ -175,9 +188,9 @@ pub fn is_valid(path: &Path) -> bool {
}
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
decode(&b).map(Arc::new)
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
}
#[cfg(test)]
@@ -228,7 +241,7 @@ mod tests {
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let good = encode(&img).unwrap();
let good = encode(&img, None).unwrap();
std::fs::write(a.join("1.lcsp"), &good).unwrap();
assert!(is_valid(&a.join("1.lcsp")));
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
@@ -242,11 +255,45 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn camera_tone_travels_with_the_proxy() {
let tone = CameraTone::new(std::array::from_fn(|i| {
let x = 0.004 * 1.17f32.powi(i as i32);
[x, 0.95 * (1.0 - (-2.7 * x).exp())]
}))
.unwrap();
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let bytes = encode(&img, Some(&tone)).unwrap();
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
let dir = temp("tone");
let path = dir.join("t.lcsp");
std::fs::write(&path, &bytes).unwrap();
// the longer header is still read by the validity check, and the curve by `load`
assert!(is_valid(&path));
let loaded = load(&path).unwrap();
assert_eq!(loaded.camera_tone, Some(tone));
let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() };
assert_eq!(loaded.info_or(header).camera_tone, Some(tone));
// a hostile curve (reversing knots) is ignored, not trusted
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap();
knots.swap(3, 4);
head["tone"]["knots"] = serde_json::json!(knots);
let mut hostile = MAGIC.to_vec();
hostile.extend_from_slice(head.to_string().as_bytes());
hostile.extend_from_slice(&bytes[nl..]);
let (pixels, tone) = decode(&hostile).unwrap();
assert_eq!((pixels.width, tone), (img.width, None));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_keeps_the_picture() {
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
let back = decode(&encode(&img).unwrap()).unwrap();
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
assert_eq!((back.width, back.height), (img.width, img.height));
assert!(tone.is_none());
let mut err = 0.0f64;
for (a, b) in img.data.iter().zip(&back.data) {
for k in 0..3 {
+24
View File
@@ -10,6 +10,30 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
}
#[test]
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
let mut s = demo();
let id = PhotoId(100);
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0);
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
}
#[test]
fn demo_library_loads() {
let mut s = demo();
+21
View File
@@ -323,3 +323,24 @@ fn a_failed_export_write_keeps_the_previous_file() {
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
/// disk file by file; an edit copy that becomes a library photo still is.
#[test]
fn exports_are_atomic_without_a_sync() {
use crate::export::{Conflict, Destination};
use lightcraft_catalog::safe_file::syncs_on_this_thread;
let (mut s, id, dir, _src, _) = library_with_jpeg("export-nosync");
let out = dir.join("out");
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
let o = ExportOptions { conflict: Conflict::Unique, ..Default::default() };
let before = syncs_on_this_thread();
for _ in 0..3 {
disk_batch(&mut s, id, &o, &to).unwrap();
}
assert_eq!(syncs_on_this_thread(), before, "no per-file sync for exports");
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 3, "three exports, no temp files");
crate::export::write_file_durable(&out.join("edit.tif").to_string_lossy(), b"tiff").unwrap();
assert!(syncs_on_this_thread() > before, "the durable writer syncs");
let _ = std::fs::remove_dir_all(&dir);
}
+75 -7
View File
@@ -144,11 +144,10 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
#[test]
fn find_missing_matches_renamed_files_by_content() {
let dir = temp_dir("missing-content");
/// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with
/// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors.
fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) {
let dir = temp_dir(tag);
std::fs::create_dir_all(dir.join("old")).unwrap();
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
@@ -156,20 +155,26 @@ fn find_missing_matches_renamed_files_by_content() {
}
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
// an impostor: same name and size, other bytes
let impostor = |src: &Path, dst: &Path| {
let mut b = std::fs::read(src).unwrap();
let k = b.len() - 20;
b[k] ^= 0xff;
std::fs::write(dst, b).unwrap();
};
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
std::fs::remove_file(dir.join("old/c.png")).unwrap();
(dir, s)
}
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
#[test]
fn find_missing_matches_renamed_files_by_content() {
let (dir, mut s) = renamed_and_ambiguous_scene("missing-content");
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
let found = r["found"].as_array().unwrap();
assert_eq!(found.len(), 2, "{r}");
@@ -183,6 +188,55 @@ fn find_missing_matches_renamed_files_by_content() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan
/// afterwards; that gives exactly what the synchronous command gives — renamed files by content,
/// impostors and look-alikes skipped (`ambiguous`).
#[test]
fn find_missing_planned_on_a_worker_matches_the_command() {
use crate::cmd::missing::{find_candidates, plan_find_missing};
let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker");
let folder = dir.join("moved").to_string_lossy().to_string();
let candidates = find_candidates(&s.catalog);
let f = folder.clone();
let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap();
assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}");
let applied = s.execute("library.findMissing", &plan.to_json()).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three");
let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap();
assert_eq!(applied, direct);
assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}");
assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked
/// meanwhile is left alone, and a file that is now another photo's original isn't used.
#[test]
fn find_missing_plan_is_rechecked_when_applied() {
use crate::cmd::missing::{find_candidates, plan_find_missing};
let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck");
let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap();
let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone();
let (a, b) = (to("a.png"), to("b.png"));
// meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file
std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap();
s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap();
let c = plan.ambiguous[0].id;
s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap();
let r = s.execute("library.findMissing", &plan.to_json()).unwrap();
assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}");
assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}");
let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => String::new(),
};
assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy());
assert_eq!(path_of(b.id), b.from);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_files_are_found_and_relinked() {
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
@@ -425,6 +479,20 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
// the app's background build (issue #104) checks and repairs the same way, on a worker thread
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap();
assert_eq!(r["what"], "smart previews", "{r}");
let t0 = std::time::Instant::now();
let r = loop {
let r = s.execute("library.previewProgress", &json!({})).unwrap();
if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) {
break r;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}");
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
+113
View File
@@ -207,6 +207,67 @@ fn copy_import_verifies_each_copy() {
let _ = std::fs::remove_dir_all(&base);
}
/// Copy verifies against the probe's content hash (issue #134): the hash, not a re-read of the
/// source, is the reference — a copy that matches the source but not the hash is refused, a
/// corrupted copy is caught, and the file hash agrees with `hash_bytes` across read chunks.
#[test]
fn copy_is_verified_against_the_probe_hash() {
use crate::import_move::copy_verified;
let base = temp_dir("copyhash");
let src = base.join("src.bin");
// larger than one 1 MB read, not a multiple of it
let bytes: Vec<u8> = (0..(3 << 20) + 12_345u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
std::fs::write(&src, &bytes).unwrap();
let good = lightcraft_preview::hash_bytes(&bytes);
copy_verified(&src, &base.join("ok.bin"), Some(good)).unwrap();
assert_eq!(std::fs::read(base.join("ok.bin")).unwrap(), bytes);
// a hash that doesn't match: refused (a byte compare with the source would have passed)
let stale = lightcraft_preview::hash_bytes(b"what the probe read earlier");
let e = copy_verified(&src, &base.join("stale.bin"), Some(stale)).unwrap_err();
assert!(e.to_string().contains("differs"), "{e}");
assert!(!base.join("stale.bin").exists(), "the refused copy is removed");
// a copy that comes out different: caught by the hash
inject(Fault::Corrupt);
let r = copy_verified(&src, &base.join("bad.bin"), Some(good));
inject(Fault::None);
assert!(r.is_err());
assert!(!base.join("bad.bin").exists());
assert_eq!(std::fs::read(&src).unwrap(), bytes, "the source is untouched");
let _ = std::fs::remove_dir_all(&base);
}
/// A file replaced after Review Import probed it is not imported on the strength of the old probe:
/// its copy no longer matches the probe's hash, so it's reported as failed and nothing is left.
#[test]
fn copy_import_refuses_a_file_changed_since_the_review() {
let base = temp_dir("copychanged");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 3);
let mut s = session();
let r = s.execute("library.importPreview", &json!({"paths": [card.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 1, "{r}");
write_png(&card.join("a.png"), 9); // other pixels
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
)
.unwrap();
assert_eq!(len(&r, "imported"), 0, "{r}");
assert_eq!(len(&r, "failed"), 1, "{r}");
assert!(r.to_string().contains("changed meanwhile"), "{r}");
assert_eq!(files_under(&dest), Vec::<String>::new());
// importing again probes afresh and succeeds
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
)
.unwrap();
assert_eq!(len(&r, "imported"), 1, "{r}");
let _ = std::fs::remove_dir_all(&base);
}
/// A source that can't be removed (a read-only card) stays and is reported; the photo is
/// imported from its copy.
#[test]
@@ -320,3 +381,55 @@ fn move_into_the_library_is_saved_before_sources_go() {
assert_eq!(photo_paths(&again), [want.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&base);
}
/// Timing for issue #134 (not a pass/fail test): `cargo test -p lightcraft-engine --release
/// write_policy_timing -- --ignored --nocapture`. Writes N export-sized files durably (temp +
/// sync + rename) and atomically only, and copies N files verified byte for byte and against the
/// probe hash, alternating so a loaded machine affects both alike. `LIGHTCRAFT_BENCH_DIR` puts
/// the files on another volume (a USB drive or a NAS is where the difference shows).
#[test]
#[ignore]
fn write_policy_timing() {
use crate::import_move::copy_verified;
use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync};
use std::time::{Duration, Instant};
let root = std::env::var_os("LIGHTCRAFT_BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
let base = root.join(format!("lc-write-policy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let data = |n: usize, seed: u32| -> Vec<u8> { (0..n as u32).map(|i| (i.wrapping_add(seed).wrapping_mul(2_654_435_761) >> 11) as u8).collect() };
let (n, size) = (200usize, 300 << 10);
let (mut synced, mut unsynced) = (Duration::ZERO, Duration::ZERO);
for i in 0..n {
let b = data(size, i as u32);
let t = Instant::now();
write_atomic(&base.join(format!("sync-{i}.jpg")), &b).unwrap();
synced += t.elapsed();
let t = Instant::now();
write_atomic_nosync(&base.join(format!("nosync-{i}.jpg")), &b).unwrap();
unsynced += t.elapsed();
}
println!("export {n} × {} KB: write_atomic (synced) {synced:?}, write_atomic_nosync {unsynced:?}", size >> 10);
let (n, size) = (40usize, 8 << 20);
let card = base.join("card");
std::fs::create_dir_all(&card).unwrap();
let hashes: Vec<_> = (0..n)
.map(|i| {
let b = data(size, 7 * i as u32);
std::fs::write(card.join(format!("{i}.raw")), &b).unwrap();
lightcraft_preview::hash_bytes(&b)
})
.collect();
let (mut bytewise, mut hashed) = (Duration::ZERO, Duration::ZERO);
for (i, h) in hashes.iter().enumerate() {
let src = card.join(format!("{i}.raw"));
let t = Instant::now();
copy_verified(&src, &base.join(format!("bytes-{i}.raw")), None).unwrap();
bytewise += t.elapsed();
let t = Instant::now();
copy_verified(&src, &base.join(format!("hash-{i}.raw")), Some(*h)).unwrap();
hashed += t.elapsed();
}
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
let _ = std::fs::remove_dir_all(&base);
}
+234
View File
@@ -0,0 +1,234 @@
//! Which wgpu backends LightCraft lets wgpu use — for its compute device and for the desktop
//! window (eframe) — and the crash sentinel around device creation (issue #136).
//!
//! wgpu loads the system driver of *every* backend in the instance's set while it enumerates
//! adapters, even when it then picks another one: with Vulkan in the set, a broken Vulkan driver
//! can crash the process (an access violation inside the driver DLL, which nothing can catch)
//! although DX12 would have been used. So Windows defaults to DX12 alone. macOS uses Metal, Linux
//! Vulkan (plus GL for the window when the build has it).
//!
//! Overrides, read once per process:
//! - `LIGHTCRAFT_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto`
//! (the platform default), or `off` (no GPU compute; the window still needs one backend and
//! keeps the platform default);
//! - else `WGPU_BACKEND` (wgpu's own variable, same names) for both the window and compute.
//!
//! Backends this build doesn't contain (e.g. `gl`: the GL backend isn't compiled in) are ignored
//! with a warning and the platform default is used.
use std::path::PathBuf;
use std::sync::Mutex;
pub use wgpu::Backends;
/// What the environment asks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BackendChoice {
/// The platform default.
Auto,
/// No GPU compute.
Off,
/// These backends.
Use(Backends),
}
/// Parse a backend list (`"dx12"`, `"vulkan,gl"`, `"auto"`, `"off"`…). `None`: nothing usable in
/// it (empty or unknown names).
pub fn parse_backends(s: &str) -> Option<BackendChoice> {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"" => None,
"auto" | "default" => Some(BackendChoice::Auto),
"off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off),
_ => {
let b = Backends::from_comma_list(&s);
(!b.is_empty()).then_some(BackendChoice::Use(b))
}
}
}
/// The choice from `LIGHTCRAFT_GPU_BACKEND` (`lightcraft`) or else `WGPU_BACKEND` (`wgpu`): the
/// first one that parses wins; an unknown value is skipped (with a warning).
pub fn choose(lightcraft: Option<&str>, wgpu: Option<&str>) -> BackendChoice {
for (name, v) in [("LIGHTCRAFT_GPU_BACKEND", lightcraft), ("WGPU_BACKEND", wgpu)] {
let Some(v) = v else { continue };
match parse_backends(v) {
// WGPU_BACKEND is wgpu's variable: it selects backends, it never turns the GPU off
Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use LIGHTCRAFT_GPU_BACKEND=off)"),
Some(c) => return c,
None => log::warn!("gpu: {name}={v} names no known backend; ignored"),
}
}
BackendChoice::Auto
}
/// The backends compute devices use when nothing is asked for on `os` (`std::env::consts::OS`).
pub fn default_compute_backends(os: &str) -> Backends {
match os {
// never touch the Vulkan driver unless asked: see the module docs (issue #136)
"windows" => Backends::DX12,
"macos" | "ios" => Backends::METAL,
_ => Backends::PRIMARY,
}
}
/// The backends the window uses when nothing is asked for on `os`.
pub fn default_window_backends(os: &str) -> Backends {
match os {
"windows" => Backends::DX12,
"macos" | "ios" => Backends::METAL,
// eframe's default (GL only when the build has it)
_ => Backends::PRIMARY | Backends::GL,
}
}
/// Resolve `choice` against the backends this build contains (`compiled`): `None` = GPU off.
/// Asked-for backends that aren't compiled in fall back to `default`.
pub fn resolve(choice: BackendChoice, default: Backends, compiled: Backends) -> Option<Backends> {
match choice {
BackendChoice::Off => None,
BackendChoice::Auto => Some(default),
BackendChoice::Use(b) if b.intersects(compiled) => Some(b & compiled),
BackendChoice::Use(b) => {
log::warn!("gpu: backend {b:?} is not in this build ({compiled:?}); using {default:?}");
Some(default)
}
}
}
fn env_choice() -> BackendChoice {
static C: std::sync::OnceLock<BackendChoice> = std::sync::OnceLock::new();
*C.get_or_init(|| {
let lc = std::env::var("LIGHTCRAFT_GPU_BACKEND").ok();
let wg = std::env::var("WGPU_BACKEND").ok();
choose(lc.as_deref(), wg.as_deref())
})
}
/// The backends of the compute device (`None`: `LIGHTCRAFT_GPU_BACKEND=off`).
pub fn compute_backends() -> Option<Backends> {
resolve(env_choice(), default_compute_backends(std::env::consts::OS), wgpu::Instance::enabled_backend_features())
}
/// The backends for the desktop window's renderer (eframe/egui-wgpu). The window always needs
/// one: `off` keeps the platform default.
pub fn window_backends() -> Backends {
let default = default_window_backends(std::env::consts::OS);
let choice = match env_choice() {
BackendChoice::Off => BackendChoice::Auto,
c => c,
};
resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default)
}
/// `LIGHTCRAFT_GPU_BACKEND=off`.
pub(crate) fn env_off() -> bool {
env_choice() == BackendChoice::Off
}
static MARKER: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Write this file before the compute device is created and remove it once creation returned
/// (successfully or not). A file still there at the next launch means the process died inside
/// the driver: see [`take_init_marker`]. `None` (the default) writes nothing.
pub fn set_init_marker(path: Option<PathBuf>) {
*MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path;
}
/// Run device creation `f` between writing and removing the init marker (if one is set).
pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) -> T {
let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(m) = &marker {
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let _ = std::fs::write(m, format!("GPU device creation started (backends {backends:?}, unix time {secs})\n"));
}
let r = f();
if let Some(m) = &marker {
let _ = std::fs::remove_file(m);
}
r
}
/// If the init marker `path` is present — the last process died while creating the GPU device —
/// remove it and return what it recorded.
pub fn take_init_marker(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let _ = std::fs::remove_file(path);
Some(text.trim().to_string())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parses_backend_names() {
assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12)));
assert_eq!(parse_backends(" D3D12 "), Some(BackendChoice::Use(Backends::DX12)));
assert_eq!(parse_backends("vulkan"), Some(BackendChoice::Use(Backends::VULKAN)));
assert_eq!(parse_backends("vk,gl"), Some(BackendChoice::Use(Backends::VULKAN | Backends::GL)));
assert_eq!(parse_backends("metal"), Some(BackendChoice::Use(Backends::METAL)));
assert_eq!(parse_backends("auto"), Some(BackendChoice::Auto));
for off in ["off", "OFF", "none", "cpu", "0"] {
assert_eq!(parse_backends(off), Some(BackendChoice::Off), "{off}");
}
assert_eq!(parse_backends(""), None);
assert_eq!(parse_backends("directx9"), None);
}
#[test]
fn lightcraft_variable_wins_over_wgpu_backend() {
assert_eq!(choose(None, None), BackendChoice::Auto);
assert_eq!(choose(None, Some("dx12")), BackendChoice::Use(Backends::DX12));
assert_eq!(choose(Some("vulkan"), Some("dx12")), BackendChoice::Use(Backends::VULKAN));
assert_eq!(choose(Some("off"), Some("dx12")), BackendChoice::Off);
// unknown LightCraft value: WGPU_BACKEND still applies
assert_eq!(choose(Some("bogus"), Some("gl")), BackendChoice::Use(Backends::GL));
// WGPU_BACKEND never turns the GPU off
assert_eq!(choose(None, Some("off")), BackendChoice::Auto);
}
/// Issue #136: Windows must not load the Vulkan driver unless asked to.
#[test]
fn windows_defaults_to_dx12_only() {
assert_eq!(default_compute_backends("windows"), Backends::DX12);
assert_eq!(default_window_backends("windows"), Backends::DX12);
assert!(!default_window_backends("windows").contains(Backends::VULKAN));
assert_eq!(default_compute_backends("macos"), Backends::METAL);
assert!(default_compute_backends("linux").contains(Backends::VULKAN));
assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL));
}
#[test]
fn resolves_against_the_build() {
let compiled = Backends::DX12 | Backends::VULKAN;
let def = Backends::DX12;
assert_eq!(resolve(BackendChoice::Auto, def, compiled), Some(def));
assert_eq!(resolve(BackendChoice::Off, def, compiled), None);
assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN), def, compiled), Some(Backends::VULKAN));
assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN | Backends::GL), def, compiled), Some(Backends::VULKAN));
// GL isn't compiled in: the default, not an empty set (no adapter at all)
assert_eq!(resolve(BackendChoice::Use(Backends::GL), def, compiled), Some(def));
}
#[test]
fn init_marker_is_written_during_creation_and_removed_after() {
let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id()));
let m = dir.join("gpu-init.marker");
let _ = std::fs::remove_dir_all(&dir);
set_init_marker(Some(m.clone()));
let seen = with_init_marker(Backends::DX12, || m.exists());
set_init_marker(None);
assert!(seen, "the marker exists while the device is created");
assert!(!m.exists(), "and is removed afterwards");
// a marker left behind by a crashed process is reported once
std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap();
assert!(take_init_marker(&m).unwrap().contains("DX12"));
assert_eq!(take_init_marker(&m), None);
let _ = std::fs::remove_dir_all(&dir);
}
}
+4 -3
View File
@@ -276,14 +276,15 @@ fn module_source(m: &Module, consts: &str) -> String {
impl Gpu {
/// Create a device on the best available adapter (no software fallback), or why there is none.
pub fn new() -> Result<Gpu, String> {
pub fn new(backends: wgpu::Backends) -> Result<Gpu, String> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::PRIMARY;
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
desc.backends = backends;
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.map_err(|e| format!("no GPU adapter ({e})"))?;
.map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?;
let info = adapter.get_info();
if info.device_type == wgpu::DeviceType::Cpu {
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
+33 -9
View File
@@ -6,8 +6,11 @@
//! same settings on both and bound the difference in 8-bit sRGB. Stages without a kernel run on the
//! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`.
//!
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or
//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
//! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and
//! the crash sentinel around device creation: [`backend`] (issue #136).
//!
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`,
//! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`]
//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`).
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
@@ -22,6 +25,8 @@ use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_raster::Rgb32f;
#[cfg(not(target_arch = "wasm32"))]
pub mod backend;
#[cfg(not(target_arch = "wasm32"))]
mod ctx;
#[cfg(not(target_arch = "wasm32"))]
@@ -61,6 +66,10 @@ fn record_fallback(reason: String) {
/// Never blocks.
pub fn unavailable_reason() -> Option<String> {
if env_disabled() {
#[cfg(not(target_arch = "wasm32"))]
if backend::env_off() {
return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into());
}
return Some("disabled by LIGHTCRAFT_GPU=0".into());
}
if !ENABLED.load(Ordering::Relaxed) {
@@ -136,9 +145,15 @@ pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled()
}
/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process.
fn env_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")))
*OFF.get_or_init(|| {
let off = std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
#[cfg(not(target_arch = "wasm32"))]
let off = off || backend::env_off();
off
})
}
#[cfg(not(target_arch = "wasm32"))]
@@ -146,10 +161,18 @@ static GPU: std::sync::OnceLock<Result<ctx::Gpu, String>> = std::sync::OnceLock:
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
if env_disabled() {
return None;
// turned off (preference, environment): don't even load the driver (issue #136)
if env_disabled() || !ENABLED.load(Ordering::Relaxed) {
return existing_device();
}
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok()
GPU.get_or_init(|| {
let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };
backend::with_init_marker(backends, || {
std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into()))
})
})
.as_ref()
.ok()
}
/// The device if it has been created (never creates it).
@@ -158,11 +181,12 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> {
GPU.get().and_then(|g| g.as_ref().ok())
}
/// Create the device and compile the kernels on a background thread now (app start), so the
/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does.
/// Create the device and compile the kernels on a background thread now (once the window is up),
/// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing
/// while GPU rendering is turned off.
pub fn warm_up() {
#[cfg(not(target_arch = "wasm32"))]
if !env_disabled() && GPU.get().is_none() {
if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() {
let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
let _ = device();
});
+21
View File
@@ -22,6 +22,27 @@ fn gpu() -> bool {
ok
}
#[test]
fn camera_tone_and_relative_wb() {
if !gpu() {
return;
}
let src = scene(2, 320, 240);
let curve = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() };
let mut s = DevelopSettings::default();
check("camera tone neutral", &src, &info, &s, &RenderRequest::fit(320, 240));
s.light.exposure = 1.0;
s.light.contrast = 40.0;
s.wb.mode = WbMode::Custom;
s.wb.temp = 8000.0;
check("camera tone edited", &src, &info, &s, &RenderRequest::fit(320, 240));
}
fn scene(i: usize, w: usize, h: usize) -> Arc<Rgb32f> {
Arc::new(lightcraft_scenes::demo_library()[i].render(w, h))
}
+1
View File
@@ -45,6 +45,7 @@ pub fn color_data(color: &FrameColor) -> ColorData {
as_shot_neutral: Some([1.0, 1.0, 1.0]),
as_shot_white_xy: None,
baseline_exposure: 0.0,
profile: Default::default(),
}
}
}
+3 -1
View File
@@ -221,7 +221,9 @@ impl FinishParams {
FinishParams {
calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None },
shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 },
tone: if info.raw {
tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) {
ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks)
} else if info.raw {
ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks)
} else {
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
+4 -1
View File
@@ -64,11 +64,14 @@ pub struct SourceInfo {
pub raw: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
pub relative_wb: bool,
pub camera_tone: Option<tone::CameraTone>,
}
impl Default for SourceInfo {
fn default() -> Self {
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, relative_wb: false, camera_tone: None }
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) {
use lightcraft_develop::WbMode;
match s.wb.mode {
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
m if info.raw => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
_ => (s.wb.temp, s.wb.tint),
}
}
+89
View File
@@ -14,12 +14,75 @@ pub const LUT_MIN_EV: f32 = -14.0;
pub const LUT_MAX_EV: f32 = 10.0;
pub const LUT_N: usize = 4096;
/// A file-local camera look, fitted independently of the scene-linear colour transform.
/// Knots are scene/display-linear luminance pairs. Keeping this in the finish stage preserves
/// RAW exposure and highlight headroom; it is never baked into the decoded sensor pixels.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
pub struct CameraTone {
knots: [[f32; 2]; 32],
}
impl<'de> serde::Deserialize<'de> for CameraTone {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Wire {
knots: [[f32; 2]; 32],
}
let w = Wire::deserialize(d)?;
Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))
}
}
impl CameraTone {
pub fn new(knots: [[f32; 2]; 32]) -> Option<Self> {
let mut previous = [0.0, 0.0];
for p in knots {
if !p.iter().all(|v| v.is_finite()) || p[0] <= previous[0] || p[1] < previous[1] || p[1] >= 1.0 {
return None;
}
previous = p;
}
Some(Self { knots })
}
pub fn apply(&self, y: f32) -> f32 {
if !y.is_finite() || y <= 0.0 {
return 0.0;
}
let mut previous = [0.0, 0.0];
for p in self.knots {
if y <= p[0] {
let t = (y - previous[0]) / (p[0] - previous[0]);
return previous[1] + t * (p[1] - previous[1]);
}
previous = p;
}
let a = self.knots[30];
let b = self.knots[31];
// Extend beyond observed (unclipped) highlights with a continuous, bounded shoulder.
let slope = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0);
1.0 - (1.0 - b[1]) * (-(y - b[0]) * slope / (1.0 - b[1]).max(0.01)).exp()
}
}
#[derive(Clone, Debug)]
pub struct ToneMap {
lut: Vec<f32>,
}
impl ToneMap {
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
let adjustment = Self::display(contrast, whites, blacks);
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = curve.apply(GREY * 2f32.powf(ev));
if neutral { y } else { adjustment.apply(y) }
})
.collect();
ToneMap { lut }
}
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
let c = (contrast / 100.0) as f32;
@@ -116,6 +179,32 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
mod tests {
use super::*;
#[test]
fn camera_curve_preserves_black_and_extends_headroom() {
let knots = std::array::from_fn(|i| {
let x = 0.005 * 1.15f32.powi(i as i32);
[x, 1.0 - (-3.0 * x).exp()]
});
let curve = CameraTone::new(knots).unwrap();
let base = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
assert_eq!(base.apply(0.0), 0.0);
assert!(base.apply(0.1) < base.apply(0.2));
let mut previous = 0.0;
for i in 0..2000 {
let y = 1e-6 * 1.01f32.powi(i);
let v = base.apply(y);
assert!((0.0..=1.0).contains(&v));
assert!(v >= previous - 1e-6);
previous = v;
}
assert!((base.apply(0.1) - curve.apply(0.1)).abs() < 0.001);
assert!(ToneMap::camera(&curve, 50.0, 0.0, 0.0).apply(0.3) > base.apply(0.3));
let mut invalid = knots;
invalid[1][0] = invalid[0][0];
assert!(CameraTone::new(invalid).is_none());
assert!(serde_json::from_value::<CameraTone>(serde_json::json!({"knots": invalid})).is_err());
}
#[test]
fn monotone_and_bounded() {
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
+22
View File
@@ -1,6 +1,7 @@
//! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]),
//! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists.
use crate::profile::{HsvTable, ProfileLook, ToneCurve};
use crate::tiffraw::{Packing, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes};
use lightcraft_color::Xy;
@@ -45,6 +46,27 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData {
as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])),
baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0)
+ get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0),
profile: profile_look(ifd0, raw),
}
}
/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed
/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0.
pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook {
let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 };
let table = |dims: u16, data: u16, enc: u16| {
let ifd = pick(dims);
let dims = ifd.u64s(dims)?;
let data = ifd.f64s(data)?;
HsvTable::from_tags(&dims, &data, ifd.u64s(enc).and_then(|v| v.first().copied()).unwrap_or(0))
};
ProfileLook {
hue_sat_map: [
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_1, t::PROFILE_HUE_SAT_MAP_ENCODING),
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_2, t::PROFILE_HUE_SAT_MAP_ENCODING),
],
look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING),
tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)),
}
}
+32
View File
@@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set(t::AS_SHOT_WHITE_XY, rat_vec(&[xy.x, xy.y]));
}
ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)]));
// the source's own profile look travels with its data (conversions, smart previews, merges)
let p = &c.profile;
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
if table.srgb_value {
ifd.set(enc, Value::Long(vec![1]));
}
};
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
match &p.hue_sat_map {
[Some(a), b] => {
table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a));
if let Some(b) =
b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions))
{
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b));
}
}
[None, Some(b)] => {
table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b));
}
[None, None] => {}
}
if let Some(l) = &p.look_table {
table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l));
}
if let Some(curve) = &p.tone_curve {
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
}
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
if !list.is_empty() {
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
+7 -1
View File
@@ -8,7 +8,8 @@
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails).
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
//! and produces camera → linear Rec.2020 D65 matrices.
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
//! look tables and tone curve.
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
@@ -29,6 +30,7 @@ pub mod highlight;
pub mod ljpeg;
pub mod opcodes;
mod preview;
pub mod profile;
mod tiffraw;
mod unpack;
mod vendor;
@@ -393,6 +395,10 @@ pub struct ColorData {
pub as_shot_white_xy: Option<Xy>,
/// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`).
pub baseline_exposure: f64,
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
/// `ProfileToneCurve`), applied by [`color`]'s users at render time.
#[serde(default)]
pub profile: profile::ProfileLook,
}
/// A decoded raw image.
+387
View File
@@ -0,0 +1,387 @@
//! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7,
//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags).
//!
//! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift
//! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor).
//! Hue samples are spread evenly around the circle with wrap-around, saturation and value samples
//! evenly over 0..=1; a dimension with one division is constant. Lookups are tri-linear.
//! - Tables are applied in HSV of linear ProPhoto RGB (D50). With encoding 1 ("sRGB") the value
//! axis is indexed through the sRGB transfer curve (3D tables only).
//! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
//!
//! The data is read from the user's own file at run time; nothing here ships profile data.
//! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever
//! lowers them, so a lookup can't clip what the scene-referred pipeline still needs.
use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear};
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford};
use serde::{Deserialize, Serialize};
/// Upper bound on table entries (Adobe profiles use ≤ 90 × 30 × 16; guards header-driven sizes).
const MAX_ENTRIES: usize = 1 << 20;
/// A DNG hue/saturation/value mapping table (`ProfileHueSatMapData*` / `ProfileLookTableData`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HsvTable {
pub hue_divisions: usize,
pub sat_divisions: usize,
pub val_divisions: usize,
/// `(hue shift °, saturation scale, value scale)`, value-major, hue-middle, saturation-minor.
pub data: Vec<[f32; 3]>,
/// The value axis is indexed through the sRGB encoding curve (`…Encoding` = 1).
pub srgb_value: bool,
}
impl HsvTable {
/// A table from its `…Dims` (hue, saturation, value divisions), data floats and encoding.
/// `None` when the dimensions are out of range, the data length doesn't match or a value is
/// not finite.
pub fn from_tags(dims: &[u64], data: &[f64], encoding: u64) -> Option<HsvTable> {
let [h, s, v] = <[u64; 3]>::try_from(dims).ok()?;
let (h, s, v) = (usize::try_from(h).ok()?, usize::try_from(s).ok()?, usize::try_from(v).ok()?);
if h < 1 || s < 2 || v < 1 || h > 4096 || s > 4096 || v > 4096 {
return None;
}
let n = h.checked_mul(s)?.checked_mul(v)?;
if n > MAX_ENTRIES || data.len() != n * 3 || data.iter().any(|x| !x.is_finite()) {
return None;
}
let data = data.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, (c[1] as f32).max(0.0), (c[2] as f32).max(0.0)]).collect();
Some(HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value: encoding == 1 && v > 1 })
}
/// The entry-wise blend `g · a + (1 − g) · b` of two tables of the same shape (`None` otherwise).
pub fn blend(a: &HsvTable, b: &HsvTable, g: f64) -> Option<HsvTable> {
if (a.hue_divisions, a.sat_divisions, a.val_divisions) != (b.hue_divisions, b.sat_divisions, b.val_divisions) {
return None;
}
let g = g.clamp(0.0, 1.0) as f32;
let data = a.data.iter().zip(&b.data).map(|(x, y)| std::array::from_fn(|i| g * x[i] + (1.0 - g) * y[i])).collect();
Some(HsvTable { data, ..a.clone() })
}
fn entry(&self, v: usize, h: usize, s: usize) -> [f32; 3] {
self.data.get((v * self.hue_divisions + h) * self.sat_divisions + s).copied().unwrap_or([0.0, 1.0, 1.0])
}
/// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates).
pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] {
let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32);
let h0 = (hf as usize).min(self.hue_divisions - 1);
let h1 = (h0 + 1) % self.hue_divisions;
let th = (hf - h0 as f32).clamp(0.0, 1.0);
let axis = |x: f32, n: usize| {
let f = x.clamp(0.0, 1.0) * (n - 1) as f32;
let i0 = (f as usize).min(n.saturating_sub(2));
let i1 = (i0 + 1).min(n - 1);
(i0, i1, (f - i0 as f32).clamp(0.0, 1.0))
};
let (s0, s1, ts) = axis(s, self.sat_divisions);
let (v0, v1, tv) = if self.val_divisions > 1 { axis(v, self.val_divisions) } else { (0, 0, 0.0) };
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) };
let plane = |vi: usize| {
let lo = lerp(self.entry(vi, h0, s0), self.entry(vi, h0, s1), ts);
let hi = lerp(self.entry(vi, h1, s0), self.entry(vi, h1, s1), ts);
lerp(lo, hi, th)
};
if v0 == v1 { plane(v0) } else { lerp(plane(v0), plane(v1), tv) }
}
/// Apply the table to one linear ProPhoto RGB colour.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let max = rgb[0].max(rgb[1]).max(rgb[2]);
if max.is_nan() || max <= 0.0 || !max.is_finite() {
return rgb;
}
let min = rgb[0].min(rgb[1]).min(rgb[2]);
let d = max - min;
let hue = hue_of(rgb, max, d);
let sat = d / max;
let v_index = if self.srgb_value { linear_to_srgb(max.min(1.0)) } else { max };
let [shift, ss, vs] = self.lookup(hue, sat, v_index);
// saturation and value: scaled and clipped to 1.0 (DNG chapter 6), except that inputs
// already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered.
let s2 = limited(sat, ss);
let v2 = if self.srgb_value && max <= 1.0 {
srgb_to_linear((linear_to_srgb(max) * vs).min(1.0))
} else if self.srgb_value {
max * srgb_to_linear(vs.min(1.0)).min(1.0)
} else {
limited(max, vs)
};
hsv_to_rgb(hue + shift, s2, v2)
}
}
/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1).
fn limited(x: f32, k: f32) -> f32 {
if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) }
}
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
if d <= 0.0 {
return 0.0;
}
let h = if max == c[0] {
((c[1] - c[2]) / d).rem_euclid(6.0)
} else if max == c[1] {
(c[2] - c[0]) / d + 2.0
} else {
(c[0] - c[1]) / d + 4.0
};
h * 60.0
}
/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of
/// [`hue_of`] for out-of-gamut inputs).
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let hp = h.rem_euclid(360.0) / 60.0;
let sector = (hp as u32).min(5);
let f = hp - sector as f32;
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
match sector {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
/// A DNG tone curve (`ProfileToneCurve`): linear-gamma (input, output) pairs, increasing input.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToneCurve {
pub points: Vec<[f32; 2]>,
}
impl ToneCurve {
/// From the tag's flat float pairs. `None` unless there are 2..=65536 finite pairs within
/// 0..=1 with strictly increasing inputs.
pub fn from_tag(v: &[f64]) -> Option<ToneCurve> {
if !v.len().is_multiple_of(2) || v.len() < 4 || v.len() > 131_072 {
return None;
}
let points: Vec<[f32; 2]> = v.as_chunks::<2>().0.iter().map(|p| [p[0] as f32, p[1] as f32]).collect();
let ok = points.iter().all(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))) && points.windows(2).all(|w| w[1][0] > w[0][0]);
ok.then_some(ToneCurve { points })
}
/// The curve at `x` (0..=1): monotone cubic (Fritsch–Carlson) through the samples.
pub fn eval(&self, x: f32) -> f32 {
let p = &self.points;
let n = p.len();
let x = x.clamp(p[0][0], p[n - 1][0]);
let i = p.partition_point(|q| q[0] <= x).clamp(1, n - 1) - 1;
let slope = |k: usize| (p[k + 1][1] - p[k][1]) / (p[k + 1][0] - p[k][0]);
let tangent = |k: usize| -> f32 {
if k == 0 {
return slope(0);
}
if k == n - 1 {
return slope(n - 2);
}
let (a, b) = (slope(k - 1), slope(k));
if a * b <= 0.0 { 0.0 } else { 2.0 / (1.0 / a + 1.0 / b) }
};
let (x0, x1) = (p[i][0], p[i + 1][0]);
let h = x1 - x0;
let t = (x - x0) / h;
let (t2, t3) = (t * t, t * t * t);
let y = (2.0 * t3 - 3.0 * t2 + 1.0) * p[i][1]
+ (t3 - 2.0 * t2 + t) * h * tangent(i)
+ (-2.0 * t3 + 3.0 * t2) * p[i + 1][1]
+ (t3 - t2) * h * tangent(i + 1);
y.clamp(0.0, 1.0)
}
}
/// The DNG profile look tags of a file (all optional).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProfileLook {
/// `ProfileHueSatMapData1/2` (same shape, `ProfileHueSatMapDims`).
pub hue_sat_map: [Option<HsvTable>; 2],
/// `ProfileLookTableData`.
pub look_table: Option<HsvTable>,
/// `ProfileToneCurve`.
pub tone_curve: Option<ToneCurve>,
}
impl ProfileLook {
pub fn is_empty(&self) -> bool {
self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none()
}
/// The hue/saturation map for calibration-1 weight `g` (one table: used for any white).
pub fn hue_sat_for(&self, g: f64) -> Option<HsvTable> {
match &self.hue_sat_map {
[Some(a), Some(b)] => HsvTable::blend(a, b, g).or_else(|| Some(a.clone())),
[Some(a), None] | [None, Some(a)] => Some(a.clone()),
[None, None] => None,
}
}
}
/// The tables resolved for one white balance, applied to linear Rec.2020 D65 pixels.
#[derive(Clone, Debug)]
pub struct ProfileTables {
hue_sat: Option<HsvTable>,
look: Option<HsvTable>,
to_prophoto: [[f32; 3]; 3],
from_prophoto: [[f32; 3]; 3],
}
impl ProfileTables {
/// `None` when the profile has no hue/saturation map and no look table.
pub fn new(look: &ProfileLook, illuminant_weight: f64) -> Option<ProfileTables> {
let hue_sat = look.hue_sat_for(illuminant_weight);
if hue_sat.is_none() && look.look_table.is_none() {
return None;
}
let to: Mat3 = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz());
let from = to.inverse()?;
Some(ProfileTables { hue_sat, look: look.look_table.clone(), to_prophoto: to.to_f32(), from_prophoto: from.to_f32() })
}
/// Hue/saturation map, then `gain` (exposure compensation), then the look table:
/// linear Rec.2020 D65 in, linear Rec.2020 D65 out.
#[inline]
pub fn apply(&self, rgb: [f32; 3], gain: f32) -> [f32; 3] {
let m = &self.to_prophoto;
let mut p: [f32; 3] = std::array::from_fn(|i| m[i][0] * rgb[0] + m[i][1] * rgb[1] + m[i][2] * rgb[2]);
if let Some(t) = &self.hue_sat {
p = t.apply(p);
}
p = p.map(|v| v * gain);
if let Some(t) = &self.look {
p = t.apply(p);
}
let m = &self.from_prophoto;
std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity(h: usize, s: usize, v: usize) -> HsvTable {
HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false }
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn identity_table_is_identity() {
let t = identity(6, 3, 1);
for c in [[0.2, 0.5, 0.1], [0.9, 0.9, 0.9], [0.0, 0.0, 0.4], [3.0, 1.0, 0.5], [0.3, -0.05, 0.2]] {
assert!(close(t.apply(c), c, 1e-5), "{c:?} → {:?}", t.apply(c));
}
assert_eq!(t.apply([0.0; 3]), [0.0; 3]);
assert_eq!(t.apply([-1.0, -2.0, -0.5]), [-1.0, -2.0, -0.5]);
}
#[test]
fn parses_tags_and_rejects_bad_shapes() {
let data: Vec<f64> = (0..4).flat_map(|_| [0.0, 1.0, 1.0]).collect();
assert!(HsvTable::from_tags(&[2, 2, 1], &data, 0).is_some());
assert!(HsvTable::from_tags(&[2, 2], &data, 0).is_none());
assert!(HsvTable::from_tags(&[2, 1, 2], &data, 0).is_none(), "saturation needs ≥ 2 divisions");
assert!(HsvTable::from_tags(&[2, 2, 2], &data, 0).is_none(), "length mismatch");
assert!(HsvTable::from_tags(&[u64::MAX, 2, 2], &data, 0).is_none());
let mut nan = data.clone();
nan[1] = f64::NAN;
assert!(HsvTable::from_tags(&[2, 2, 1], &nan, 0).is_none());
// the sRGB encoding only applies to 3D tables
assert!(!HsvTable::from_tags(&[2, 2, 1], &data, 1).unwrap().srgb_value);
}
#[test]
fn saturation_scale_follows_the_table_layout() {
// 4 hues (0°, 90°, 180°, 270°), 2 saturations; saturation-minor: entry (h, s) at h·2 + s.
let mut t = identity(4, 2, 1);
t.data[1] = [0.0, 1.5, 1.0]; // hue 0°, saturation 1 → ×1.5
let red = [0.5, 0.4, 0.4]; // hue 0°, saturation 0.2
let out = t.apply(red);
// half-way along the saturation axis is ×1.1 (0.2 → 0.22 sat), value kept
let sat = (out[0] - out[1].min(out[2])) / out[0];
assert!((sat - 0.2 * 1.1).abs() < 1e-4, "{out:?}");
assert!((out[0] - 0.5).abs() < 1e-6);
// other hues are untouched (hue 180°)
let cyan = [0.3, 0.4, 0.4];
assert!(close(t.apply(cyan), cyan, 1e-6));
// saturation clips to 1.0
assert!(t.apply([1.0, 0.1, 0.1]).iter().all(|v| *v >= -1e-6));
}
#[test]
fn hue_wraps_and_value_scales() {
// 2 hues: entries at 0° and 180°, hue shift +20° at 0°, value ×0.5 at 180°.
let mut t = identity(2, 2, 1);
t.data = vec![[20.0, 1.0, 1.0], [20.0, 1.0, 1.0], [0.0, 1.0, 0.5], [0.0, 1.0, 0.5]];
// 350° sits between the last sample (180°) and the first (0° = 360°), 10/180 from 360
assert!((t.lookup(350.0, 0.5, 0.5)[0] - 20.0 * (170.0 / 180.0)).abs() < 1e-3);
assert!((t.lookup(180.0, 0.5, 0.5)[2] - 0.5).abs() < 1e-6);
// headroom above 1 only goes down
let bright = [2.0, 2.4, 2.4];
let out = t.apply(bright);
assert!((out[1] - 1.2).abs() < 1e-5, "{out:?}");
let mut up = identity(1, 2, 1);
up.data = vec![[0.0, 1.0, 2.0]; 2];
assert!((up.apply([0.4, 0.4, 0.4])[0] - 0.8).abs() < 1e-6);
assert!((up.apply([0.8, 0.8, 0.8])[0] - 1.0).abs() < 1e-6, "clipped to 1");
assert!((up.apply([3.0, 3.0, 3.0])[0] - 3.0).abs() < 1e-6, "headroom kept");
}
#[test]
fn value_axis_and_srgb_encoding() {
// 1 hue, 2 sats, 2 values: saturation ×2 at value 1 only
let mut t = identity(1, 2, 2);
t.data[3] = [0.0, 2.0, 1.0];
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
let dark = [0.25, 0.2, 0.2]; // V 0.25, S 0.2
// linear: 1/4 of the way → S scale at s=0.2: lerp(1, lerp(1,2,0.2)=1.2, 0.25) = 1.05
assert!((sat(t.apply(dark)) - 0.2 * 1.05).abs() < 1e-4);
t.srgb_value = true;
let k = linear_to_srgb(0.25); // ≈ 0.537
let want = 0.2 * (1.0 + 0.2 * k);
assert!((sat(t.apply(dark)) - want).abs() < 1e-4);
}
#[test]
fn blend_and_tables_round_trip_through_prophoto() {
let a = identity(2, 2, 1);
let mut b = identity(2, 2, 1);
b.data[1] = [10.0, 2.0, 1.0];
let m = HsvTable::blend(&a, &b, 0.25).unwrap();
assert_eq!(m.data[1], [7.5, 1.75, 1.0]);
assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none());
let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None };
let t = ProfileTables::new(&look, 0.5).unwrap();
let c = [0.3, 0.2, 0.1];
assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0));
assert!(ProfileTables::new(&ProfileLook::default(), 0.5).is_none());
}
#[test]
fn tone_curve_interpolates_monotonically() {
assert!(ToneCurve::from_tag(&[0.0, 0.0]).is_none());
assert!(ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.6, 0.4, 0.7, 1.0, 1.0]).is_none(), "inputs must increase");
assert!(ToneCurve::from_tag(&[0.0, 0.0, 1.0, 1.5]).is_none());
let c = ToneCurve::from_tag(&[0.0, 0.0, 0.25, 0.4, 0.5, 0.7, 1.0, 1.0]).unwrap();
assert_eq!(c.eval(0.0), 0.0);
assert!((c.eval(0.25) - 0.4).abs() < 1e-6);
assert!((c.eval(1.0) - 1.0).abs() < 1e-6);
let mut last = 0.0;
for i in 0..=100 {
let y = c.eval(i as f32 / 100.0);
assert!(y >= last - 1e-6, "monotone at {i}");
last = y;
}
assert!(c.eval(0.1) > 0.1 && c.eval(0.1) < 0.4);
}
}
+37
View File
@@ -193,3 +193,40 @@ fn corpus_nef_compressed_matches_uncompressed() {
assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}");
}
}
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
/// look table; we read them (and render with them). Camera-written DNGs here carry none.
#[test]
fn corpus_adobe_dngs_carry_profile_looks() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut seen = 0;
for p in rd.flatten().map(|e| e.path()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
if !name.starts_with("dng-") || !name.ends_with(".dng") {
continue;
}
let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}"));
let look = &info.color.profile;
if name.starts_with("dng-adobe-") {
seen += 1;
let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map"));
assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}");
assert!(look.look_table.is_some(), "{name}: no look table");
// a profile applied to a mid grey keeps it (close to) neutral
let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap();
let g = t.apply([0.18; 3], 1.0);
assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}");
}
eprintln!(
"{name:44} profile look: hsm {} look {} tone {}",
look.hue_sat_map[0].is_some(),
look.look_table.is_some(),
look.tone_curve.is_some()
);
}
eprintln!("{seen} Adobe-converted DNGs checked");
}
+71
View File
@@ -182,6 +182,11 @@ fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder
}
fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec<u8> {
dng_with(raw, order, |_| {})
}
/// [`dng`] with extra IFD 0 tags (where DNG writers put camera-profile tags).
fn dng_with(raw: IfdBuilder, order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
@@ -200,6 +205,7 @@ fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec<u8> {
exif.set(t::ISO_SPEED, Value::Short(vec![200]));
ifd0.set_child(t::EXIF_IFD, exif);
ifd0.add_sub_ifd(raw);
extra(&mut ifd0);
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
@@ -461,3 +467,68 @@ fn rejects_unsupported_and_broken() {
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 128]] });
assert!(decode(&dng(raw, ByteOrder::Little)).is_err());
}
/// A Lightroom-style DNG: profile look tags in IFD 0, LinearRaw data in a SubIFD (issue #138).
fn profile_dng(order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
let (w, h) = (8, 6);
let px = pattern(w, h, 3, 16);
let mut raw = base_ifd(w, h, 16, 3, false);
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] });
dng_with(raw, order, extra)
}
fn hsv_floats(h: usize, s: usize, v: usize, f: impl Fn(usize, usize, usize) -> [f32; 3]) -> Vec<f32> {
(0..v).flat_map(|vi| (0..h).flat_map(move |hi| (0..s).map(move |si| (vi, hi, si)))).flat_map(|(vi, hi, si)| f(vi, hi, si)).collect()
}
#[test]
fn profile_look_tags_are_read_from_ifd0() {
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes = profile_dng(order, |ifd0| {
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(hsv_floats(6, 3, 1, |_, h, s| [h as f32, 1.0 + s as f32 * 0.1, 1.0])));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_2, Value::Float(hsv_floats(6, 3, 1, |_, _, _| [0.0, 1.0, 1.0])));
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![4, 2, 3]));
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(hsv_floats(4, 2, 3, |v, _, _| [0.0, 1.0, 1.0 - v as f32 * 0.05])));
ifd0.set(t::PROFILE_LOOK_TABLE_ENCODING, Value::Long(vec![1]));
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.25, 0.15, 0.5, 0.55, 1.0, 1.0]));
});
let img = decode(&bytes).unwrap();
assert_eq!(img.cpp, 3);
let p = &img.color.profile;
let hsm = p.hue_sat_map[0].as_ref().unwrap();
assert_eq!((hsm.hue_divisions, hsm.sat_divisions, hsm.val_divisions), (6, 3, 1));
// value-major, hue-middle, saturation-minor: entry (h = 2, s = 1) is at 2·3 + 1
assert_eq!(hsm.data[7], [2.0, 1.1, 1.0]);
assert!(p.hue_sat_map[1].is_some());
let look = p.look_table.as_ref().unwrap();
assert_eq!((look.hue_divisions, look.sat_divisions, look.val_divisions, look.srgb_value), (4, 2, 3, true));
assert_eq!(look.data[2 * 4 * 2], [0.0, 1.0, 0.9]);
assert_eq!(p.tone_curve.as_ref().unwrap().points.len(), 4);
// the header-only probe sees the same profile
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap().color.profile, *p);
// our DNG writer keeps the look with the data (conversions, smart previews)
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
assert_eq!(again.color.profile, *p);
}
}
#[test]
fn malformed_profile_look_tags_are_ignored() {
let bytes = profile_dng(ByteOrder::Little, |ifd0| {
// data count doesn't match the dimensions
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(vec![0.0, 1.0, 1.0]));
// one saturation division is not allowed
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![2, 1, 1]));
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0]));
// decreasing inputs and a NaN
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.6, 0.5, 0.4, f32::NAN, 1.0, 1.0]));
});
let img = decode(&bytes).unwrap();
assert!(img.color.profile.is_empty(), "{:?}", img.color.profile);
// and without any of the tags there is nothing to apply
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
assert!(plain.color.profile.is_empty());
}
+9
View File
@@ -152,6 +152,15 @@ pub const CAMERA_CALIBRATION_SIGNATURE: u16 = 50931;
pub const PROFILE_CALIBRATION_SIGNATURE: u16 = 50932;
pub const AS_SHOT_PROFILE_NAME: u16 = 50934;
pub const PROFILE_NAME: u16 = 50936;
pub const PROFILE_HUE_SAT_MAP_DIMS: u16 = 50937;
pub const PROFILE_HUE_SAT_MAP_DATA_1: u16 = 50938;
pub const PROFILE_HUE_SAT_MAP_DATA_2: u16 = 50939;
pub const PROFILE_TONE_CURVE: u16 = 50940;
pub const PROFILE_EMBED_POLICY: u16 = 50941;
pub const PROFILE_LOOK_TABLE_DIMS: u16 = 50981;
pub const PROFILE_LOOK_TABLE_DATA: u16 = 50982;
pub const PROFILE_HUE_SAT_MAP_ENCODING: u16 = 51107;
pub const PROFILE_LOOK_TABLE_ENCODING: u16 = 51108;
pub const BASELINE_EXPOSURE_OFFSET: u16 = 51109;
pub const DEFAULT_BLACK_RENDER: u16 = 51110;
pub const FORWARD_MATRIX_1: u16 = 50964;
+3
View File
@@ -25,3 +25,6 @@ log = { workspace = true }
[dev-dependencies]
lightcraft-codecs = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+18
View File
@@ -0,0 +1,18 @@
//! Compile translated format strings so Rust checks both languages' placeholders.
use std::{collections::BTreeMap, env, fs, path::PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=locales/ja-formats.json");
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
let mut source = String::from("macro_rules! tr_format {\n");
for (english, japanese) in messages {
let en = serde_json::to_string(&english)?;
let ja = serde_json::to_string(&japanese)?;
source.push_str(&format!(
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
));
}
source.push_str("}\npub(crate) use tr_format;\n");
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?;
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
{
" · {skipped} skipped (file exists)": "・{skipped}件は同名ファイルのためスキップ",
" · {} duplicate{} skipped": "・重複{}枚をスキップ{:.0}",
" · {} failed: {}": "・{}件失敗:{}",
" · {} moved": "・{}件移動",
" · {} not readable": "・{}件は読み込み不可",
", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}",
"Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加",
"Add {} Photo{}": "{}枚の写真を追加{:.0}",
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
"Automatic ({} MB)": "自動({}MB)",
"Click {}:1": "クリック時 {}:1",
"Create {n} Virtual Copies": "{n}枚の仮想コピーを作成",
"Decoder: {reason}": "デコーダー:{reason}",
"Delete \"{label}\"": "「{label}」を削除",
"Delete \"{label}\" (and the mask)": "「{label}」とマスクを削除",
"Delete {n} Photos": "{n}枚の写真を削除",
"Delete “{current}”": "「{current}」を削除",
"Duplicate \"{label}\"": "「{label}」を複製",
"Example: {example}": "例:{example}",
"Exported {n} photo{}": "{n}枚を書き出しました{:.0}",
"Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",
"Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}",
"Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}",
"Exporting {} of {total}": "書き出し中:{total}枚中{}枚",
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}",
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}",
"Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}",
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
"Imported {} photo{}": "{}枚を読み込みました{:.0}",
"Matches: {}": "一致条件:{}",
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
"Named ({named_n})": "名前付き({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",
"Preset: {name}": "プリセット:{name}",
"Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})",
"Preview only — {}": "プレビューのみ:{}",
"Profile: {}": "プロファイル:{}",
"Proof Copy ({})": "校正用コピー({})",
"Proof Preview · {}": "校正プレビュー・{}",
"Quick Develop · {n} photos": "クイック現像・{n}枚",
"Rated {}": "評価:{}",
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
"Redo {}": "やり直す:{}",
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
"Rename “{name}”": "「{name}」の名前を変更",
"Rendering on the CPU: {why}": "CPUで描画:{why}",
"Reset {} Channel": "{}チャンネルをリセット",
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
"Showing {n} of {}": "{}枚中{n}枚を表示",
"Smart album: {}": "スマートアルバム:{}",
"Undo {}": "取り消す:{}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ",
"Version: {}": "バージョン:{}",
"none (CPU rendering): {why}": "なし(CPU描画):{why}",
"the {n} selected photos": "選択中の{n}枚",
"the {} photos in view": "表示中の{}枚",
"these {count} photos": "この{count}枚の写真",
"{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚{:.0}・{:.1}MB",
"{f} folders (and their subfolders)": "{f}個のフォルダとそのサブフォルダ",
"{label} Grading": "{label}のカラーグレーディング",
"{label} {d:+} on every selected photo": "選択したすべての写真の{label}を{d:+}調整",
"{label}, {n} photos": "{label}、{n}枚",
"{matching} of {t} photos": "{t}枚中{matching}枚",
"{matching} photos": "{matching}枚",
"{mode} spot {} of {}": "{mode}:{1}か所中{0}か所",
"{msg} ({failed} failed)": "{msg}({failed}件失敗)",
"{name} Label": "{name}のラベル",
"{name} Off": "{name}を無効にする",
"{name} On": "{name}を有効にする",
"{name} — double-click to reset it": "{name}:ダブルクリックでリセット",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更{:.0}をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます{:.0}",
"{n} found · {sel} selected": "{n}枚検出・{sel}枚選択",
"{n} photos": "{n}枚",
"{n} photos in {} are hidden by the filters above": "{}の{n}枚は上のフィルターで非表示になっています",
"{n} photo{}": "{n}枚{:.0}",
"{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新",
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{what} merge added": "{what}合成を追加しました",
"{what} merge failed: {e}": "{what}合成に失敗:{e}",
"{} and higher": "{}以上",
"{} files": "{}個のファイル",
"{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ",
"{} photos": "{}枚",
"{} photo{} not readable": "{}枚を読み込めません{:.0}",
"{} spot(s) on this photo": "この写真に{}か所のスポット",
"· {dups} already in the library": "・{dups}枚は読み込み済み",
"· {n} photo{}": "・{n}枚{:.0}",
"… and {more} more": "…ほか{more}件",
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -88,6 +88,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"libraryProblem": app.library_problem.as_ref().map(crate::panels::library_problem::LibraryProblem::to_json),
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
"import": app.import.as_ref().map(crate::import::ImportTask::status),
"tasks": app.tasks.labels(),
"memory": memory(app),
})
}
+9 -5
View File
@@ -75,12 +75,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let ok = files.iter().filter(|f| f.get("path").is_some()).count();
let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();
let mut m = format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" });
let mut m =
crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);
if skipped > 0 {
m += &format!(" · {skipped} skipped (file exists)");
m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);
}
if let Some(first) = failed.first() {
m += &format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
}
if cancelled {
m += " · cancelled";
@@ -96,14 +97,17 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
let (total, cancel) = (task.total, task.cancel.clone());
let t = Tokens::get(ctx);
egui::Window::new("Exporting")
egui::Window::new(crate::i18n::tr("Exporting"))
.title_bar(false)
.resizable(false)
.anchor(Align2::LEFT_BOTTOM, [16.0, -56.0])
.fixed_size([300.0, 64.0])
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("Exporting {} of {total}", (done + 1).min(total))).color(t.text));
ui.label(
egui::RichText::new(crate::i18n::tr_format!("Exporting {} of {total}", (done + 1).min(total), total = total))
.color(t.text),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let stopping = cancel.load(Ordering::Relaxed);
if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() {
+10
View File
@@ -176,6 +176,7 @@ impl Headless {
|| self.app.scan.is_some()
|| self.app.import.is_some()
|| self.app.export.is_some()
|| !self.app.tasks.is_empty()
|| !self.app.synthetic.is_empty()
|| !self.events.is_empty()
}
@@ -412,6 +413,15 @@ mod tests {
assert_eq!(after.develop, before.develop);
assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering");
assert!(h.app.session.undo.is_empty());
// hovering down across group headers within the presets list retains the preview (no flicker)
h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W"));
// hovering another preset switches smoothly to it
h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t);
h.settle(SETTLE);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow"));
// thumbnails: variant textures for the visible presets
h.app.ui.preset_thumbs = true;
h.request("ui.move", json!({"x": 5, "y": 500}), t);
+125 -117
View File
@@ -1,24 +1,27 @@
//! Interface translations. Command ids, document text and file names remain stable.
//! Untranslated labels fall back to English so coverage can grow incrementally.
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
use serde::{Deserialize, Serialize};
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Language {
#[default]
#[serde(rename = "en")]
En,
#[serde(rename = "ja")]
Ja,
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
@@ -26,129 +29,134 @@ impl Language {
_ => None,
}
}
pub fn tr(self, text: &str) -> &str {
if self == Self::Ja
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
{
return japanese;
}
text
pub fn tr(self, source: &str) -> &str {
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
}
}
const JAPANESE: &[(&str, &str)] = &[
("Photo", "写真"),
("Import Photos…", "写真を読み込み…"),
("Detail", "詳細"),
("Photo Grid", "写真グリッド"),
("Square Grid", "正方形グリッド"),
("Object", "オブジェクト"),
("Effect", "効果"),
("Settings…", "環境設定…"),
("Image", "画像"),
("Layer", "レイヤー"),
("Type", "書式"),
("Select", "選択"),
("Filter", "フィルター"),
("Window", "ウィンドウ"),
("Language", "表示言語"),
("Save As…", "別名で保存…"),
("Exit", "終了"),
("New…", "新規…"),
("New", "新規"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Orientation", "組み方向"),
("Layers", "レイヤー"),
("History", "履歴"),
("Properties", "プロパティ"),
("Color", "カラー"),
("Brush Settings", "ブラシ設定"),
("Tools", "ツール"),
("Options", "オプション"),
("Zoom In", "ズームイン"),
("Zoom Out", "ズームアウト"),
("Fit on Screen", "画面に合わせる"),
("Copy", "コピー"),
("Cut", "切り取り"),
("Paste", "貼り付け"),
("Select All", "すべて選択"),
("Deselect", "選択を解除"),
("Export", "書き出し"),
("Export As…", "形式を指定して書き出し…"),
("Search…", "検索…"),
("Theme", "テーマ"),
("Menu", "メニュー"),
("File", "ファイル"),
("Edit", "編集"),
("Pages", "ページ"),
("View", "表示"),
("Help", "ヘルプ"),
("Preferences", "環境設定"),
("Preferences…", "環境設定…"),
("Interface language", "表示言語"),
("Open…", "開く…"),
("New blank PDF", "空白の PDF を作成"),
("Create PDF from file…", "ファイルから PDF を作成…"),
("Create PDF from images…", "画像から PDF を作成…"),
("Create PDF from clipboard", "クリップボードから PDF を作成"),
("Combine files…", "ファイルを結合…"),
("Save", "保存"),
("Save as…", "別名で保存…"),
("Close file", "ファイルを閉じる"),
("Close all", "すべて閉じる"),
("Revert", "保存済みの状態に戻す"),
("Print…", "印刷…"),
("Document properties…", "文書のプロパティ…"),
("Undo", "取り消し"),
("Redo", "やり直し"),
("Find…", "検索…"),
("Advanced search…", "高度な検索…"),
("Copy pages", "ページをコピー"),
("Cut pages", "ページを切り取り"),
("Paste pages", "ページを貼り付け"),
("Fit visible", "表示範囲に合わせる"),
("Marquee zoom", "範囲指定ズーム"),
("Take a snapshot", "スナップショットを作成"),
("Full screen mode", "全画面表示"),
("Read mode", "閲覧モード"),
("Switch light / dark theme", "明るい/暗いテーマを切り替え"),
("Comments panel", "コメントパネル"),
("Form fields panel", "フォームフィールドパネル"),
("Clear form", "フォームをクリア"),
("Find tools and commands…", "ツールとコマンドを検索…"),
("Zoom", "ズーム"),
("Actual size", "実際のサイズ"),
("Zoom to page level", "ページ全体を表示"),
("Fit to width", "幅に合わせる"),
("Display theme", "表示テーマ"),
("Side panels", "サイドパネル"),
("OK", "OK"),
];
thread_local! {
static LANGUAGE: Cell<Language> = const { Cell::new(Language::En) };
}
pub fn default_language() -> Language {
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
}
pub fn is_japanese() -> bool {
LANGUAGE.with(|value| value.get() == Language::Ja)
}
fn japanese() -> &'static BTreeMap<String, String> {
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
MESSAGES.get_or_init(|| {
serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| {
log::error!("Invalid Japanese message catalog: {error}");
BTreeMap::new()
})
})
}
/// Translate a built-in display label, preserving unknown labels verbatim.
/// Never call this on editable user text, filenames or command identifiers.
pub fn tr(source: &str) -> &str {
if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_setting_persists() {
let state = crate::state::UiState { language: Language::Ja, ..Default::default() };
let saved = serde_json::to_string(&state).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
assert_eq!(crate::state::UiState::default().language, Language::En);
fn catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}");
}
}
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
fn language_switches_and_unknown_text_survives() {
set_language(Language::Ja);
assert_eq!(tr("Exposure"), "露出");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Language::En);
assert_eq!(tr("Exposure"), "Exposure");
}
#[test]
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
set_language(Language::Ja);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
set_language(Language::En);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
}
#[test]
fn preferences_round_trip_and_old_settings_remain_readable() {
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
assert_eq!(old.language, Language::En);
let settings = crate::state::UiState { language: Language::Ja, ..old };
let saved = serde_json::to_string(&settings).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
}
#[test]
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::Ja;
app.ui.left_panel = true;
let mut text = String::new();
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
match shape {
egui::epaint::Shape::Text(shape) => {
text.push_str(&shape.galley.job.text);
text.push('\n');
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)),
_ => {}
}
}
assert_eq!(Language::Ja.tr("File"), "ファイル");
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
assert_eq!(Language::parse("xx"), None);
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
// This assertion inspects shapes without a renderer; discard texture uploads explicitly.
out.textures_delta.clear();
text.clear();
for shape in out.shapes {
collect(&shape.shape, &mut text);
}
}
assert!(text.contains("マイフォト"), "{text}");
assert!(text.contains("すべての写真"), "{text}");
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for message in japanese().values() {
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
}
}
}
});
// Locale affects presentation only: command ids remain the same.
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let japanese_ids = ids(&app);
set_language(Language::En);
assert_eq!(japanese_ids, ids(&app));
}
}
+334 -74
View File
@@ -3,8 +3,9 @@
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
//! place / copy or move into the library's `Originals/` or a chosen folder, filed by day, by month,
//! into one folder or by a custom folder template, optionally renamed), an album
//! (existing or new), a preset and keywords to apply. Importing runs in small batches, one per
//! frame, with a progress window; the whole import is one undo step.
//! (existing or new), a preset and keywords to apply. Importing runs on a worker thread (files are
//! probed, copied or moved there) and its batches join the catalog between frames, with a progress
//! window and Cancel; the whole import is one undo step.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
@@ -17,7 +18,7 @@ use crate::render::Slot;
use crate::theme::Tokens;
use crate::widgets::register;
/// Files per batch (one batch per frame, so the progress window updates).
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
pub(crate) const BATCH: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -91,9 +92,13 @@ impl ImportDialog {
}
}
/// A running import (see the module docs).
/// A running import (see the module docs). The file-system work — expanding folders, probing,
/// reading sidecars, copying or placing files — runs on a worker thread
/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the
/// UI thread between frames, so a slow drive never stalls the window.
#[derive(Debug, Default)]
pub struct ImportTask {
/// Files and folders to import (handed to the worker when the task starts).
queue: Vec<String>,
pub total: usize,
pub done: usize,
@@ -109,6 +114,156 @@ pub struct ImportTask {
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
/// selected or announced as added.
browse: bool,
/// Cancel was pressed: no further files are started; batches already readied are added.
pub cancelled: bool,
/// Auto Import (the watched folder): the selection stays as it is, a short toast when done.
auto: bool,
/// Auto Import: the selection to keep.
keep_selection: Option<lightcraft_engine::Selection>,
run: Option<ImportRun>,
}
impl ImportTask {
/// An import of `paths` (files or folders) with `library.import` params (without `paths`).
pub fn new(paths: Vec<String>, params: Value, undo0: usize, browse: bool) -> Self {
ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }
}
/// An Auto Import (see [`ImportTask::auto`]).
pub fn auto(mut self) -> Self {
self.auto = true;
self
}
/// `{done, total, imported, cancelled}` for `ui.inspect`.
pub fn status(&self) -> Value {
json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled})
}
}
/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no
/// slow drives) inline, one batch per frame.
enum Runner {
#[cfg(not(target_arch = "wasm32"))]
Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),
#[cfg(target_arch = "wasm32")]
Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> },
}
struct ImportRun {
runner: Runner,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
opts: lightcraft_engine::import::ImportOptions,
now: String,
album: Option<u64>,
album_name: Option<String>,
}
impl std::fmt::Debug for ImportRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish()
}
}
impl Drop for ImportRun {
/// A dropped import (replaced, or the app closing) stops its worker at the next file.
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl ImportRun {
fn start(
job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
album: Option<u64>,
album_name: Option<String>,
ctx: &egui::Context,
) -> Result<Self, String> {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (total, done) = (job.total.clone(), job.done.clone());
let (opts, now) = (job.opts.clone(), job.now().to_string());
let runner = Self::spawn(job, queue, cancel.clone(), ctx)?;
Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name })
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(
mut job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
ctx: &egui::Context,
) -> Result<Runner, String> {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let work = move || {
let files = job.expand(&queue);
for chunk in files.chunks(BATCH) {
if cancel.load(Ordering::Relaxed) {
break;
}
let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {
Ok(p) => p,
Err(_) => break, // logged; the files readied so far are added
};
if let Err(unsent) = tx.send(prepared) {
// the import was dropped: nothing will add these, so take back what a Move placed
unsent.0.rollback();
break;
}
ctx.request_repaint();
}
ctx.request_repaint();
};
std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))
}
#[cfg(target_arch = "wasm32")]
fn spawn(
job: lightcraft_engine::import::ImportJob,
queue: Vec<String>,
_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
_ctx: &egui::Context,
) -> Result<Runner, String> {
Ok(Runner::Inline { job: Box::new(job), queue, files: None })
}
/// Readied batches (at most a few per frame). `None` once the worker is done.
fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {
match &mut self.runner {
#[cfg(not(target_arch = "wasm32"))]
Runner::Thread(rx) => {
let mut out = Vec::new();
while out.len() < 4 {
match rx.try_recv() {
Ok(p) => out.push(p),
Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out),
Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) },
}
}
Some(out)
}
#[cfg(target_arch = "wasm32")]
Runner::Inline { job, queue, files } => {
if self.cancel.load(Ordering::Relaxed) {
return None;
}
if files.is_none() {
*files = Some(job.expand(queue).into());
return Some(Vec::new());
}
let Some(files) = files.as_mut() else { return None };
if files.is_empty() {
return None;
}
let n = files.len().min(BATCH);
let batch: Vec<String> = files.drain(..n).collect();
Some(vec![job.prepare_files(batch, &self.cancel)])
}
}
}
}
/// A folder scan running on a worker thread (a network share can take minutes to read; the
@@ -232,7 +387,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
if !queue.is_empty() {
let undo0 = app.session.undo.len();
let total = queue.len();
app.import = Some(ImportTask { queue, total, params: json!({"mode": "add", "local": true}), undo0, browse: true, ..Default::default() });
let _ = total;
app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));
}
return;
}
@@ -256,19 +412,21 @@ pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) {
let text = if total == 0 {
if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string()
} else {
format!("Reading photos… {done} of {total}")
crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total)
};
let mut cancel = false;
egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show(
ctx,
|ui| {
egui::Window::new(crate::i18n::tr("Scanning"))
.title_bar(false)
.resizable(false)
.anchor(Align2::CENTER_BOTTOM, [0.0, -80.0])
.fixed_size([340.0, 80.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0));
let r = ui.button("Cancel");
let r = ui.button(crate::i18n::tr("Cancel"));
register(ui.ctx(), "button:scanCancel", r.rect);
cancel = r.clicked();
},
);
});
if cancel {
// the worker stops at its next file; don't wait for it (a NAS read can take a while)
task.progress.cancel.store(true, Ordering::Relaxed);
@@ -326,28 +484,82 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
}
}
let total = queue.len();
app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() });
app.import = Some(ImportTask::new(queue, params, undo0, false));
app.renderer.forget_imports();
Ok(json!({"importing": total}))
}
/// Run one batch of the import in progress (called every frame).
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = app.import.as_mut() else { return };
// (browsing reads a sidecar per file, slow on a network share: keep each frame short)
let n = task.queue.len().min(if task.browse { 2 } else { BATCH });
let batch: Vec<String> = task.queue.drain(..n).collect();
let mut p = task.params.clone();
p["paths"] = json!(batch);
// renamed copies keep counting across batches
if p.get("rename").is_some() {
p["renameStart"] = json!(1 + task.imported);
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) {
return Err("an import is running".into());
}
let r = app.session.execute("library.import", &p);
let Some(task) = app.import.as_mut() else { return };
task.done += n;
let undo0 = app.session.undo.len();
let total = paths.len();
app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));
Ok(json!({"importing": total}))
}
/// Advance the import in progress (called every frame): start its worker, then add the batches it
/// has readied to the catalog.
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(mut task) = app.import.take() else { return };
if task.run.is_none() {
let mut p = task.params.clone();
p["paths"] = json!(task.queue);
let started = lightcraft_engine::cmd::library::import_params(&app.session, &p)
.and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name)))
.map_err(|e| e.to_string())
.and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx));
if task.auto {
task.keep_selection = Some(app.session.selection.clone());
}
match started {
Ok(run) => task.run = Some(run),
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, format!("Import failed: {e}"));
return;
}
}
}
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let Some(run) = task.run.as_mut() else { return };
let batches = run.next_batches();
let finished = batches.is_none();
for prepared in batches.into_iter().flatten() {
commit_batch(app, &mut task, prepared);
// arriving photos don't take over the selection
if let Some(sel) = &task.keep_selection {
app.session.selection = sel.clone();
}
}
if let Some(run) = &task.run {
task.total = run.total.load(Ordering::Relaxed).max(task.total);
task.done = run.done.load(Ordering::Relaxed);
}
if !finished {
app.import = Some(task);
return;
}
finish(app, ctx, task);
}
/// Add one readied batch to the catalog (an undo step merged into the import's at the end).
fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) {
let Some(run) = task.run.as_ref() else { return };
let n = prepared.len();
let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone());
let r = app.session.execute_fn("library.import", |s| {
let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?;
lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref())
});
match r {
Ok(v) => {
// a new album (named in the params) is created with the first photos; later batches join it
if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) {
run.album = Some(a);
}
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
task.imported += len("imported");
task.duplicates += len("duplicates");
@@ -366,17 +578,22 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
task.failed += n;
}
}
ctx.request_repaint();
if !task.queue.is_empty() {
}
/// The import is done (or cancelled): one undo step, select the first photo, say what happened.
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
let steps = app.session.undo.len().saturating_sub(task.undo0);
let label = crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
app.session.merge_undo(steps, &label);
if task.auto {
if task.imported > 0 {
app.toast(ctx, format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
}
return;
}
let Some(task) = app.import.take() else { return };
let steps = app.session.undo.len().saturating_sub(task.undo0);
let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
app.session.merge_undo(steps, &label);
if task.browse {
if task.failed > 0 {
app.toast(ctx, format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
}
return;
}
@@ -384,35 +601,58 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let _ = app.run("library.select", json!({"ids": [f]}));
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
let mut msg = format!("Imported {} photo{}", task.imported, plural(task.imported));
let mut msg = if task.cancelled {
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
} else {
crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))
};
if task.params["mode"] == "move" {
msg.push_str(&format!(" · {} moved", task.moved));
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
if task.kept > 0 {
msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
}
}
if task.duplicates > 0 {
msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
}
if task.failed > 0 {
msg.push_str(&format!(" · {} not readable", task.failed));
msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
}
app.toast(ctx, msg);
}
/// The progress window while an import runs.
/// The progress window while an import runs, with Cancel (files already copied or added stay;
/// nothing new is started).
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.import else { return };
let t = Tokens::get(ctx);
let frac = task.done as f32 / task.total.max(1) as f32;
let text = format!("{} photos… {} of {}", if task.browse { "Reading" } else { "Adding" }, task.done, task.total);
egui::Window::new("Importing").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 60.0]).show(
ctx,
|ui| {
let text = if task.cancelled {
crate::i18n::tr("Stopping…").to_string()
} else if task.browse {
crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total)
} else {
crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total)
};
let mut cancel = false;
egui::Window::new(crate::i18n::tr("Importing"))
.title_bar(false)
.resizable(false)
.anchor(Align2::CENTER_BOTTOM, [0.0, -80.0])
.fixed_size([340.0, 80.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
},
);
let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel")));
register(ui.ctx(), "button:importCancel", r.rect);
cancel = r.clicked();
});
if cancel && let Some(task) = app.import.as_mut() {
task.cancelled = true;
if let Some(run) = &task.run {
run.cancel.store(true, Ordering::Relaxed);
}
}
}
/// The dialog body: options, then the candidate grid.
@@ -422,15 +662,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count();
let sel = d.selected_paths().len();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("{n} found · {sel} selected")).color(t.text));
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text));
if dups > 0 {
ui.label(egui::RichText::new(format!("· {dups} already in the library")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::text_button(ui, "importNone", "Uncheck All", false).clicked() {
if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() {
d.checked.iter_mut().for_each(|c| *c = false);
}
if crate::widgets::text_button(ui, "importAll", "Check All", false).clicked() {
if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() {
for i in 0..n {
d.checked[i] = d.importable(i);
}
@@ -458,29 +698,42 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
// options
if !d.sources.is_empty() {
field(ui, "Source", |ui| {
let r = ui.label(egui::RichText::new(source_summary(&d.sources)).color(t.text_label)).on_hover_text(d.sources.join("\n"));
// (whether each source is a folder is checked off the UI thread, once)
let joined = d.sources.join("\n");
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
source_summary(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
})
.unwrap_or_else(|| format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
register(ui.ctx(), "label:importSource", r.rect);
});
ui.label(
egui::RichText::new("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")
egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing)."))
.color(t.text_dim)
.small(),
);
}
field(ui, "Transfer", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy)
.on_hover_text(crate::i18n::tr("Reference the files where they are"))
.clicked()
{
d.copy = false;
d.move_files = false;
}
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy && !d.move_files)).inner;
if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() {
let r =
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner;
if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() {
d.copy = true;
d.move_files = false;
}
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", "Move", d.copy && d.move_files)).inner;
if r.on_hover_text("Move the files (into the library's Originals/, or a folder you choose), removing them from the source").clicked() {
let r =
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner;
if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source"))
.clicked()
{
d.copy = true;
d.move_files = true;
}
@@ -505,12 +758,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked()
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
{
d.destination = f;
}
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() {
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() {
d.destination.clear();
}
});
@@ -557,14 +810,16 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
None => unknown_tags_warning(ui, &d.folder_template),
}
ui.label(
egui::RichText::new("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").")
.color(t.text_dim)
.small(),
egui::RichText::new(crate::i18n::tr(
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").",
))
.color(t.text_dim)
.small(),
);
}
if !d.move_files {
field(ui, "Raw files", |ui| {
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG"));
register(ui.ctx(), "check:importDng", r.rect);
});
}
@@ -582,10 +837,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
unknown_tags_warning(ui, &d.rename);
if let Some(example) = example_destination(app, d) {
let t = Tokens::get(ui.ctx());
let r = ui.label(egui::RichText::new(format!("Example: {example}")).color(t.text_dim));
let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim));
register(ui.ctx(), "label:importExample", r.rect);
ui.label(
egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(),
egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date."))
.color(t.text_dim)
.small(),
);
}
}
@@ -602,11 +859,11 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
None => "None".into(),
};
egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), "None").clicked() {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
d.album = None;
d.new_album.clear();
}
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), "New album…").clicked() {
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
d.album = None;
if d.new_album.is_empty() {
d.new_album = "Imported Photos".into();
@@ -627,7 +884,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
field(ui, "Preset", |ui| {
let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into());
egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
if ui.selectable_label(d.preset.is_empty(), "None").clicked() {
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.preset.clear();
}
for p in &app.session.presets {
@@ -641,7 +898,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() {
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.metadata_preset.clear();
}
for m in &app.session.metadata_presets {
@@ -653,7 +910,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
}
field(ui, "Keywords", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importKeywords", r.rect);
});
}
@@ -726,7 +983,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
let id = egui::Id::new((key, "tags-open"));
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("Show the template tags; click one to insert it");
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open)
.on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it"));
if r.clicked() {
open = !open;
ui.ctx().data_mut(|d| d.insert_temp(id, open));
@@ -770,7 +1028,9 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
let t = Tokens::get(ui.ctx());
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
ui.label(
egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(),
egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48."))
.color(t.text_dim)
.small(),
);
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
@@ -848,9 +1108,9 @@ pub fn source_summary(sources: &[String]) -> String {
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => format!("{} files", many.len()),
f if f == many.len() => format!("{f} folders (and their subfolders)"),
f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }),
0 => crate::i18n::tr_format!("{} files", many.len()),
f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f),
}
}
}
+66 -13
View File
@@ -21,6 +21,7 @@ pub mod render;
pub mod shortcuts;
pub mod softpaint;
pub mod state;
pub mod tasks;
pub mod theme;
pub mod widgets;
@@ -33,6 +34,8 @@ mod tests_library_problem;
#[cfg(test)]
mod tests_masking;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_quit_unsaved;
@@ -64,6 +67,9 @@ pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a file in an application (`app` = "" for the system's default one).
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
/// Something the host does with the session (web: back up or restore the library in browser
/// storage); the work may finish asynchronously.
pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
@@ -93,6 +99,12 @@ pub struct Services {
pub open_url: Option<OpenUrlFn>,
/// Open a file in an external editor (Edit in External Editor; desktop only).
pub open_with: Option<OpenWithFn>,
/// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the
/// user keeps (web only: there the library lives in browser storage, which the browser may
/// clear; on the desktop it is a folder backed up like any other).
pub backup_library: Option<HostAction>,
/// File ▸ Restore Library from Backup… (web only; keeps the current library).
pub restore_library: Option<HostAction>,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -169,6 +181,8 @@ pub struct LightcraftApp {
pub scan: Option<import::ScanTask>,
/// A background export in progress.
pub export: Option<export_task::ExportTask>,
/// Background file-system work of other commands (Find Missing Photos, auto import…).
pub tasks: tasks::Tasks,
/// The files of the last finished background export (`ui.inspect` → `export.last`).
pub last_export_result: Option<Value>,
/// The look the loupe shows while the pointer rests on a preset or profile (set by the
@@ -187,8 +201,6 @@ pub struct LightcraftApp {
impl LightcraftApp {
pub fn new(session: Session, services: Services) -> Self {
// GPU device + kernels off the UI thread, before the first photo is opened
lightcraft_engine::gpu::warm_up();
Self {
session,
ui: UiState::default(),
@@ -224,6 +236,7 @@ impl LightcraftApp {
import: None,
scan: None,
export: None,
tasks: Default::default(),
last_export_result: None,
hover_preview: None,
window_is_fullscreen: false,
@@ -286,7 +299,12 @@ impl LightcraftApp {
if self.ui.preview_build_seen != Some((key, true)) {
self.ui.preview_build_seen = Some((key, true));
let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed));
let mut msg = format!("Previews ready for {done} photo{}", if done == 1 { "" } else { "s" });
let plural = if done == 1 { "" } else { "s" };
let mut msg = match (b.error(), b.what) {
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
(None, "") => format!("Previews ready for {done} photo{plural}"),
(None, what) => format!("Done ({what}): {done} photo{plural}"),
};
if failed > 0 {
msg.push_str(&format!(" · {failed} couldn't be rendered"));
}
@@ -295,7 +313,11 @@ impl LightcraftApp {
} else {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
self.toast(ctx, format!("Building previews for {} photos…", b.total));
let msg = match b.what {
"" => format!("Building previews for {} photos…", b.total),
what => format!("Working on {what} for {} photos…", b.total),
};
self.toast(ctx, msg);
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
@@ -456,6 +478,7 @@ impl LightcraftApp {
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
pub fn logic(&mut self, ctx: &egui::Context) {
i18n::set_language(self.ui.language);
let t0 = now_ms();
panels::library_problem::logic(self);
self.logic_inner(ctx);
@@ -469,6 +492,10 @@ impl LightcraftApp {
// File → Add from Device lists cards scanned in the background: show hot-plugs
let repaint = ctx.clone();
lightcraft_engine::devices::on_change(move || repaint.request_repaint());
// "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a
// worker fills: a sleeping NAS or a dropped share never stalls a frame
let repaint = ctx.clone();
self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint()));
self.styled = true;
} else {
self.fonts_ready = true;
@@ -493,6 +520,7 @@ impl LightcraftApp {
merge::poll(self, ctx);
import::poll_scan(self, ctx);
import::tick(self, ctx);
tasks::poll(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
self.slideshow_tick(ctx);
@@ -507,17 +535,33 @@ impl LightcraftApp {
}
}
self.ui.was_focused = focused;
// auto import: scan the watched folder every few seconds
// auto import: list the watched folder every few seconds (on a worker thread: it may be on
// a network share), then import what's new like any import (also on a worker thread)
#[cfg(not(target_arch = "wasm32"))]
if self.session.import_defaults.auto_folder.is_some() {
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
const LABEL: &str = "Auto Import";
let now = ctx.input(|i| i.time);
if now - self.ui.auto_import_at >= 3.0 {
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && !self.tasks.is_running(LABEL) {
self.ui.auto_import_at = now;
if let Ok(r) = self.session.execute("library.autoImportScan", &serde_json::json!({})) {
let n = r["imported"].as_array().map_or(0, Vec::len);
if n > 0 {
self.toast(ctx, format!("Auto Import: added {n} photo{}", if n == 1 { "" } else { "s" }));
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
let listing = match listing {
Ok(l) => l,
Err(e) => return log::debug!("auto import: {e}"),
};
let p = serde_json::json!({"listing": listing, "start": false});
let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return };
let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return };
let paths: Vec<String> =
params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
if let Some(o) = params.as_object_mut() {
o.remove("paths");
}
let undo0 = app.session.undo.len();
app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
};
if let Err(e) = tasks::spawn(self, LABEL, work, done) {
log::warn!("{e}");
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(3));
@@ -537,8 +581,11 @@ impl LightcraftApp {
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
if !photos.is_empty() {
let _ = self.run("library.import", serde_json::json!({"paths": photos}));
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
if !photos.is_empty()
&& let Err(e) = import::start_paths(self, photos)
{
self.toast(ctx, e);
}
}
}
@@ -548,6 +595,11 @@ impl LightcraftApp {
if self.gpu_applied != Some(self.ui.settings.gpu) {
self.gpu_applied = Some(self.ui.settings.gpu);
let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu}));
// GPU device + kernels off the UI thread, once the window is up and only when GPU
// rendering is on: a broken driver must not keep the window from appearing (issue #136)
if self.ui.settings.gpu {
lightcraft_engine::gpu::warm_up();
}
}
let mb = self.ui.settings.memory_mb;
// automatic at startup: leave the engine's default alone
@@ -609,6 +661,7 @@ impl LightcraftApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
i18n::set_language(self.ui.language);
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
+89 -42
View File
@@ -43,6 +43,15 @@ impl MenuNode {
}
}
/// User-defined names in parameterized menus are data, not message keys.
pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str {
if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) {
label
} else {
crate::i18n::tr(label)
}
}
/// Top-level menus in order (the macOS app menu is added by the host).
pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"];
@@ -58,6 +67,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"@Import from Device",
"---",
"app.openLibrary",
"file.backupLibrary",
"file.restoreLibrary",
"---",
"dialog.newAlbum",
"dialog.newFolder",
@@ -241,6 +252,15 @@ const HIDDEN: &[&str] = &[
"preset.create",
];
/// Items only some hosts have are left out of the others' menus (the web build's library backup).
fn host_supports(app: &LightcraftApp, id: &str) -> bool {
match id {
"file.backupLibrary" => app.services.backup_library.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
_ => true,
}
}
fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {
MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked }
}
@@ -296,11 +316,15 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
let n = app.session.selection.ids.len();
match id {
"edit.undo" => app.session.undo.last().map(|e| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()),
"photo.delete" if n > 1 => format!("Delete {n} Photos"),
"photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"),
"dialog.rename" if n > 1 => format!("Rename {n} Photos…"),
"edit.undo" => {
app.session.undo.last().map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Undo".into())
}
"edit.redo" => {
app.session.redo.last().map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Redo".into())
}
"photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n),
"photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n),
"dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n),
_ => label.to_string(),
}
}
@@ -325,20 +349,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
// (read and written on a worker thread: the originals may be on a slow drive)
item(
"library.smartPreviews",
Value::Null,
json!({"background": true}),
format!("Build Smart Previews ({scope})"),
None,
app.session.media.smart_dir.is_some(),
app.session.media.smart_dir.is_some() && !running,
None,
),
item(
"library.smartPreviews",
json!({"discard": true}),
json!({"discard": true, "background": true}),
format!("Discard Smart Previews ({scope})"),
None,
app.session.media.smart_dir.is_some(),
app.session.media.smart_dir.is_some() && !running,
None,
),
MenuNode::Separator,
@@ -464,14 +489,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
.map(|(f, label)| item("library.selectBy", json!({"flag": f}), label, None, true, None))
.collect();
v.push(MenuNode::Separator);
v.extend(
(1..=5u8).map(|r| item("library.selectBy", json!({"rating": r}), format!("{} and higher", "★".repeat(r as usize)), None, true, None)),
);
v.extend((1..=5u8).map(|r| {
item("library.selectBy", json!({"rating": r}), crate::i18n::tr_format!("{} and higher", "★".repeat(r as usize)), None, true, None)
}));
v.push(item("library.selectBy", json!({"rating": 0, "ratingOp": "eq"}), "Unrated", None, true, None));
v.push(MenuNode::Separator);
v.extend(lightcraft_catalog::ColorLabel::ALL.iter().map(|l| {
let name = format!("{l:?}");
item("library.selectBy", json!({"label": name.to_lowercase()}), format!("{name} Label"), None, true, None)
item(
"library.selectBy",
json!({"label": name.to_lowercase()}),
crate::i18n::tr_format!("{name} Label", name = name),
None,
true,
None,
)
}));
v
}
@@ -537,7 +569,8 @@ fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {
/// The whole menu bar: (title, items) per menu in [`MENUS`] order.
pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
let entries: Vec<MenuEntry> = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect();
let entries: Vec<MenuEntry> =
crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str()) && host_supports(app, &e.id)).collect();
let mut used = vec![false; entries.len()];
let mut bar = Vec::new();
for title in MENUS {
@@ -595,22 +628,6 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
}
bar.push((title.to_string(), tidy(items)));
}
fn translate(nodes: &mut [MenuNode], language: crate::i18n::Language) {
for node in nodes {
match node {
MenuNode::Submenu { label, children } => {
*label = language.tr(label).to_string();
translate(children, language);
}
MenuNode::Item { label, .. } => *label = language.tr(label).to_string(),
MenuNode::Separator => {}
}
}
}
for (title, nodes) in &mut bar {
*title = app.ui.language.tr(title).to_string();
translate(nodes, app.ui.language);
}
bar
}
@@ -664,9 +681,9 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String {
const TITLE_GAP: f32 = 24.0;
/// Width of the in-window menu bar's titles.
pub fn bar_width(ui: &egui::Ui, language: crate::i18n::Language) -> f32 {
pub fn bar_width(ui: &egui::Ui) -> f32 {
let t = crate::theme::Tokens::get(ui.ctx());
MENUS.iter().map(|m| ui.painter().layout_no_wrap(language.tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::<f32>()
MENUS.iter().map(|m| ui.painter().layout_no_wrap(crate::i18n::tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::<f32>()
}
/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single
@@ -675,8 +692,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
let t = crate::theme::Tokens::get(ui.ctx());
let bar = menu_bar(app);
let font = t.font(13.0);
let widths: Vec<f32> =
bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect();
let widths: Vec<f32> = bar
.iter()
.map(|(title, _)| ui.painter().layout_no_wrap(crate::i18n::tr(title).to_string(), font.clone(), t.text).size().x + TITLE_GAP)
.collect();
let total: f32 = widths.iter().sum();
let mut clicked: Option<(String, Value)> = None;
let start = ui.cursor().left();
@@ -684,18 +703,18 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
if total <= max_width {
let saved = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = TITLE_GAP;
for (index, (title, items)) in bar.iter().enumerate() {
let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{}", MENUS.get(index).copied().unwrap_or(title.as_str())), r.rect);
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
}
ui.spacing_mut().item_spacing.x = saved;
} else {
let r = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (title, items) in &bar {
ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked));
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
}
});
}
@@ -713,11 +732,11 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
ui.separator();
}
MenuNode::Submenu { label, children } => {
ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked));
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
// a gutter for check marks, like native menus
let mut b = egui::Button::new(format!(" {label}"));
let mut b = egui::Button::new(format!(" {}", display_item_label(id, params, label)));
if let Some(sc) = shortcut {
b = b.shortcut_text(shortcut_text(sc, mac));
}
@@ -774,6 +793,34 @@ mod tests {
assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus");
}
/// Back Up / Restore Library are the browser build's (its library lives in browser storage):
/// absent from the desktop's menus, present and wired to the host where it provides them.
#[test]
fn library_backup_items_follow_the_host() {
let all = |app: &LightcraftApp| -> Vec<MenuNode> { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() };
let mut desktop = app();
assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none());
assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host");
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
let c = calls.clone();
let services = crate::Services {
backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| {
c.set(c.get() + 1);
Ok(json!({"photos": s.catalog.len()}))
})),
restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))),
..Default::default()
};
let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let bar = all(&web);
for id in ["file.backupLibrary", "file.restoreLibrary"] {
assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}");
}
let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap();
assert!(r["photos"].as_u64().unwrap() > 0);
assert_eq!(calls.get(), 1);
}
#[test]
fn bar_follows_the_registry_and_state() {
let mut app = app();
@@ -936,7 +983,7 @@ mod tests {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default());
app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap();
std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap();
let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy()})).unwrap();
let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap();
assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}");
// Locate: an explicit file
std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap();
+59 -20
View File
@@ -116,6 +116,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("file.addFolder", "Import from Folder…", None, "File"),
("file.addFromDevice", "Import from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
("file.backupLibrary", "Back Up Library…", None, "File"),
("file.restoreLibrary", "Restore Library from Backup…", None, "File"),
("photo.locate", "Locate Missing File…", None, ""),
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
@@ -149,10 +151,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
app.toast(ctx, format!("{name} Off"));
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
} else {
app.ui.right = p;
app.toast(ctx, format!("{name} On"));
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -381,6 +383,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"app.openLibrary" => crate::panels::settings::open_library(app, p),
"file.backupLibrary" | "file.restoreLibrary" => {
let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() };
match action {
Some(f) => f(&mut app.session),
None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()),
}
}
"view.filmstrip" => {
app.ui.filmstrip = !app.ui.filmstrip;
Ok(Value::Null)
@@ -865,7 +874,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
(
"GPU".to_string(),
gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() {
Some(why) => format!("none (CPU rendering): {why}"),
Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why),
None => "none (CPU rendering)".into(),
}),
),
@@ -928,18 +937,40 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
None => app.services.pick_folder.as_mut().and_then(|f| f()),
};
let Some(folder) = folder else { return Some(Ok(Value::Null)) };
let r = app.run("library.findMissing", json!({"folder": folder}));
if let Ok(v) = &r {
let n = v["found"].as_array().map_or(0, Vec::len);
let left = v["missing"].as_u64().unwrap_or(0);
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
app.toast(
&egui::Context::default(),
format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }),
);
// the search (checking every photo's file, walking the folder) runs on a worker thread;
// the relinking happens back here, as one undo step
const LABEL: &str = "Find Missing Photos";
if app.tasks.is_running(LABEL) {
return Some(Err("Find Missing Photos is already searching".into()));
}
r
let candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog);
let work = move || lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder);
let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result<lightcraft_engine::cmd::missing::FindPlan, String>| {
// relinked under the session as it is now: photos relinked meanwhile and files
// now in use are skipped
let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json()));
match r {
Ok(v) => {
let n = v["found"].as_array().map_or(0, Vec::len);
let left = v["missing"].as_u64().unwrap_or(0);
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
app.toast(ctx, format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }));
app.ui.last_find_missing = Some(v);
}
Err(e) => app.toast(ctx, e),
}
};
app.ui.last_find_missing = None;
if let Err(e) = crate::tasks::spawn(app, LABEL, work, done) {
return Some(Err(e));
}
if p.get("wait").and_then(Value::as_bool).unwrap_or(false) {
let ctx = app.tasks.repaint.clone().unwrap_or_default();
crate::tasks::wait(app, &ctx, std::time::Duration::from_secs(600));
return Some(Ok(app.ui.last_find_missing.clone().unwrap_or(Value::Null)));
}
Ok(json!({"background": true}))
}
"photo.tagFromTracklog" => {
// a GPX file → GPS for the selected photos by capture time (one undo step)
@@ -1064,8 +1095,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = match (n, failed) {
(0, 0) => "No new presets".to_string(),
(n, 0) => format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }),
(n, f) => format!("Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }),
(n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n),
(n, f) => crate::i18n::tr_format!(
"Imported {n} preset{}, {f} file{} not readable",
if n == 1 { "" } else { "s" },
if f == 1 { "" } else { "s" },
f = f,
n = n
),
};
// settings with no counterpart here (the other editor's profiles, masks…)
let mut skipped: Vec<&str> = v["imported"]
@@ -1110,7 +1147,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
let r = app.session.execute("preset.export", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}
@@ -1129,9 +1166,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n);
if failed > 0 {
msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" });
msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed);
}
app.toast(&ctx, msg);
}
@@ -1152,7 +1189,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
let r = app.session.execute("curve.exportPresets", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}
@@ -1193,6 +1230,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"view.compare" => app.session.catalog.len() > 1,
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
"file.backupLibrary" => app.services.backup_library.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
+13 -13
View File
@@ -257,18 +257,18 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {
Ok(v) => {
app.merge.last_result = Some(v);
app.toast(ctx, format!("{what} merge added"));
app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what));
}
Err(e) => {
app.ui.status = e.to_string();
app.toast(ctx, format!("{what} merge failed: {e}"));
app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what));
}
}
}
Err(_) => {
let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default();
let now = ctx.input(|i| i.time);
app.ui.toast = Some((format!("Merging… {stage} {:.0}%", f * 100.0), now + 0.5));
app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5));
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
@@ -299,7 +299,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
ui.painter().text(
rect.center_bottom() - egui::vec2(0.0, 14.0),
egui::Align2::CENTER_CENTER,
format!("{stage}… {:.0}%", f * 100.0),
crate::i18n::tr_format!("{stage}… {:.0}%", f * 100.0, stage = stage),
t.font(12.0),
t.text,
);
@@ -330,7 +330,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
ui.vertical(|ui| {
ui.set_width(220.0);
if opts.is_pano() {
ui.label(egui::RichText::new("Projection").color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr("Projection")).color(t.text_label));
for (v, l) in [("auto", "Auto Select"), ("spherical", "Spherical"), ("cylindrical", "Cylindrical"), ("perspective", "Perspective")] {
if ui.radio(opts.projection == v, l).clicked() {
opts.projection = v.to_string();
@@ -341,13 +341,13 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
if num(ui, &BOUNDARY_WARP, &mut bw) {
opts.boundary_warp = bw.round();
}
ui.checkbox(&mut opts.fill_edges, "Fill Edges");
ui.checkbox(&mut opts.auto_crop, "Auto Crop");
ui.checkbox(&mut opts.fill_edges, crate::i18n::tr("Fill Edges"));
ui.checkbox(&mut opts.auto_crop, crate::i18n::tr("Auto Crop"));
ui.add_space(4.0);
}
if opts.is_hdr() {
ui.checkbox(&mut opts.align, "Auto Align");
ui.label(egui::RichText::new("Deghost Amount").color(t.text_label));
ui.checkbox(&mut opts.align, crate::i18n::tr("Auto Align"));
ui.label(egui::RichText::new(crate::i18n::tr("Deghost Amount")).color(t.text_label));
ui.horizontal(|ui| {
for (i, (v, l)) in [("none", "None"), ("low", "Low"), ("medium", "Med"), ("high", "High")].iter().enumerate() {
if crate::widgets::text_button(ui, &format!("mergeDeghost-{i}"), l, opts.deghost == *v).clicked() {
@@ -356,7 +356,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
}
});
if opts.command == "merge.hdr" {
ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, "Show Deghost Overlay"));
ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, crate::i18n::tr("Show Deghost Overlay")));
}
if opts.command == "merge.hdrPanorama" {
let mut b = opts.bracket as f64;
@@ -366,10 +366,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
}
ui.add_space(4.0);
}
ui.checkbox(&mut opts.auto_settings, "Auto Settings");
ui.checkbox(&mut opts.stack, "Create Stack");
ui.checkbox(&mut opts.auto_settings, crate::i18n::tr("Auto Settings"));
ui.checkbox(&mut opts.stack, crate::i18n::tr("Create Stack"));
ui.add_space(8.0);
ui.label(egui::RichText::new(format!("{} photos", app.merge.ids.len())).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("{} photos", app.merge.ids.len())).color(t.text_dim));
});
});
}
+21 -9
View File
@@ -104,7 +104,13 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
ui.painter().text(
copy_r.center(),
Align2::CENTER_CENTER,
crate::i18n::tr(label),
t.font(13.0),
if active.is_some() { t.text_label } else { t.text_disabled },
);
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
@@ -168,7 +174,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
Zoom::Fill => "Fill".to_string(),
Zoom::Percent(p) => format!("{p}%"),
};
let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label);
let zr = crate::widgets::dropdown(&mut child, "zoom", crate::i18n::tr(&zoom_label), t.font(13.0), t.text_label);
egui::Popup::menu(&zr).show(|ui| {
for (label, z) in [
("Fit", Zoom::Fit),
@@ -185,7 +191,13 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
}
});
child.add_space(6.0);
let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label);
let cz = crate::widgets::dropdown(
&mut child,
"clickZoom",
&crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100),
t.font(13.0),
t.text_label,
);
egui::Popup::menu(&cz).show(|ui| {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
@@ -216,7 +228,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_catalog::GroupBy;
use lightcraft_catalog::SortKey::*;
let cur = app.session.sort;
ui.label(egui::RichText::new("Sort by").weak());
ui.label(egui::RichText::new(crate::i18n::tr("Sort by")).weak());
for (label, key, k) in [
("Capture Date", CaptureDate, "captureDate"),
("Import Date", ImportDate, "importDate"),
@@ -225,19 +237,19 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
("Rating", Rating, "rating"),
("File Size", FileSize, "fileSize"),
] {
if ui.selectable_label(cur.key == key, label).clicked() {
if ui.selectable_label(cur.key == key, crate::i18n::tr(label)).clicked() {
let _ = app.run("library.sort", json!({"key": k}));
}
}
ui.separator();
if ui.selectable_label(cur.ascending, "Ascending").clicked() {
if ui.selectable_label(cur.ascending, crate::i18n::tr("Ascending")).clicked() {
let _ = app.run("library.sort", json!({"ascending": true}));
}
if ui.selectable_label(!cur.ascending, "Descending").clicked() {
if ui.selectable_label(!cur.ascending, crate::i18n::tr("Descending")).clicked() {
let _ = app.run("library.sort", json!({"ascending": false}));
}
ui.separator();
ui.label(egui::RichText::new("Group by date").weak());
ui.label(egui::RichText::new(crate::i18n::tr("Group by date")).weak());
for (label, g, k) in [
("Automatic", GroupBy::Auto, "auto"),
("Day", GroupBy::Day, "day"),
@@ -245,7 +257,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
("Year", GroupBy::Year, "year"),
("None", GroupBy::None, "none"),
] {
if ui.selectable_label(cur.group == g, label).clicked() {
if ui.selectable_label(cur.group == g, crate::i18n::tr(label)).clicked() {
let _ = app.run("library.sort", json!({"group": k}));
}
}
+5 -5
View File
@@ -18,8 +18,8 @@ const TAIL: f32 = 170.0;
/// "14 of 120 photos" when a filter narrows the source, else "14 photos".
pub fn count_text(matching: usize, total: Option<usize>, filtering: bool) -> String {
match total {
Some(t) if filtering && t != matching => format!("{matching} of {t} photos"),
_ => format!("{matching} photos"),
Some(t) if filtering && t != matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t),
_ => crate::i18n::tr_format!("{matching} photos", matching = matching),
}
}
@@ -53,7 +53,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y));
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text);
let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0));
let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text("Remove this filter");
let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter"));
register(ui.ctx(), format!("chip:{i}"), xr);
let col = if resp.hovered() { t.text } else { t.text_dim };
let m = 3.5;
@@ -80,7 +80,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false);
egui::Popup::menu(&more).show(|ui| {
for c in hidden {
if ui.button(format!("{} ✕", c.label)).on_hover_text("Remove this filter").clicked() {
if ui.button(format!("{} ✕", c.label)).on_hover_text(crate::i18n::tr("Remove this filter")).clicked() {
let _ = app.run("library.filter", c.clear.clone());
if c.clear.get("text").is_some() {
app.ui.search.clear();
@@ -89,7 +89,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
}
});
}
if crate::widgets::text_button(&mut child, "chipsClearAll", "Clear all", false).clicked() {
if crate::widgets::text_button(&mut child, "chipsClearAll", crate::i18n::tr("Clear all"), false).clicked() {
app.ui.search.clear();
let _ = app.run("library.clearFilter", json!({}));
}
+9 -6
View File
@@ -263,10 +263,10 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
}
resp.context_menu(|ui| {
if ui.button("Swap").clicked() {
if ui.button(crate::i18n::tr("Swap")).clicked() {
let _ = swap(app);
}
if ui.button("Make Candidate the Select").clicked() {
if ui.button(crate::i18n::tr("Make Candidate the Select")).clicked() {
let _ = make_select(app);
}
ui.separator();
@@ -321,7 +321,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// remove from the survey (deselect) on hover
if resp.hovered() && photos.len() > 1 {
let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0));
let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey");
let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey"));
ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 }));
paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text);
if xresp.clicked() {
@@ -331,8 +331,11 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
}
let n = photos.len();
let msg =
if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() };
let msg = if app.session.selection.ids.len() > SURVEY_MAX {
crate::i18n::tr_format!("Showing {n} of {}", app.session.selection.ids.len(), n = n)
} else {
String::new()
};
if !msg.is_empty() {
ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim);
}
@@ -357,7 +360,7 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.image_rect = Some(img);
pan(app, &resp, img);
lresp.context_menu(|ui| {
if ui.button("Clear Reference").clicked() {
if ui.button(crate::i18n::tr("Clear Reference")).clicked() {
app.ui.reference = None;
app.ui.view = ViewMode::Detail;
}
+16 -10
View File
@@ -267,7 +267,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
if app.ui.soft_proof && !fullscreen {
p.rect_filled(canvas, 0.0, Color32::from_gray(238));
let label = format!("Proof Preview · {}", app.ui.proof.space.label());
let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label());
p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60));
}
let shown;
@@ -275,13 +275,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
draw(Slot::Before, br);
shown = draw(Slot::Main, img_rect);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
} else if show_before {
shown = draw(Slot::Before, img_rect);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
} else if let Some((key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id && t.key == *key)
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text);
} else if let Some((_key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
{
p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE);
@@ -294,11 +294,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if shown == "none" {
match app.renderer.failure(Slot::Main) {
Some(e) => {
p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, "This photo can't be opened", t.semibold(14.0), t.text);
p.text(
canvas.center() - vec2(0.0, 10.0),
Align2::CENTER_CENTER,
crate::i18n::tr("This photo can't be opened"),
t.semibold(14.0),
t.text,
);
p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim);
}
None => {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim);
}
}
}
@@ -354,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
preview_only_pill(ui, canvas, why);
}
resp.context_menu(|ui| {
ui.menu_button("Zoom", |ui| {
ui.menu_button(crate::i18n::tr("Zoom"), |ui| {
for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({}));
@@ -419,7 +425,7 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li
fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let text = format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason));
let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason));
let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE);
let size = vec2(g.size().x + 40.0, 26.0);
let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size);
+117 -63
View File
@@ -7,6 +7,53 @@ use crate::LightcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
/// Rows the Rename dialog previews.
const RENAME_PREVIEW_ROWS: usize = 6;
/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files
/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the
/// rows are planned on a worker thread, and only when the template, start number, photos or
/// catalog change — never once per frame.
pub(crate) fn rename_preview(
app: &mut LightcraftApp,
ctx: &egui::Context,
template: &str,
start: usize,
) -> (Option<Vec<lightcraft_engine::rename::RenamePlan>>, usize) {
use std::hash::{Hash, Hasher};
type Rows = std::sync::Arc<std::sync::Mutex<Option<Vec<lightcraft_engine::rename::RenamePlan>>>>;
let ids = app.session.targets(&json!({}));
let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids);
let total = photos.len();
let mut h = std::collections::hash_map::DefaultHasher::new();
(template, start, app.session.catalog.revision, &ids).hash(&mut h);
let key = h.finish();
let id = egui::Id::new("rename-preview-rows");
let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id))
&& k == key
{
return (read(&rows), total);
}
let rows: Rows = Default::default();
let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect();
let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone());
let exists = app.session.media.availability.probe();
let work = move || {
let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f));
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans);
repaint.request_repaint();
};
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) {
log::warn!("rename preview: {e}");
}
#[cfg(target_arch = "wasm32")]
work();
ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone())));
(read(&rows), total)
}
/// Help ▸ What's New (docs/whats-new.md).
pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md");
@@ -52,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
.to_string();
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12));
let shown = egui::Window::new(title)
let shown = egui::Window::new(crate::i18n::tr(&title)).id(egui::Id::new("lightcraft-dialog"))
.collapsible(false)
.resizable(false)
.frame(frame)
@@ -67,7 +114,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.spacing_mut().item_spacing.y = 8.0;
match &mut dlg {
Dialog::AutoStack { gap } => {
ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label));
ui.add(
egui::Slider::new(gap, 0.0..=86400.0)
.logarithmic(true)
@@ -83,15 +130,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::Cull { reject_below, pick_best } => {
let n = app.session.selection.ids.len();
let scope = if n > 1 { format!("the {n} selected photos") } else { format!("the {} photos in view", app.session.visible_cloned().len()) };
let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) };
ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label));
ui.add_space(6.0);
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0));
crate::widgets::register(ui.ctx(), "field:cullReject", r.rect);
ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim));
let r = ui.checkbox(pick_best, "Pick the sharpest photo of each burst");
let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst"));
crate::widgets::register(ui.ctx(), "check:cullPick", r.rect);
ui.label(egui::RichText::new("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
}
Dialog::WhatsNew => {
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
@@ -120,14 +167,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.end_row();
}
});
if ui.button("Copy to Clipboard").clicked() {
if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() {
let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect();
ui.ctx().copy_text(text);
}
}
Dialog::AllMetadata { title, rows, search } => {
ui.label(egui::RichText::new(title.as_str()).color(t.text_label));
let r = ui.add(egui::TextEdit::singleline(search).hint_text("Filter fields").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect);
let q = search.trim().to_lowercase();
let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q);
@@ -170,7 +217,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
Dialog::SmartRules { name, rules, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:smartName", r.rect);
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
@@ -183,22 +230,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(
egui::RichText::new(match problems.first() {
Some(p) => p.clone(),
None => format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }),
None => crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n),
})
.color(t.text_dim),
);
}
Dialog::NewSmartAlbum { name } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
}
let rules = app.session.view_rules();
let n = app.session.catalog.query(&rules, &Default::default()).len();
ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", rules.describe())).color(t.text_label));
ui.label(
egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }))
egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n))
.color(t.text_dim),
);
}
@@ -252,7 +299,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
);
}
if n > 1 {
ui.label(egui::RichText::new(format!("All {n} selected photos move by the same amount.")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim));
}
}
Dialog::LabelNames { names, save_as } => {
@@ -278,17 +325,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
field(ui, "Save as set", |ui| {
let te = ui.add(egui::TextEdit::singleline(save_as).hint_text("Optional name").desired_width(f32::INFINITY));
let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect);
});
ui.label(
egui::RichText::new("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")
egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name."))
.color(t.text_dim),
);
}
Dialog::Rename { template, start } => {
let n = app.session.targets(&json!({})).len();
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));
let template_id = egui::Id::new("rename-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@@ -313,26 +360,33 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999)));
ui.label(
egui::RichText::new("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
egui::RichText::new(crate::i18n::tr("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…"))
.color(t.text_dim),
);
let preview = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default();
egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| {
for pl in preview.as_array().into_iter().flatten().take(6) {
ui.label(egui::RichText::new(pl["from"].as_str().unwrap_or("")).color(t.text_dim));
ui.label(egui::RichText::new("→").color(t.text_dim));
ui.label(egui::RichText::new(pl["to"].as_str().unwrap_or("")).color(t.text));
ui.end_row();
let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize);
match &preview {
Some(rows) => {
egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| {
for pl in rows {
ui.label(egui::RichText::new(&pl.from).color(t.text_dim));
ui.label(egui::RichText::new("→").color(t.text_dim));
ui.label(egui::RichText::new(&pl.to).color(t.text));
ui.end_row();
}
});
}
});
let more = preview.as_array().map_or(0, Vec::len).saturating_sub(6);
None => {
ui.label(egui::RichText::new("Checking names…").color(t.text_dim));
}
}
let more = total.saturating_sub(RENAME_PREVIEW_ROWS);
if more > 0 {
ui.label(egui::RichText::new(format!("… and {more} more")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim));
}
}
Dialog::RenameKeyword { from, to } => {
let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count();
let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(to).hint_text(crate::i18n::tr("New name")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:keywordName", r.rect);
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
@@ -348,7 +402,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::MergeKeywords { from, into } => {
ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
let r = ui.add(egui::TextEdit::singleline(into).hint_text("Keyword").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(into).hint_text(crate::i18n::tr("Keyword")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect);
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
@@ -377,23 +431,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
}
}
Dialog::CreatePreset { name, group, groups } => {
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
ui.label(egui::RichText::new("Settings to include").color(t.text_dim));
ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY));
ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim));
group_checklist(ui, "presetInclude", groups);
}
Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups),
Dialog::PasteSettings { groups } => {
let n = app.session.targets(&json!({})).len();
ui.label(
egui::RichText::new(format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }))
egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n))
.color(t.text_dim),
);
group_checklist(ui, "pasteGroup", groups);
@@ -401,11 +455,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => {
use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S};
let n = app.session.selection.ids.len().max(1);
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));
// Preset: load a built-in or saved set of options into the dialog
field(ui, "Preset", |ui| {
let mut chosen = None;
let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text("Choose…").show_ui(ui, |ui| {
let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| {
let mut builtin = true;
for (p, b) in app.session.all_export_presets() {
if builtin && !b {
@@ -434,7 +488,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui,
"Format",
"exportFormat",
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original")],
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")],
&mut opts.format,
);
if !opts.format.is_rendered() {
@@ -458,7 +512,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
if !rendered {
} else if opts.format == F::Avif {
ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
} else {
use lightcraft_engine::export::OutputSpace as C;
choices(
@@ -517,10 +571,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
&mut opts.metadata,
);
if !matches!(opts.metadata, M::None | M::Copyright) {
ui.checkbox(&mut opts.remove_location, "Remove location info");
ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info"));
}
let mut wm_on = opts.watermark.is_some();
if ui.checkbox(&mut wm_on, "Watermark").changed() {
if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() {
opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() });
}
if let Some(wm) = &mut opts.watermark {
@@ -538,7 +592,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
field(ui, "Graphic", |ui| {
trailing_button_row(ui, |ui| {
if app.services.pick_files.is_some()
&& crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked()
&& crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
wm.image = f;
@@ -551,7 +605,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
wm.image_width = (width / 100.0) as f32;
}
} else {
choices(ui, app.ui.language.tr("Orientation"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal")), (true, app.ui.language.tr("Vertical"))], &mut wm.vertical);
choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical);
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY))
});
@@ -572,7 +626,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
wm.opacity = (op / 100.0) as f32;
}
if !graphic {
ui.checkbox(&mut wm.shadow, "Shadow");
ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow"));
}
}
}
@@ -605,7 +659,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
field(ui, "Folder", |ui| {
trailing_button_row(ui, |ui| {
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked()
&& crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked()
&& let Some(pick) = app.services.pick_folder.as_mut()
&& let Some(d) = pick()
{
@@ -615,7 +669,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
});
field(ui, "Subfolder", |ui| {
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text("none").desired_width(f32::INFINITY))
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
});
use lightcraft_engine::export::Conflict as K;
choices(
@@ -629,17 +683,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
field(ui, "Save preset", |ui| {
trailing_button_row(ui, |ui| {
let named = !preset_name.trim().is_empty();
if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named {
if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named {
let params = export_dialog_params(opts, *full_size, resize, *limit_kb);
match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) {
Ok(_) => {
app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim()));
app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim()));
preset_name.clear();
}
Err(e) => app.toast(ui.ctx(), e),
}
}
ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(ui.available_width()));
ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width()));
});
});
}
@@ -647,14 +701,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") };
ui.label(format!("Move {what} to Recently Deleted?"));
ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim));
let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
Dialog::About => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Font: Inter (OFL). Icons: original.");
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Fonts: Inter / BIZ UDPGothic (OFL). Icons: original.");
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
@@ -681,7 +735,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc {
ui.label(*label);
ui.label(crate::i18n::tr(label));
ui.label(*sc);
ui.label(egui::RichText::new(*id).color(t.text_dim));
ui.end_row();
@@ -689,7 +743,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
for c in lightcraft_engine::command_specs() {
if let Some(sc) = c.shortcut {
ui.label(c.label);
ui.label(crate::i18n::tr(c.label));
ui.label(sc);
ui.label(egui::RichText::new(c.id).color(t.text_dim));
ui.end_row();
@@ -702,7 +756,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
.map(|c| c.1)
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
.unwrap_or(id);
ui.label(label);
ui.label(crate::i18n::tr(label));
ui.label(*sc);
ui.label(egui::RichText::new(*id).color(t.text_dim));
ui.end_row();
@@ -714,7 +768,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
if !informational && ui.button("Cancel").clicked() {
if !informational && ui.button(crate::i18n::tr("Cancel")).clicked() {
close = true;
}
let add_label;
@@ -730,7 +784,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ if informational => "Close",
_ => "OK",
};
if ui.button(ok).clicked() {
if ui.button(crate::i18n::tr(ok)).clicked() {
if informational {
close = true;
} else {
@@ -929,15 +983,15 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
(R::Megapixels, "Megapixels"),
(R::Percent, "Percentage"),
];
let r_full = ui.checkbox(full, "Full size");
let r_full = ui.checkbox(full, crate::i18n::tr("Full size"));
crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect);
if !*full {
field(ui, "Resize to", |ui| {
let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1);
let before = r.mode;
let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(cur).show_ui(ui, |ui| {
let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (m, l) in MODES {
ui.selectable_value(&mut r.mode, m, l);
ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l));
}
});
crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect);
@@ -970,7 +1024,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
r.height = h as u32;
}
}
let c = ui.checkbox(&mut r.dont_enlarge, "Don't enlarge");
let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge"));
crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect);
}
let mut p = *ppi as f64;
@@ -999,7 +1053,7 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(LABEL_W);
ui.label(egui::RichText::new(label).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})
+75 -57
View File
@@ -76,7 +76,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default();
// a raw shown from its embedded JPEG (preview only) gets the rendered-file white balance scale
let raw = app.session.catalog.photo(id).is_some_and(|p| p.develops_raw());
let raw = app.session.catalog.photo(id).is_some_and(|p| p.develops_raw() && !p.relative_wb());
let mut d = d;
if d.wb.mode == WbMode::AsShot && app.session.catalog.photo(id).is_some_and(|p| p.relative_wb()) {
let wb = &mut std::sync::Arc::make_mut(&mut d).wb;
wb.temp = 6500.0;
wb.tint = 0.0;
}
let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone());
if app.ui.histogram {
@@ -87,20 +93,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
// header: Edit + Auto / B&W / HDR
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, "Edit", t.semibold(15.0), t.text);
ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if text_button(ui, "auto", "Auto", false).clicked() {
if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), "Auto settings applied");
}
let bw = crate::is_bw(&d);
if text_button(ui, "bw", "B&W", bw).clicked() {
if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() {
let _ = app.run("develop.treatment", json!({}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "reset", "Reset", false).on_hover_text("Reset all edits (Cmd+Shift+R)").clicked() {
if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked()
{
let _ = app.run("develop.reset", json!({}));
}
});
@@ -118,9 +125,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if app.session.auto_sync && n > 1 {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("Auto Sync: edits apply to {n} photos")).color(t.accent).size(12.0));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Auto Sync: edits apply to {n} photos", n = n)).color(t.accent).size(12.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "autoSyncOff", "Turn Off", false).clicked() {
if text_button(ui, "autoSyncOff", crate::i18n::tr("Turn Off"), false).clicked() {
let _ = app.run("develop.autoSync", json!({"on": false}));
}
});
@@ -131,9 +138,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// profile row
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));
let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
let r = crate::widgets::dropdown(ui, "profile", name, t.font(15.0), t.text_label);
let r =
crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label);
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() {
@@ -154,7 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
ui.add_space(6.0);
let open = app.ui.flyout_open("curve");
if flyout_row(ui, "curve", "Curve", Icon::Curve, open).clicked() {
if flyout_row(ui, "curve", crate::i18n::tr("Curve"), Icon::Curve, open).clicked() {
app.ui.toggle_flyout("curve");
}
if open {
@@ -166,8 +174,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// White balance row
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("White Balance").font(t.font(13.0)).color(t.text_dim));
let r = crate::widgets::dropdown(ui, "wbMode", d.wb.mode.label(), t.font(14.0), t.text_label);
ui.label(egui::RichText::new(crate::i18n::tr("White Balance")).font(t.font(13.0)).color(t.text_dim));
let r = crate::widgets::dropdown(ui, "wbMode", crate::i18n::tr(d.wb.mode.label()), t.font(14.0), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for m in WbMode::ALL {
if m == WbMode::Custom {
@@ -210,7 +218,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
if !crate::is_bw(d) {
let open = app.ui.flyout_open("pointColor");
if flyout_row(ui, "pointColor", "Point Color", Icon::Picker, open).clicked() {
if flyout_row(ui, "pointColor", crate::i18n::tr("Point Color"), Icon::Picker, open).clicked() {
app.ui.toggle_flyout("pointColor");
}
if open {
@@ -218,7 +226,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
let open = app.ui.flyout_open("grading");
if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() {
if flyout_row(ui, "grading", crate::i18n::tr("Color Grading"), Icon::Presets, open).clicked() {
app.ui.toggle_flyout("grading");
}
if open {
@@ -230,7 +238,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
for c in ["effects.texture", "effects.clarity", "effects.dehaze"] {
control(app, ui, d, c, true);
}
sub_title(ui, "Vignette");
sub_title(ui, crate::i18n::tr("Vignette"));
{
use lightcraft_develop::VignetteStyle as V;
let styles = [
@@ -255,7 +263,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let enabled = c == "vignette.amount" || d.vignette.amount != 0.0;
control(app, ui, d, c, enabled);
}
sub_title(ui, "Grain");
sub_title(ui, crate::i18n::tr("Grain"));
for c in ["grain.amount", "grain.size", "grain.roughness"] {
control(app, ui, d, c, c == "grain.amount" || d.grain.amount != 0.0);
}
@@ -265,7 +273,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] {
control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0);
}
sub_title(ui, "Noise Reduction");
sub_title(ui, crate::i18n::tr("Noise Reduction"));
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"]
{
control(app, ui, d, c, true);
@@ -276,16 +284,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let has_lens = app.session.catalog.photo(id).is_some_and(|p| p.embedded_lens.is_some());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
let mut ca = d.optics.remove_ca;
if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() {
if ui.checkbox(&mut ca, crate::i18n::tr("Remove Chromatic Aberration")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));
}
let mut lp = d.optics.lens_profile;
if ui.checkbox(&mut lp, "Enable Lens Corrections").changed() {
if ui.checkbox(&mut lp, crate::i18n::tr("Enable Lens Corrections")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));
}
if lp && !has_lens {
ui.label(
egui::RichText::new("No lens data embedded in this file (DNG lens opcodes only).")
egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only)."))
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
@@ -296,14 +304,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
control(app, ui, d, c, has_lens);
}
}
sub_title(ui, "Manual");
sub_title(ui, crate::i18n::tr("Manual"));
for c in ["optics.distortion", "optics.vignetting"] {
control(app, ui, d, c, true);
}
control(app, ui, d, "optics.vignettingMidpoint", d.optics.vignetting != 0.0);
ui.add_space(6.0);
let open = app.ui.flyout_open("defringe");
if flyout_row(ui, "defringe", "Defringe", Icon::Picker, open).clicked() {
if flyout_row(ui, "defringe", crate::i18n::tr("Defringe"), Icon::Picker, open).clicked() {
app.ui.toggle_flyout("defringe");
}
if open {
@@ -315,7 +323,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let on = d.optics.defringe_green_amount > 0.0;
control(app, ui, d, "optics.defringeGreenHueLo", on);
control(app, ui, d, "optics.defringeGreenHueHi", on);
sub_title(ui, "Lateral Chromatic Aberration");
sub_title(ui, crate::i18n::tr("Lateral Chromatic Aberration"));
control(app, ui, d, "optics.caRed", true);
control(app, ui, d, "optics.caBlue", true);
}
@@ -323,7 +331,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
// Lightroom Classic's Calibration panel (the cloud app hides it): last, like there.
section(app, ui, &d, "calibration", "Calibration", |app, ui, d| {
sub_title(ui, "Shadows");
sub_title(ui, crate::i18n::tr("Shadows"));
control(app, ui, d, "calibration.shadowsTint", true);
for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] {
sub_title(ui, title);
@@ -383,14 +391,18 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.separator();
if let Some(p) = profile(cur) {
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let label = if fav { format!("Remove “{}” from Favorites", p.name) } else { format!("Add “{}” to Favorites", p.name) };
let label = if fav {
crate::i18n::tr_format!("Remove “{}” from Favorites", p.name)
} else {
crate::i18n::tr_format!("Add “{}” to Favorites", p.name)
};
let r = ui.button(label);
register(ui.ctx(), "profileMenu:toggleFavorite", r.rect);
if r.clicked() {
let _ = app.run("profile.favorite", json!({"id": p.id}));
}
}
let r = ui.button("Browse…");
let r = ui.button(crate::i18n::tr("Browse…"));
register(ui.ctx(), "profileMenu:browse", r.rect);
if r.clicked() {
let _ = app.run("panel.profiles", json!({}));
@@ -402,7 +414,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
let mut s = d.clone();
s.profile.id = p.id.to_string();
s.profile.amount = 100.0;
app.hover_preview = Some(crate::HoverPreview { label: format!("Profile: {}", p.name), settings: s });
app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
}
if let Some(id) = pick.0 {
let _ = app.run("develop.profile", json!({"id": id}));
@@ -412,7 +424,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
pub fn sub_title(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);
}
fn section(
@@ -531,16 +543,17 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("Soft Proofing").color(t.text).strong());
ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut dest = app.ui.proof.dest_warning;
let r = ui
.toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40)))
.on_hover_text("Show destination gamut warning");
.on_hover_text(crate::i18n::tr("Show destination gamut warning"));
register(ui.ctx(), "button:proofDestWarning", r.rect);
let mut disp = app.ui.proof.display_warning;
let r2 =
ui.toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))).on_hover_text("Show display gamut warning");
let r2 = ui
.toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255)))
.on_hover_text(crate::i18n::tr("Show display gamut warning"));
register(ui.ctx(), "button:proofDisplayWarning", r2.rect);
if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning {
let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp}));
@@ -548,7 +561,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
});
ui.horizontal(|ui| {
ui.label(RichText::new("Profile:").color(t.text_label));
ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label));
let cur = app.ui.proof.space;
egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(cur.label()).show_ui(ui, |ui| {
for s in OutputSpace::ALL {
@@ -558,10 +571,10 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
});
let r = ui.button("Create Proof Copy");
let r = ui.button(crate::i18n::tr("Create Proof Copy"));
register(ui.ctx(), "button:createProofCopy", r.rect);
if r.clicked() {
let name = format!("Proof Copy ({})", app.ui.proof.space.label());
let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label());
if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() {
app.toast(ui.ctx(), "Proof copy created");
}
@@ -605,7 +618,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
"green" => "Green channel",
_ => "Blue channel",
};
let resp = resp.on_hover_text(format!("{name} — double-click to reset it"));
let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name));
if resp.double_clicked() {
app.ui.curve_channel = ch.into();
let _ = app.run("curve.reset", json!({"channel": ch}));
@@ -796,7 +809,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("Point Curve").font(t.font(12.0)).color(t.text_label));
ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label));
let current = matching_preset(&app.session, &d.curve);
let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
egui::Popup::menu(&r).show(|ui| {
@@ -816,7 +829,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
if !p.builtin {
r.context_menu(|ui| {
if ui.button("Delete Preset").clicked() {
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("curve.deletePreset", json!({"name": p.name}));
ui.close();
}
@@ -825,28 +838,29 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
ui.separator();
let shaped = current.as_deref() != Some("Linear");
let r = ui.add_enabled(shaped, egui::Button::new("Save Point Curve…"));
let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…")));
register(ui.ctx(), "curvePresetMenu:save", r.rect);
if r.clicked() {
crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name");
ui.close();
}
if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new("Import Presets…")).clicked() {
if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() {
let _ = app.run("file.importCurvePresets", json!({}));
ui.close();
}
let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty();
if ui.add_enabled(can_export, egui::Button::new("Export Presets…")).clicked() {
if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() {
let _ = app.run("file.exportCurvePresets", json!({}));
ui.close();
}
if presets.iter().any(|p| !p.builtin) {
ui.label(RichText::new("Right-click a preset to delete it").font(t.font(11.0)).color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = text_button(ui, "curveReset", "Reset", false)
.on_hover_text("Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)");
let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr(
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)",
));
if r.clicked() {
let _ = app.run("curve.reset", json!({"channel": "all"}));
}
@@ -857,14 +871,15 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
/// Right-click menu of the curve graph.
fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) {
let label = if ch == "parametric" { "Reset Parametric Curve".to_string() } else { format!("Reset {} Channel", channel_label(ch)) };
let label =
if ch == "parametric" { "Reset Parametric Curve".to_string() } else { crate::i18n::tr_format!("Reset {} Channel", channel_label(ch)) };
let r = ui.button(label);
register(ui.ctx(), "curveMenu:resetChannel", r.rect);
if r.clicked() {
let _ = app.run("curve.reset", json!({"channel": ch}));
ui.close();
}
let r = ui.button("Reset All Curves");
let r = ui.button(crate::i18n::tr("Reset All Curves"));
register(ui.ctx(), "curveMenu:resetAll", r.rect);
if r.clicked() {
let _ = app.run("curve.reset", json!({"channel": "all"}));
@@ -925,7 +940,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
let tool = format!("tat:{k}");
let active = app.ui.tool == tool;
if text_button(ui, &format!("tatMixer-{k}"), label, active)
.on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there")
.on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there"))
.clicked()
{
app.ui.tool = if active { String::new() } else { tool };
@@ -937,7 +952,10 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
if crate::is_bw(d) {
ui.horizontal(|ui| {
ui.add_space(24.0);
if crate::widgets::text_button(ui, "bwAuto", "Auto", false).on_hover_text("Set the mix from the photo's colours").clicked() {
if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false)
.on_hover_text(crate::i18n::tr("Set the mix from the photo's colours"))
.clicked()
{
let _ = app.run("develop.autoBwMix", json!({}));
}
});
@@ -994,25 +1012,25 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
});
if n == 0 {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| {
ui.label(egui::RichText::new("Pick a colour on the photo with the eyedropper.").size(12.0).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim));
});
return;
}
for k in ["hueShift", "satShift", "lumShift", "variance"] {
control(app, ui, d, &format!("pointColor.{sel}.{k}"), true);
}
sub_title(ui, "Range");
sub_title(ui, crate::i18n::tr("Range"));
for k in ["range", "hueRange", "satRange", "lumRange"] {
control(app, ui, d, &format!("pointColor.{sel}.{k}"), true);
}
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| {
ui.horizontal(|ui| {
let mut v = app.ui.point_color_visualize;
if ui.checkbox(&mut v, "Visualize range").changed() {
if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() {
app.ui.point_color_visualize = v;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "pointColorDelete", "Delete", false).clicked() {
if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() {
let _ = app.run("pointColor.delete", json!({"index": sel}));
}
});
@@ -1047,9 +1065,9 @@ fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0);
ui.painter().circle_filled(dot, 5.0, Color32::WHITE);
ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK));
ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, label, t.font(12.0), t.text_label);
ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label);
if resp.drag_started() {
let _ = app.run("develop.beginInteraction", json!({"label": format!("{label} Grading")}));
let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)}));
}
if (resp.dragged() || resp.clicked())
&& let Some(q) = resp.interact_pointer_pos()
@@ -1093,7 +1111,7 @@ fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) {
fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
ui.label(egui::RichText::new(format!("Quick Develop · {n} photos")).color(t.text_label).size(12.5));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5));
ui.add_space(4.0);
let rows: [(&str, &str, f64, f64); 9] = [
("Exposure", "light.exposure", 1.0 / 3.0, 1.0),
@@ -1108,11 +1126,11 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
];
egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| {
for (label, ctl, small, big) in rows {
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] {
let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0)));
crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect);
if r.on_hover_text(format!("{label} {d:+} on every selected photo")).clicked() {
if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = label)).clicked() {
let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d}));
}
}
+10 -10
View File
@@ -33,7 +33,7 @@ fn filter(app: &mut LightcraftApp, patch: Value) {
/// A small label before a group of controls.
fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).font(t.font(12.0)).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.font(12.0)).color(t.text_dim));
}
/// A square toggle with an icon or a colour swatch.
@@ -59,13 +59,13 @@ fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&e
t.icon
},
);
resp.on_hover_text(tip)
resp.on_hover_text(crate::i18n::tr(tip))
}
/// A dropdown showing `current`; `items` are (label, filter patch, selected).
fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) {
let t = Tokens::get(ui.ctx());
let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), current, t.font(12.5), if active { t.text } else { t.text_label });
let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), crate::i18n::tr(current), t.font(12.5), if active { t.text } else { t.text_label });
if active {
ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside);
}
@@ -73,7 +73,7 @@ fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, a
egui::Popup::menu(&r).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (label, patch, sel) in &items {
if ui.selectable_label(*sel, label).clicked() {
if ui.selectable_label(*sel, crate::i18n::tr(label)).clicked() {
chosen = Some(patch.clone());
}
}
@@ -260,19 +260,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// save / clear
ui.add_space(14.0);
let filtering = app.session.filter != Default::default();
let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Save as Smart Album…").font(t.font(12.5))));
let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Save as Smart Album…")).font(t.font(12.5))));
register(ui.ctx(), "button:filterSave", save.rect);
if save.clicked() {
let _ = app.run("dialog.newSmartAlbum", json!({}));
}
let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Clear").font(t.font(12.5))));
let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Clear")).font(t.font(12.5))));
register(ui.ctx(), "button:filterClear", clear.rect);
if clear.clicked() {
app.ui.search.clear();
let _ = app.run("library.clearFilter", json!({}));
}
// filter presets: apply one, save the current filter, delete
let presets = ui.button(egui::RichText::new("Presets ▾").font(t.font(12.5)));
let presets = ui.button(egui::RichText::new(crate::i18n::tr("Presets ▾")).font(t.font(12.5)));
register(ui.ctx(), "button:filterPresets", presets.rect);
egui::Popup::menu(&presets).show(|ui| {
let names: Vec<String> = app.session.filter_presets.iter().map(|f| f.name.clone()).collect();
@@ -282,16 +282,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = app.run("filter.applyPreset", json!({"name": n}));
}
r.context_menu(|ui| {
if ui.button("Delete Preset").clicked() {
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("filter.deletePreset", json!({"name": n}));
}
});
}
if names.is_empty() {
ui.label(egui::RichText::new("No filter presets yet").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("No filter presets yet")).color(t.text_dim));
}
ui.separator();
if ui.add_enabled(filtering, egui::Button::new("Save Current Filter as Preset…")).clicked() {
if ui.add_enabled(filtering, egui::Button::new(crate::i18n::tr("Save Current Filter as Preset…"))).clicked() {
crate::panels::dialogs::prompt(app, "Save Filter Preset", "Preset name", "", "filter.savePreset", json!({}), "name");
}
});
+52 -39
View File
@@ -143,7 +143,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
let total = app.session.source_total();
let counted = super::chips::count_text(ids.len(), total, !chips.is_empty());
let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted };
let cnt = if sel_n > 1 { crate::i18n::tr_format!("{sel_n} selected · {counted}", counted = counted, sel_n = sel_n) } else { counted };
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
Some(b) => {
let local = app.caches.grid.local(&app.session.catalog, &ids, generation);
@@ -163,7 +163,9 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if ids.is_empty() {
if !chips.is_empty() {
let body = match total {
Some(n) if n > 0 => format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog)),
Some(n) if n > 0 => {
crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n)
}
_ => "Remove a filter above, or choose Clear all".to_string(),
};
super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body);
@@ -412,7 +414,13 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
let gw = g.size().x;
p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text);
let n = run.count;
p.text(pos2(x + gw + 10.0, r.center().y), Align2::LEFT_CENTER, format!("· {n} photo{}", if n == 1 { "" } else { "s" }), t.font(12.5), t.text_dim);
p.text(
pos2(x + gw + 10.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::tr_format!("· {n} photo{}", if n == 1 { "" } else { "s" }, n = n),
t.font(12.5),
t.text_dim,
);
if resp.clicked() {
let group: Vec<u64> = ids[run.start..(run.start + run.count).min(ids.len())].iter().map(|p| p.0).collect();
let m = ui.input(|i| i.modifiers);
@@ -571,7 +579,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
if let Some(why) = &photo.preview_only {
// a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom
// left, above the badge bar (always shown: it changes what editing does)
let g = p.layout_no_wrap("Preview".into(), t.semibold(9.5), Color32::WHITE);
let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE);
let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0);
let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0));
p.rect_filled(br, 8.0, Color32::from_black_alpha(170));
@@ -579,7 +587,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
register(ui.ctx(), format!("badge:previewOnly:{}", id.0), br);
ui.interact(br, egui::Id::new(("preview-only-badge", id.0)), Sense::hover())
.on_hover_text(format!("Preview only — {}", crate::widgets::preview_only_explanation(why)));
.on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why)));
}
if photo.flag == Flag::Reject {
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
@@ -656,18 +664,18 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &light
ui.add_space(10.0);
// counted once per view (cached); the ids are only gathered on a click
if local_n > 0 {
let label = format!("Add {local_n} to My Photos");
let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n);
if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() {
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
let n = local.len();
match app.run("photo.addToLibrary", json!({"ids": local})) {
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })),
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)),
Err(e) => app.toast(ui.ctx(), e),
}
}
}
let mut sub = b.subfolders;
let c = ui.checkbox(&mut sub, "Include subfolders");
let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders"));
register(ui.ctx(), "check:includeSubfolders", c.rect);
if c.changed() {
let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub}));
@@ -690,7 +698,11 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| {
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(Color32::WHITE).font(t.semibold(12.0)));
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n))
.color(Color32::WHITE)
.font(t.semibold(12.0)),
);
});
});
}
@@ -712,11 +724,11 @@ pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.close();
}
}
if ui.selectable_label(current.is_none(), "None").clicked() {
if ui.selectable_label(current.is_none(), crate::i18n::tr("None")).clicked() {
let _ = app.run("photo.label", json!({"label": "none"}));
}
ui.separator();
if ui.button("Edit Label Names…").clicked() {
if ui.button(crate::i18n::tr("Edit Label Names…")).clicked() {
let _ = app.run("dialog.labelNames", json!({}));
}
}
@@ -755,10 +767,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
if ui.button("Open in Detail").clicked() {
if ui.button(crate::i18n::tr("Open in Detail")).clicked() {
let _ = app.run("view.detail", json!({}));
}
if ui.button("Find Similar Photos").clicked() {
if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() {
match app.run("library.findSimilar", json!({"id": id.0})) {
Ok(r) => {
let n = r["photos"].as_array().map_or(0, Vec::len);
@@ -768,26 +780,26 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button("Set as Reference Photo").clicked() {
if ui.button(crate::i18n::tr("Set as Reference Photo")).clicked() {
let _ = app.run("photo.setReference", json!({"id": id.0}));
}
ui.separator();
ui.menu_button("Set Rating", |ui| {
ui.menu_button(crate::i18n::tr("Set Rating"), |ui| {
for r in 0..=5 {
if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
});
ui.menu_button("Set Flag", |ui| {
ui.menu_button(crate::i18n::tr("Set Flag"), |ui| {
for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] {
if ui.button(l).clicked() {
let _ = app.run("photo.flag", json!({"flag": f}));
}
}
});
ui.menu_button("Set Color Label", |ui| label_menu(app, ui));
ui.menu_button("Add to Album", |ui| {
ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui));
ui.menu_button(crate::i18n::tr("Add to Album"), |ui| {
let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
for (aid, name) in albums {
if ui.button(name).clicked() {
@@ -799,23 +811,23 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source
&& app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart())
{
if ui.button("Remove from Album").clicked() {
if ui.button(crate::i18n::tr("Remove from Album")).clicked() {
let _ = app.run("album.removePhotos", json!({"id": aid.0}));
}
if ui.button("Set as Album Cover").clicked() {
if ui.button(crate::i18n::tr("Set as Album Cover")).clicked() {
let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0}));
}
}
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
let _ = app.run("dialog.rename", json!({}));
}
if ui.button("Create Virtual Copy").clicked() {
if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
}
if ui.button("Create Version").clicked() {
if ui.button(crate::i18n::tr("Create Version")).clicked() {
let _ = app.run("version.create", json!({}));
}
ui.menu_button("Stack", |ui| {
ui.menu_button(crate::i18n::tr("Stack"), |ui| {
let stacked = app.session.catalog.stack_of(id).is_some();
let several = app.session.selection.ids.len() > 1;
for (label, cmd, on) in [
@@ -833,24 +845,24 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
ui.separator();
if ui.button("Auto-Stack by Capture Time…").clicked() {
if ui.button(crate::i18n::tr("Auto-Stack by Capture Time…")).clicked() {
let _ = app.run("dialog.autoStack", json!({}));
}
});
ui.separator();
if ui.button("Copy Edit Settings").clicked() {
if ui.button(crate::i18n::tr("Copy Edit Settings")).clicked() {
let _ = app.run("develop.copy", json!({}));
}
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Edit Settings")).clicked() {
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new(crate::i18n::tr("Paste Edit Settings"))).clicked() {
let _ = app.run("develop.paste", json!({}));
}
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Selected Settings…")).clicked() {
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new(crate::i18n::tr("Paste Selected Settings…"))).clicked() {
let _ = app.run("dialog.pasteSettings", json!({}));
}
if ui.button("Reset Edits").clicked() {
if ui.button(crate::i18n::tr("Reset Edits")).clicked() {
let _ = app.run("develop.reset", json!({}));
}
ui.menu_button("Photo Merge", |ui| {
ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| {
let n = app.session.targets(&json!({})).len();
for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] {
if ui.add_enabled(n >= 2, egui::Button::new(label)).clicked() {
@@ -859,32 +871,33 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.separator();
if ui.button("Rotate Left").clicked() {
if ui.button(crate::i18n::tr("Rotate Left")).clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if ui.button("Rotate Right").clicked() {
if ui.button(crate::i18n::tr("Rotate Right")).clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
ui.separator();
// the original moved or its drive is gone: point the photo at the file again
let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if !std::path::Path::new(path).exists());
if missing && ui.button("Locate Missing File…").clicked() {
// (a cached answer, checked off the UI thread)
let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path));
if missing && ui.button(crate::i18n::tr("Locate Missing File…")).clicked() {
let _ = app.run("photo.locate", json!({}));
}
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new("Show in Finder")).clicked() {
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr("Show in Finder"))).clicked() {
let _ = app.run("app.showInFinder", json!({}));
}
if ui.button("Export…").clicked() {
if ui.button(crate::i18n::tr("Export…")).clicked() {
let _ = app.run("dialog.export", json!({}));
}
ui.menu_button("Export with Preset", |ui| {
ui.menu_button(crate::i18n::tr("Export with Preset"), |ui| {
for (p, _) in app.session.all_export_presets() {
if ui.button(&p.name).clicked() {
match app.run("app.export", json!({"preset": p.name, "background": true})) {
Ok(r) if r.get("background").is_some() => {}
Ok(r) => {
let n = r["files"].as_array().map_or(0, Vec::len);
app.toast(ui.ctx(), format!("Exported {n} photo{}", if n == 1 { "" } else { "s" }));
app.toast(ui.ctx(), crate::i18n::tr_format!("Exported {n} photo{}", if n == 1 { "" } else { "s" }, n = n));
}
Err(e) => app.toast(ui.ctx(), e),
}
@@ -892,7 +905,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.separator();
if ui.button("Delete Photo").clicked() {
if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
let _ = app.run("photo.delete", json!({}));
}
}
+102 -47
View File
@@ -20,11 +20,12 @@ fn row(
selected: bool,
indent: f32,
) -> egui::Response {
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click());
register(ui.ctx(), format!("source:{id}"), r);
let name = match count {
Some(n) => format!("{label}, {n} photos"),
Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = label, n = n),
None => label.to_string(),
};
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
@@ -61,7 +62,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
let counts = app.caches.counts(&app.session.catalog);
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
@@ -85,7 +86,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.add_space(10.0);
// Albums header
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label);
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Albums"), t.semibold(13.5), t.text_label);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
@@ -93,20 +94,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
);
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
egui::Popup::menu(&plus).show(|ui| {
if ui.button("Create Album…").clicked() {
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
}
if ui.button("Create Smart Album…").clicked() {
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
id: None,
name: String::new(),
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
});
}
if ui.button("Create Smart Album from Filter…").clicked() {
if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() });
}
if ui.button("Create Folder…").clicked() {
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
});
@@ -116,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
local_section(app, ui);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
@@ -193,14 +194,15 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let t = Tokens::get(ui.ctx());
let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label);
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Local"), t.semibold(13.5), t.text_label);
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut builtin: Vec<(String, String)> = Vec::new();
if !home.is_empty() {
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
// joined with the platform's separator, like the paths browsing gives back
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
if std::path::Path::new(&p).is_dir() {
// (checked off the UI thread: a home folder can be on a network share)
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
builtin.push((name.to_string(), p));
}
}
@@ -231,9 +233,9 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let hidden = app.ui.hidden_locations.len();
if hidden > 0 {
let label = format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" });
let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden);
if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0)
.on_hover_text("Put the locations you removed from Local back (no files change)")
.on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)"))
.clicked()
{
let _ = app.run("local.restoreHidden", json!({}));
@@ -305,15 +307,13 @@ pub(crate) fn local_places(
out
}
/// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder.
/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every
/// 2 s per folder (empty until the first listing).
fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
let id = egui::Id::new(("subfolders", path.to_string()));
let now = ui.input(|i| i.time);
if let Some((t, v)) = ui.data(|d| d.get_temp::<(f64, Vec<(String, String)>)>(id))
&& now - t < 2.0
{
return v;
}
fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default()
}
fn list_subfolders(path: &str) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = std::fs::read_dir(path)
.map(|rd| {
rd.flatten()
@@ -326,10 +326,59 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
})
.unwrap_or_default();
v.sort_by_key(|(n, _)| n.to_lowercase());
ui.data_mut(|d| d.insert_temp(id, (now, v.clone())));
v
}
/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker
/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a
/// spinning-up drive never blocks a frame. `None` until the first answer arrives.
pub(crate) fn fs_cached<T: Clone + Send + 'static>(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option<T> {
struct Entry<T> {
value: Option<T>,
at: Option<f64>,
running: bool,
}
type Cell<T> = std::sync::Arc<std::sync::Mutex<Entry<T>>>;
let id = egui::Id::new(("fs-cached", kind, path.to_string()));
let now = ui.input(|i| i.time);
let cell: Cell<T> = match ui.data(|d| d.get_temp::<Cell<T>>(id)) {
Some(c) => c,
None => {
let c: Cell<T> = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false }));
ui.data_mut(|d| d.insert_temp(id, c.clone()));
c
}
};
let lock = |c: &Cell<T>| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone();
let start = {
let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at);
if due {
e.running = true;
e.at = Some(now);
}
due
};
if start {
let (out, path, repaint) = (cell.clone(), path.to_string(), ui.ctx().clone());
let work = move || {
let v = f(&path);
let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
e.value = Some(v);
e.running = false;
drop(e);
repaint.request_repaint();
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() {
cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false;
}
#[cfg(target_arch = "wasm32")]
work();
}
lock(&cell)
}
/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its
/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the
/// way down to it open whenever the browsed folder changes, so it shows highlighted in place.
@@ -384,20 +433,26 @@ fn folder_tree(
}
resp.context_menu(|ui| {
if (transient || indent > 0.0)
&& ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked()
&& ui
.button(if transient { "Keep in Local" } else { "Add to Local" })
.on_hover_text(crate::i18n::tr("List this folder in Local from now on"))
.clicked()
{
let _ = app.run("local.addRoot", json!({"path": path}));
ui.close();
}
if indent == 0.0
&& ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked()
&& ui
.button(crate::i18n::tr("Remove from Local"))
.on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are"))
.clicked()
{
let _ = app.run("local.hide", json!({"path": path}));
ui.close();
}
if ui.button("Rename Folder…").clicked() {
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: format!("Rename “{name}”"),
title: crate::i18n::tr_format!("Rename “{name}”", name = name),
hint: "Folder name (renamed on disk; its photos follow)".into(),
value: name.to_string(),
command: "folder.rename".into(),
@@ -405,7 +460,7 @@ fn folder_tree(
key: "name".into(),
});
}
if app.services.pick_folder.is_some() && ui.button("Move Folder To…").clicked() {
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() {
let into = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(into) = into {
match app.run("folder.move", json!({"path": path, "into": into})) {
@@ -415,7 +470,7 @@ fn folder_tree(
}
}
if app.services.reveal.is_some()
&& ui.button("Show in Finder").clicked()
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
&& let Some(f) = app.services.reveal.as_mut()
{
let _ = f(path);
@@ -489,7 +544,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
drop_target(app, ui, &resp, a);
}
if let Some(rules) = &a.smart {
resp = resp.on_hover_text(format!("Smart album: {}", rules.describe()));
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", rules.describe()));
}
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
@@ -512,7 +567,7 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
if ui.input(|i| i.pointer.any_released()) {
let n = ids.len();
match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) {
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name)),
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n)),
Err(e) => app.toast(ui.ctx(), e),
}
app.ui.dragging_photos = None;
@@ -521,27 +576,27 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
resp.context_menu(|ui| {
if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() {
if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() {
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
}
if !a.folder && !a.is_smart() {
let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick);
if !is_target && ui.button("Set as Target Album (B adds to it)").clicked() {
if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() {
let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }}));
}
if is_target && !a.quick && ui.button("Stop Using as Target Album").clicked() {
if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() {
let _ = app.run("album.setTarget", json!({"id": null}));
}
}
if a.quick && ui.button("Clear Quick Collection").clicked() {
if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() {
let _ = app.run("album.clearQuick", json!({}));
}
if a.is_smart() && ui.button("Edit Smart Album…").clicked() {
if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() {
// older smart albums keep their filter fields; the editor works on the rule set
let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default();
app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules });
}
if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() {
if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() {
let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true}));
}
if !a.folder {
@@ -551,12 +606,12 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
let _ = app.run("library.selectAll", json!({}));
};
let has_photos = app.session.catalog.album_count(a.id) > 0;
if ui.add_enabled(has_photos, egui::Button::new("Export Album…")).clicked() {
if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() {
show_all(app);
let _ = app.run("dialog.export", json!({}));
}
ui.add_enabled_ui(has_photos, |ui| {
ui.menu_button("Export Album with Preset", |ui| {
ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| {
for (p, _) in app.session.all_export_presets() {
if ui.button(&p.name).clicked() {
show_all(app);
@@ -573,8 +628,8 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
let mut folders: Vec<(u64, String)> =
app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect();
folders.sort_by_key(|(_, n)| n.to_lowercase());
ui.menu_button("Move to", |ui| {
if ui.add_enabled(a.parent.is_some(), egui::Button::new("Top Level")).clicked() {
ui.menu_button(crate::i18n::tr("Move to"), |ui| {
if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() {
let _ = app.run("album.move", json!({"id": a.id.0, "parent": null}));
}
for (fid, name) in &folders {
@@ -583,10 +638,10 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
}
}
});
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
}
if ui.button("Delete").clicked() {
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("album.delete", json!({"id": a.id.0}));
}
});
@@ -620,7 +675,7 @@ fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.add_space(10.0);
let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, "Keywords", t.semibold(13.5), t.text_label);
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Keywords"), t.semibold(13.5), t.text_label);
keyword_rows(app, ui, &tree, 0.0);
}
@@ -656,20 +711,20 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
}
resp.context_menu(|ui| {
let has_sel = app.session.active().is_some();
if ui.add_enabled(has_sel, egui::Button::new("Add to Selected Photos")).clicked() {
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
}
if ui.add_enabled(has_sel, egui::Button::new("Remove from Selected Photos")).clicked() {
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
}
ui.separator();
if ui.button("Rename Keyword…").clicked() {
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
}
if ui.button("Merge into…").clicked() {
if ui.button(crate::i18n::tr("Merge into…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
}
if ui.button("Delete Keyword").clicked() {
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
}
});
+8 -6
View File
@@ -94,7 +94,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
let mut choice = None;
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
egui::Window::new("Your library couldn't be opened")
egui::Window::new(crate::i18n::tr("Your library couldn't be opened"))
.id(egui::Id::new("library-problem"))
.order(egui::Order::Foreground)
.collapsible(false)
@@ -107,9 +107,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.set_width(440.0);
ui.spacing_mut().item_spacing.y = 8.0;
if problem.path.is_empty() {
ui.label(RichText::new("LightCraft couldn't find where to keep your library.").color(t.text));
ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't find where to keep your library.")).color(t.text));
} else {
ui.label(RichText::new("LightCraft couldn't open the library at").color(t.text_label));
ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't open the library at")).color(t.text_label));
ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text));
}
ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap());
@@ -124,7 +124,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| {
let r = ui.add_enabled(enabled, egui::Button::new(label).min_size(vec2(0.0, 26.0)));
let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0)));
register(ui.ctx(), format!("button:{id}"), r.rect);
if r.clicked() {
choice = Some(c);
@@ -166,12 +166,14 @@ pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
let r = ui.label(
RichText::new("Temporary session — your library isn't open, and nothing you do here is saved.")
RichText::new(crate::i18n::tr("Temporary session — your library isn't open, and nothing you do here is saved."))
.font(t.semibold(12.5))
.color(t.canvas),
);
register(ui.ctx(), "indicator:temporarySession", r.rect);
let b = ui.add(egui::Button::new(RichText::new("Open Library…").color(t.canvas)).fill(egui::Color32::from_black_alpha(40)));
let b = ui.add(
egui::Button::new(RichText::new(crate::i18n::tr("Open Library…")).color(t.canvas)).fill(egui::Color32::from_black_alpha(40)),
);
register(ui.ctx(), "button:libraryReopen", b.rect);
reopen = b.clicked();
});
+34 -26
View File
@@ -73,7 +73,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Masking");
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
ui.label(egui::RichText::new("Create New Mask").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Create New Mask")).color(t.text_dim));
ui.add_space(6.0);
let tiles: [(&str, &str, Icon); 8] = [
("subject", "Subject", Icon::Subject),
@@ -123,7 +123,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// mask list
egui::Frame::NONE.inner_margin(egui::Margin { left: 16, right: 16, top: 8, bottom: 8 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Masks").font(t.semibold(13.0)).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr("Masks")).font(t.semibold(13.0)).color(t.text_label));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let on = app.ui.mask_overlay;
if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() {
@@ -132,7 +132,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
});
if d.masks.is_empty() {
ui.label(egui::RichText::new("No masks yet. Choose a mask type above.").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("No masks yet. Choose a mask type above.")).color(t.text_dim));
}
let count = d.masks.len();
for (index, m) in d.masks.iter().enumerate() {
@@ -254,7 +254,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let more = ui.small_button("…").on_hover_text("Component options");
let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options"));
register(ui.ctx(), format!("button:componentMenu{i}"), more.rect);
egui::Popup::menu(&more).show(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
});
@@ -265,13 +265,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// wraps: the five actions are wider than a narrow panel
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let add = text_button(ui, "maskAddComp", "Add", false);
let add = text_button(ui, "maskAddComp", crate::i18n::tr("Add"), false);
egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add"));
let sub = text_button(ui, "maskSubComp", "Subtract", false);
let sub = text_button(ui, "maskSubComp", crate::i18n::tr("Subtract"), false);
egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract"));
let int = text_button(ui, "maskIntComp", "Intersect", false);
let int = text_button(ui, "maskIntComp", crate::i18n::tr("Intersect"), false);
egui::Popup::menu(&int).show(|ui| component_menu(app, ui, "intersect"));
if text_button(ui, "maskInvert", "Invert", m.invert).clicked() {
if text_button(ui, "maskInvert", crate::i18n::tr("Invert"), m.invert).clicked() {
let _ = app.run("mask.invert", json!({}));
}
if icon_button(ui, "maskDelete", Icon::Trash, vec2(26.0, 24.0), false, true, "Delete mask").clicked() {
@@ -335,11 +335,11 @@ fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, vi
run(ui, "Move Up", index > 0, "mask.move", json!({"id": id, "delta": -1}));
run(ui, "Move Down", index + 1 < count, "mask.move", json!({"id": id, "delta": 1}));
ui.separator();
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
app.ui.renaming_mask = Some((id, name.to_string()));
ui.close();
}
if ui.button("Delete Mask").clicked() {
if ui.button(crate::i18n::tr("Delete Mask")).clicked() {
let _ = app.run("mask.delete", json!({"id": id}));
ui.close();
}
@@ -354,7 +354,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Overlay").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Overlay")).color(t.text_dim));
let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for v in MaskView::ALL {
@@ -396,7 +396,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.mask_overlay_opacity = v as f32;
}
let mut pins = app.ui.mask_pins;
if ui.checkbox(&mut pins, "Show Pins").changed() {
if ui.checkbox(&mut pins, crate::i18n::tr("Show Pins")).changed() {
let _ = app.run("view.maskPins", json!({"show": pins}));
}
}
@@ -410,7 +410,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
};
match shape {
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
ui.label(egui::RichText::new("Select Luminance Range").color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(crate::i18n::tr("Select Luminance Range")).color(t.text_dim).size(11.5));
let (lo, hi) = (*lo, *hi);
let w = ui.available_width().min(240.0);
let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag());
@@ -471,7 +471,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
app.run("mask.update", json!({"component": comp, "shape": MaskShape::LuminanceRange { lo, hi, lo_feather: f, hi_feather: f }}))
});
let mut map = app.ui.mask_overlay && app.ui.mask_overlay_mode == "colorOnBw";
let r = ui.checkbox(&mut map, "Show Luminance Map");
let r = ui.checkbox(&mut map, crate::i18n::tr("Show Luminance Map"));
register(ui.ctx(), format!("check:lumMap{comp}"), r.rect);
if r.changed() {
// the luminance map: the photo in black & white with the selected range tinted
@@ -494,8 +494,8 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
.size(11.5),
);
let picking = app.ui.tool == "colorRange";
if text_button(ui, &format!("colorPick{comp}"), "Pick", picking)
.on_hover_text("Click the photo to pick a colour; ⇧-click adds more (up to 5)")
if text_button(ui, &format!("colorPick{comp}"), crate::i18n::tr("Pick"), picking)
.on_hover_text(crate::i18n::tr("Click the photo to pick a colour; ⇧-click adds more (up to 5)"))
.clicked()
{
app.ui.tool = if picking { String::new() } else { "colorRange".into() };
@@ -534,22 +534,26 @@ fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k:
}
};
run(ui, "Invert", json!({"action": "invert"}));
run(ui, &format!("Duplicate \"{label}\""), json!({"action": "duplicate"}));
run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"}));
if k > 0 {
ui.menu_button("Mode", |ui| {
ui.menu_button(crate::i18n::tr("Mode"), |ui| {
for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] {
run(ui, text, json!({"action": "op", "op": op}));
}
});
}
ui.menu_button("Intersect with", |ui| component_menu(app, ui, "intersect"));
ui.menu_button("Subtract", |ui| component_menu(app, ui, "subtract"));
ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect"));
ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract"));
ui.separator();
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
app.ui.renaming_component = Some((mask, k, label.to_string()));
ui.close();
}
let del = if count == 1 { format!("Delete \"{label}\" (and the mask)") } else { format!("Delete \"{label}\"") };
let del = if count == 1 {
crate::i18n::tr_format!("Delete \"{label}\" (and the mask)", label = label)
} else {
crate::i18n::tr_format!("Delete \"{label}\"", label = label)
};
if ui.button(del).clicked() {
let _ = app.run("mask.component", json!({"id": mask, "component": k, "action": "delete"}));
ui.close();
@@ -584,16 +588,20 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Brush").font(t.semibold(13.0)));
if text_button(ui, "brushAdd", "Add", !app.ui.brush_erase).clicked() {
ui.label(egui::RichText::new(crate::i18n::tr("Brush")).font(t.semibold(13.0)));
if text_button(ui, "brushAdd", crate::i18n::tr("Add"), !app.ui.brush_erase).clicked() {
app.ui.brush_erase = false;
}
if text_button(ui, "brushErase", "Erase", app.ui.brush_erase).clicked() {
if text_button(ui, "brushErase", crate::i18n::tr("Erase"), app.ui.brush_erase).clicked() {
app.ui.brush_erase = true;
}
});
let mut auto = app.ui.brush_auto_mask;
if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() {
if ui
.checkbox(&mut auto, crate::i18n::tr("Auto Mask"))
.on_hover_text(crate::i18n::tr("Paint only areas like the one under the brush"))
.changed()
{
app.ui.brush_auto_mask = auto;
}
});
+6 -4
View File
@@ -71,8 +71,10 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
let galley = painter.layout_no_wrap(text, t.font(14.0), t.text.gamma_multiply(fade));
let size = vec2(galley.size().x + 40.0, 40.0);
// long messages (a save warning, the web build's notices) wrap within the canvas
let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0);
let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap);
let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0));
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8));
painter.galley(r.center() - galley.size() / 2.0, galley, t.text);
@@ -83,6 +85,6 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) {
let t = Tokens::get(ui.ctx());
let p = ui.painter();
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, title, t.semibold(18.0), t.text_label);
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, body, t.font(13.0), t.text_dim);
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label);
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim);
}
+36 -13
View File
@@ -26,7 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Presets", t.semibold(15.0), t.text);
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom()))
@@ -38,14 +38,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, "Show Thumbnails");
let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails"));
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button("Import Presets…").clicked() {
if ui.button(crate::i18n::tr("Import Presets…")).clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new("Export User Presets…")).clicked() {
if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
@@ -78,7 +78,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
@@ -99,7 +100,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button("Export Group…").clicked() {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
@@ -141,12 +142,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if resp.hovered()
&& let Some(s) = look
{
app.hover_preview = Some(HoverPreview { label: format!("Preset: {name}"), settings: s });
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
}
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
app.toast(ui.ctx(), format!("Preset: {name}"));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
@@ -157,13 +163,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
return;
}
ui.separator();
if ui.add_enabled(app.session.active().is_some(), egui::Button::new("Update with Current Settings")).clicked() {
if ui
.add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings")))
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Rename Preset",
@@ -174,7 +183,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
"name",
);
}
ui.menu_button("Move to Group", |ui| {
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| {
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
groups.sort();
groups.dedup();
@@ -183,7 +192,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
}
}
if ui.button("New Group…").clicked() {
if ui.button(crate::i18n::tr("New Group…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Move Preset to New Group",
@@ -196,7 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
ui.separator();
if ui.button("Delete Preset").clicked() {
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
@@ -204,6 +213,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
&& let (Some(id), Some(curr)) = (active, current.as_ref())
&& let Some((saved_id, saved_preset, saved_label)) =
ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id))
&& saved_id == id
{
app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) });
}
} else {
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id));
}
});
}
+19 -3
View File
@@ -29,12 +29,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if icon_button(&mut back, "profilesBack", Icon::ChevronLeft, vec2(28.0, 28.0), false, true, "Back to Edit").clicked() {
app.ui.right = RightPanel::Edit;
}
ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Profiles", t.semibold(15.0), t.text);
ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Profiles"), t.semibold(15.0), t.text);
divider(ui);
// the applied profile and its amount
let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, "Profile", t.font(13.0), t.text_dim);
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim);
ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label);
if d.profile.id != "lc.color" {
super::edit::control(app, ui, &d, "profile.amount", true);
@@ -81,6 +81,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(6.0);
}
ui.add_space(30.0);
let last_hover_id = egui::Id::new("last-profile-hover");
let pointer_in_profiles = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| ui.max_rect().contains(pos));
if pointer_in_profiles {
if app.hover_preview.is_none()
&& let Some((saved_id, saved_profile_id)) = ui.data(|m| m.get_temp::<(PhotoId, String)>(last_hover_id))
&& saved_id == id
&& let Some(p) = profile(&saved_profile_id)
{
let s = with_profile(&d, p);
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
}
} else {
ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id));
}
}
/// `d` with profile `p` (at 100 % unless it is the applied one).
@@ -126,7 +140,8 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE });
}
if resp.hovered() && !applied && !over_star {
app.hover_preview = Some(HoverPreview { label: format!("Profile: {}", p.name), settings: s.clone() });
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string())));
}
let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group));
if resp.clicked() {
@@ -134,6 +149,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
let _ = app.run("profile.favorite", json!({"id": p.id}));
} else if !applied {
let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100}));
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover")));
}
}
}
+99 -65
View File
@@ -54,7 +54,7 @@ pub fn header(ui: &mut egui::Ui, title: &str) {
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, label, t.font(12.5), t.text_dim);
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(label), t.font(12.5), t.text_dim);
ui.painter().text(pos2(r.left() + 110.0, r.center().y), Align2::LEFT_CENTER, value, t.font(12.5), t.text_label);
}
@@ -67,7 +67,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
header(ui, "Crop");
padded(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Aspect Ratio");
ui.label(crate::i18n::tr("Aspect Ratio"));
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
let r = ui.add(egui::Button::new(cur).frame(false));
register(ui.ctx(), "button:cropAspect", r.rect);
@@ -92,25 +92,25 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
if text_button(ui, "cropRotateLeft", "Rotate Left", false).clicked() {
if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if text_button(ui, "cropRotateRight", "Rotate Right", false).clicked() {
if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() {
if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() {
let _ = app.run("photo.flipHorizontal", json!({}));
}
if text_button(ui, "cropFlipV", "Flip V", d.crop.flip_v).clicked() {
if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() {
let _ = app.run("photo.flipVertical", json!({}));
}
if text_button(ui, "cropSwapAspect", "Swap Aspect (X)", false).clicked() {
if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() {
let _ = app.run("crop.rotateAspect", json!({}));
}
if text_button(ui, "cropReset", "Reset", false).clicked() {
if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() {
let _ = app.run("crop.reset", json!({}));
}
});
@@ -123,16 +123,19 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
ui.horizontal(|ui| {
let on = app.ui.tool == "straighten";
if text_button(ui, "straightenTool", "Straighten Tool", on).on_hover_text("Drag along the horizon; double-click for Auto").clicked() {
if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on)
.on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto"))
.clicked()
{
app.ui.tool = if on { String::new() } else { "straighten".into() };
}
if text_button(ui, "straightenAuto", "Auto", false).clicked() {
if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() {
let _ = app.run("crop.autoStraighten", json!({}));
}
});
});
padded(ui, |ui| {
ui.label("Overlay");
ui.label(crate::i18n::tr("Overlay"));
{
use crate::state::CropOverlay as O;
let opts = [
@@ -149,7 +152,8 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) {
app.ui.crop_overlay = opts[i].0;
}
if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) && text_button(ui, "cropOverlayOrient", "Flip Overlay (⇧O)", false).clicked()
if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral)
&& text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked()
{
let _ = app.run("view.cropOverlayOrientation", json!({}));
}
@@ -158,7 +162,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
divider(ui);
header(ui, "Geometry");
padded(ui, |ui| {
ui.label("Upright");
ui.label(crate::i18n::tr("Upright"));
{
use lightcraft_develop::Upright;
let modes = [
@@ -179,30 +183,36 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
let guided = d.geometry.upright == lightcraft_develop::Upright::Guided;
ui.horizontal_wrapped(|ui| {
if !guided && d.geometry.upright != lightcraft_develop::Upright::Off && text_button(ui, "uprightUpdate", "Update", false).clicked() {
if !guided
&& d.geometry.upright != lightcraft_develop::Upright::Off
&& text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked()
{
let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default();
let _ = app.run("geometry.upright", json!({"mode": mode}));
}
if guided {
let drawing = app.ui.tool == "guidedUpright";
if text_button(ui, "uprightDraw", "Draw Guides", drawing).clicked() {
if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() {
app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() };
}
if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", "Clear Guides", false).clicked() {
if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() {
let _ = app.run("geometry.guides", json!({"guides": []}));
}
}
});
if guided {
ui.label(
egui::RichText::new(format!("{} of 4 guides — drag along lines that should be vertical or horizontal.", d.geometry.guides.len()))
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
egui::RichText::new(crate::i18n::tr_format!(
"{} of 4 guides — drag along lines that should be vertical or horizontal.",
d.geometry.guides.len()
))
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
let mut c = d.geometry.constrain_crop;
if ui.checkbox(&mut c, "Constrain Crop").changed() {
if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"}));
}
});
@@ -225,10 +235,10 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.add_space(8.0);
ui.label(format!("{} spot(s) on this photo", d.spots.len()));
ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len()));
ui.add_space(4.0);
if text_button(ui, "findDust", "Find Dust Spots", false)
.on_hover_text("Heal the small, soft dark spots sensor dust leaves on smooth areas")
if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false)
.on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas"))
.clicked()
{
match app.run("spot.findDust", json!({})) {
@@ -236,14 +246,18 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let n = r["added"].as_u64().unwrap_or(0);
app.toast(
ui.ctx(),
if n == 0 { "No dust spots found".to_string() } else { format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }) },
if n == 0 {
"No dust spots found".to_string()
} else {
crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n)
},
);
}
Err(e) => app.toast(ui.ctx(), e),
}
}
ui.add_space(4.0);
ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim));
});
// brush settings; with a spot selected they edit that spot too
let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone())));
@@ -255,7 +269,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
lightcraft_develop::SpotMode::Clone => "Clone",
lightcraft_develop::SpotMode::Remove => "Remove",
};
super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len()));
super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode));
}
let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec {
id,
@@ -290,10 +304,10 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if sel.is_some() {
padded(ui, |ui| {
ui.horizontal(|ui| {
if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() {
if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() {
let _ = app.run("spot.refreshSource", json!({}));
}
if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() {
if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() {
let _ = app.run("spot.delete", json!({}));
}
});
@@ -302,7 +316,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out
padded(ui, |ui| {
let mut v = app.ui.visualize_spots;
if ui.checkbox(&mut v, "Visualize Spots (A)").changed() {
if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() {
app.ui.visualize_spots = v;
}
});
@@ -322,7 +336,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
app.ui.spots_threshold = v as f32;
}
padded(ui, |ui| {
if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() {
if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() {
for i in (0..d.spots.len()).rev() {
let _ = app.run("spot.delete", json!({"index": i}));
}
@@ -343,7 +357,8 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
ui.add_space(8.0);
ui.label(
egui::RichText::new("Drag over an eye on the photo; the pupil inside is found automatically.").color(Tokens::get(ui.ctx()).text_dim),
egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically."))
.color(Tokens::get(ui.ctx()).text_dim),
);
});
let n = d.red_eye.len();
@@ -367,16 +382,16 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
if eye.pet {
let mut on = eye.catchlight.is_some();
if ui.checkbox(&mut on, "Add Catchlight").changed() {
if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() {
let _ = app.run("redeye.catchlight", json!({"index": i, "on": on}));
}
ui.add_space(4.0);
}
ui.horizontal(|ui| {
if text_button(ui, "eyeDelete", "Delete", false).clicked() {
if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() {
let _ = app.run("redeye.delete", json!({"index": i}));
}
if n > 1 && text_button(ui, "eyeDeleteAll", "Delete all", false).clicked() {
if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() {
for k in (0..n).rev() {
let _ = app.run("redeye.delete", json!({"index": k}));
}
@@ -515,7 +530,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" });
ui.horizontal(|ui| {
ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim));
if text_button(ui, "showOnMap", "Show on Map", false).on_hover_text("Open the place in OpenStreetMap").clicked() {
if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false)
.on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap"))
.clicked()
{
let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}");
if let Err(e) = crate::links::open(app, &url) {
app.toast(ui.ctx(), e);
@@ -535,9 +553,16 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.label(egui::RichText::new(line).color(t.text_dim));
}
// offline originals / smart previews
// (cached answers, checked off the UI thread: unknown counts as online / no smart preview)
if let lightcraft_catalog::Source::File { path } = &p.source {
let online = std::path::Path::new(path).exists();
let smart = lightcraft_engine::cmd::previews::has_smart_preview(&app.session, p.id);
let avail = &app.session.media.availability;
let online = !avail.is_offline(path);
let smart = app
.session
.media
.smart_dir
.as_ref()
.is_some_and(|d| avail.exists(&d.join(lightcraft_engine::smart::file_name(&p)).to_string_lossy()) == Some(true));
if !online || smart {
let text = match (online, smart) {
(false, true) => "Original offline · editing the smart preview",
@@ -549,7 +574,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
ui.add_space(10.0);
if text_button(ui, "allMetadata", "All Metadata…", false).on_hover_text("Every EXIF, GPS and XMP field in the file").clicked() {
if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false)
.on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file"))
.clicked()
{
let _ = app.run("dialog.allMetadata", json!({}));
}
});
@@ -609,10 +637,10 @@ fn human_size(bytes: u64) -> String {
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`).
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Copyright Status").size(11.5).color(t.text_dim));
let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(current.label()).show_ui(ui, |ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| {
for st in lightcraft_catalog::CopyrightStatus::ALL {
if ui.selectable_label(st == current, st.label()).clicked() && st != current {
if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current {
let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()}));
}
}
@@ -647,7 +675,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
let kid = egui::Id::new("kw-input");
let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("Add keyword").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("Add keyword")).desired_width(f32::INFINITY));
register(ui.ctx(), "field:keyword", r.rect);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() {
let kws: Vec<String> = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
@@ -665,17 +693,17 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
}
r.context_menu(|ui| {
if ui.button("Remove from Photo").clicked() {
if ui.button(crate::i18n::tr("Remove from Photo")).clicked() {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
}
if ui.button("Show Photos with Keyword").clicked() {
if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() {
let _ = app.run("library.filter", json!({"keyword": k}));
}
ui.separator();
if ui.button("Rename Keyword…").clicked() {
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() });
}
if ui.button("Delete Keyword").clicked() {
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
let _ = app.run("keyword.delete", json!({"keyword": k}));
}
});
@@ -689,12 +717,15 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let pid = egui::Id::new("kw-painter");
let painting = app.ui.keyword_painter.clone();
let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default());
let r = ui.add_enabled(painting.is_none(), egui::TextEdit::singleline(&mut k).hint_text("Keyword to paint").desired_width(130.0));
let r = ui.add_enabled(
painting.is_none(),
egui::TextEdit::singleline(&mut k).hint_text(crate::i18n::tr("Keyword to paint")).desired_width(130.0),
);
register(ui.ctx(), "field:keywordPainter", r.rect);
ui.data_mut(|d| d.insert_temp(pid, k.clone()));
let label = if painting.is_some() { "Stop" } else { "Paint" };
if text_button(ui, "keywordPaint", label, painting.is_some())
.on_hover_text("Click photos in the grid to toggle the keyword; Esc stops")
.on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops"))
.clicked()
{
let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }}));
@@ -707,7 +738,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let last = typed.rsplit(',').next().unwrap_or("").trim().to_string();
let suggestions = (*app.caches.suggestions(&app.session.catalog, &p.meta.keywords, &last, 12)).clone();
if !suggestions.is_empty() {
ui.label(egui::RichText::new("Suggestions").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim));
ui.horizontal_wrapped(|ui| {
for k in suggestions {
let r = ui
@@ -732,7 +763,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session);
let current = sets["current"].as_str().unwrap_or_default().to_string();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Keyword Set").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim));
egui::ComboBox::from_id_salt("kw-set").selected_text(&current).show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
@@ -741,7 +772,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
}
}
ui.separator();
if ui.button("Save Current Keywords as Set…").clicked() {
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
@@ -751,14 +782,16 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT && ui.button(format!("Delete “{current}”")).clicked() {
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
});
let kws: Vec<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
if kws.is_empty() {
ui.label(egui::RichText::new("Keywords you add appear here; ⌥1–⌥9 apply them.").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim));
return;
}
let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0);
@@ -788,7 +821,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
header(ui, "Versions");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
if text_button(ui, "versionCreate", "Create Version", false).clicked() {
if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() {
let _ = app.run("version.create", json!({}));
}
ui.add_space(8.0);
@@ -797,7 +830,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
let labels = [format!("Named ({named_n})"), format!("Auto ({})", p.versions.len() - named_n)];
let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)];
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
auto = i == 1;
@@ -840,7 +873,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
// resting on a version shows it in the loupe (nothing is changed)
if resp.hovered() && !current {
app.hover_preview = Some(crate::HoverPreview { label: format!("Version: {}", v.name), settings: (*v.settings).clone() });
app.hover_preview =
Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() });
}
if resp.double_clicked() {
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
@@ -849,20 +883,20 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu(
|ui| {
if ui.button("Restore").clicked() {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
if ui.button("Update with Current Settings").clicked() {
if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() {
let _ = app.run("version.update", json!({"index": i}));
}
if ui.button("Rename…").clicked() {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
}
if ui.button("Set as Before").clicked() {
if ui.button(crate::i18n::tr("Set as Before")).clicked() {
let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name}));
}
ui.separator();
if ui.button("Delete").clicked() {
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
},
@@ -892,7 +926,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
if p.history.is_empty() {
ui.label(egui::RichText::new("No edits yet.").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim));
}
for (i, h) in p.history.iter().enumerate().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
@@ -906,15 +940,15 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let _ = app.run("history.restore", json!({"index": i}));
}
resp.context_menu(|ui| {
if ui.button("Copy History Step Settings to Before").clicked() {
if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() {
let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i}));
}
if ui.button("Create Version from Step").clicked() {
if ui.button(crate::i18n::tr("Create Version from Step")).clicked() {
let _ = app.run("history.restore", json!({"index": i}));
let _ = app.run("version.create", json!({"name": h.label}));
}
ui.separator();
if ui.button("Clear History").clicked() {
if ui.button(crate::i18n::tr("Clear History")).clicked() {
let _ = app.run("history.clear", json!({}));
}
});
+7 -7
View File
@@ -98,11 +98,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
let Value::Array(a) = v else { return };
if k == Kind::Number {
number_value(ui, &mut a[0], field);
ui.label("and");
ui.label(crate::i18n::tr("and"));
number_value(ui, &mut a[1], field);
} else {
text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a"));
ui.label("and");
ui.label(crate::i18n::tr("and"));
text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b"));
}
}
@@ -134,9 +134,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new("Match").color(t.text_label));
ui.label(RichText::new(crate::i18n::tr("Match")).color(t.text_label));
match_combo(ui, salt, &mut rs.mode);
ui.label(RichText::new("of the following rules:").color(t.text_label));
ui.label(RichText::new(crate::i18n::tr("of the following rules:")).color(t.text_label));
});
let mut remove = None;
let mut insert: Option<(usize, Rule)> = None;
@@ -146,7 +146,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
Rule::Group { group } => {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
if ui.small_button("−").on_hover_text("Remove this group").clicked() {
if ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this group")).clicked() {
remove = Some(i);
}
ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1));
@@ -182,7 +182,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
});
value_editor(ui, field, op, value, &rsalt);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add = ui.small_button("+").on_hover_text("Add a rule (⌥-click: a group)");
let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)"));
register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect);
if add.clicked() {
let group = ui.input(|i| i.modifiers.alt) && depth < 3;
@@ -191,7 +191,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() },
));
}
let del = ui.small_button("−").on_hover_text("Remove this rule");
let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule"));
register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect);
if del.clicked() {
remove = Some(i);
+7 -1
View File
@@ -26,7 +26,13 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) {
crate::widgets::register(ui.ctx(), "view:secondWindow", area);
ui.allocate_rect(area, egui::Sense::hover());
let Some(id) = app.session.active() else {
ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, "No photo selected", egui::FontId::proportional(14.0), Color32::GRAY);
ui.painter().text(
area.center(),
egui::Align2::CENTER_CENTER,
crate::i18n::tr("No photo selected"),
egui::FontId::proportional(14.0),
Color32::GRAY,
);
return;
};
// a render sized for this window
+86 -66
View File
@@ -49,14 +49,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.add_space(4.0);
ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text));
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
}
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(LABEL_W);
ui.label(RichText::new(label).color(t.text_label));
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})
@@ -64,12 +64,12 @@ fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui
}
fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.label(RichText::new(text).size(11.0).color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim));
}
/// A checkbox addressable as `check:{id}`; true when toggled.
fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
let r = ui.checkbox(value, label);
let r = ui.checkbox(value, crate::i18n::tr(label));
register(ui.ctx(), format!("check:{id}"), r.rect);
r.changed()
}
@@ -90,27 +90,31 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str
// ------------------------------------------------------------------------------------- General
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Library");
row(ui, t, crate::i18n::tr("Language"), |ui| {
choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language);
crate::i18n::set_language(app.ui.language);
});
heading(ui, t, crate::i18n::tr("Library"));
let location = match &app.session.library {
Some(l) => l.dir.display().to_string(),
None => "In memory — nothing is saved".to_string(),
None => crate::i18n::tr("In memory — nothing is saved").to_string(),
};
row(ui, t, "Location", |ui| {
row(ui, t, crate::i18n::tr("Location"), |ui| {
ui.label(RichText::new(location).color(t.text));
});
row(ui, t, "", |ui| {
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new("Open Library…"));
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…")));
register(ui.ctx(), "button:settingsOpenLibrary", r.rect);
if r.clicked() {
let _ = app.run("app.openLibrary", json!({}));
}
if !can {
hint(ui, t, "not available here");
hint(ui, t, crate::i18n::tr("not available here"));
}
});
heading(ui, t, "Startup");
row(ui, t, "Open in", |ui| {
heading(ui, t, crate::i18n::tr("Startup"));
row(ui, t, crate::i18n::tr("Open in"), |ui| {
choices(
ui,
"settingsStartup",
@@ -118,18 +122,21 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
&mut app.ui.settings.startup_view,
);
});
heading(ui, t, "Culling");
heading(ui, t, crate::i18n::tr("Culling"));
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
heading(ui, t, "External Editor");
row(ui, t, "Application", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text("System default").desired_width(220.0));
heading(ui, t, crate::i18n::tr("External Editor"));
row(ui, t, crate::i18n::tr("Application"), |ui| {
let r = ui
.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0));
register(ui.ctx(), "field:externalEditor", r.rect);
});
hint(
ui,
t,
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).",
crate::i18n::tr(
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).",
),
);
}
@@ -176,9 +183,9 @@ fn cameras(app: &LightcraftApp) -> Vec<String> {
fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let d = app.session.import_defaults.clone();
heading(ui, t, "Raw defaults");
hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library.");
row(ui, t, "Raw photos", |ui| {
heading(ui, t, crate::i18n::tr("Raw defaults"));
hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library."));
row(ui, t, crate::i18n::tr("Raw photos"), |ui| {
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") {
let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}}));
}
@@ -192,7 +199,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
if per {
let cams = cameras(app);
if cams.is_empty() {
hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library.");
hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library."));
}
egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| {
for (i, cam) in cams.iter().enumerate() {
@@ -212,10 +219,10 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
let id = format!("settingsCamera-{i}");
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() {
if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() {
pick = Some(json!({"camera": cam, "remove": true}));
}
if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() {
if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() {
pick = Some(json!({"camera": cam, "preset": null}));
}
for p in &app.session.presets {
@@ -232,14 +239,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
}
heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)");
row(ui, t, "Non-raw photos", |ui| {
heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)"));
row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| {
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
}
});
heading(ui, t, "Metadata");
hint(ui, t, "Added to photos you import that don't already have it.");
heading(ui, t, crate::i18n::tr("Metadata"));
hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it."));
for (key, label, hint_text, value) in
[("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())]
{
@@ -259,14 +266,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
});
}
if !app.session.metadata_presets.is_empty() {
row(ui, t, "Metadata preset", |ui| {
row(ui, t, crate::i18n::tr("Metadata preset"), |ui| {
let cur = d.metadata_preset.clone();
let mut pick = None;
let r = egui::ComboBox::from_id_salt("settingsMetaPreset")
.width(240.0)
.selected_text(cur.clone().unwrap_or_else(|| "None".into()))
.show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), "None").clicked() {
if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() {
pick = Some(String::new());
}
for m in &app.session.metadata_presets {
@@ -281,12 +288,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
}
heading(ui, t, "XMP sidecars");
heading(ui, t, crate::i18n::tr("XMP sidecars"));
let mut xmp = app.session.xmp;
if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") {
let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write}));
}
row(ui, t, "Sidecar names", |ui| {
row(ui, t, crate::i18n::tr("Sidecar names"), |ui| {
use lightcraft_engine::sidecar::SidecarNaming as N;
let mut n = xmp.naming;
if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) {
@@ -295,12 +302,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
if !cfg!(target_arch = "wasm32") {
heading(ui, t, "Auto Import");
hint(ui, t, "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.");
row(ui, t, "Watched folder", |ui| {
heading(ui, t, crate::i18n::tr("Auto Import"));
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| "Off".into())).color(t.text));
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new("Choose…"));
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
register(ui.ctx(), "button:settingsAutoFolder", r.rect);
if r.clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
@@ -308,21 +315,21 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
{
app.toast(ui.ctx(), e);
}
if d.auto_folder.is_some() && ui.button("Turn Off").clicked() {
if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() {
let _ = app.run("library.autoImport", json!({"folder": null}));
}
});
if d.auto_folder.is_some() {
row(ui, t, "", |ui| {
let mut copy = d.auto_copy;
if ui.checkbox(&mut copy, "Copy into the library (else use the files where they are)").changed() {
if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() {
let _ = app.run("library.autoImport", json!({"copy": copy}));
}
});
row(ui, t, "Album", |ui| {
row(ui, t, crate::i18n::tr("Album"), |ui| {
let id = egui::Id::new("auto-album");
let mut name = ui.data(|m| m.get_temp::<String>(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("None").desired_width(180.0));
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0));
if r.lost_focus() {
let _ = app.run("library.autoImport", json!({"album": name.trim()}));
}
@@ -331,7 +338,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
}
if app.session.library.is_none() {
hint(ui, t, "In-memory session: these settings last until LightCraft quits.");
hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits."));
}
}
@@ -339,31 +346,31 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::gpu;
heading(ui, t, "Rendering");
heading(ui, t, crate::i18n::tr("Rendering"));
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
let status = if !gpu::available() {
match gpu::unavailable_reason() {
Some(why) => format!("Rendering on the CPU: {why}"),
Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why),
None => "No usable GPU found: rendering on the CPU".to_string(),
}
} else {
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
};
hint(ui, t, &status);
row(ui, t, "Preview size", |ui| {
row(ui, t, crate::i18n::tr("Preview size"), |ui| {
let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect();
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge);
});
hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.");
row(ui, t, "Memory for caches", |ui| {
let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
hint(ui, t, crate::i18n::tr("Largest long edge the Detail view renders at; larger is sharper on big displays but slower."));
row(ui, t, crate::i18n::tr("Memory for caches"), |ui| {
let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb);
});
heading(ui, t, "Thumbnail cache");
heading(ui, t, crate::i18n::tr("Thumbnail cache"));
let cur = (app.session.cache_bytes() >> 20) as u32;
row(ui, t, "Size limit", |ui| {
row(ui, t, crate::i18n::tr("Size limit"), |ui| {
let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")];
let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 };
if choices(ui, "settingsCache", &opts, &mut v) {
@@ -371,16 +378,16 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
let used = app.session.media.rendered.disk().map(|d| d.size());
row(ui, t, "In use", |ui| {
row(ui, t, crate::i18n::tr("In use"), |ui| {
ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text));
let r = ui.button("Clear Cache");
let r = ui.button(crate::i18n::tr("Clear Cache"));
register(ui.ctx(), "button:settingsClearCache", r.rect);
if r.clicked() {
let _ = app.run("library.clearPreviews", json!({}));
}
});
heading(ui, t, "Local folders");
row(ui, t, "Forget unchanged photos", |ui| {
heading(ui, t, crate::i18n::tr("Local folders"));
row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| {
let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")];
let mut v = app.session.forget_local_days;
if choices(ui, "settingsForgetLocal", &opts, &mut v) {
@@ -390,7 +397,9 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
hint(
ui,
t,
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
crate::i18n::tr(
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
),
);
#[cfg(not(target_arch = "wasm32"))]
smart_previews(app, ui, t);
@@ -414,29 +423,40 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
if loc.is_null() {
return;
}
heading(ui, t, "Smart previews");
heading(ui, t, crate::i18n::tr("Smart previews"));
let path = loc["path"].as_str().unwrap_or_default().to_string();
let available = loc["available"].as_bool().unwrap_or(true);
let custom = loc["custom"].as_bool().unwrap_or(false);
row(ui, t, "Folder", |ui| {
row(ui, t, crate::i18n::tr("Folder"), |ui| {
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
});
let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0));
if available {
hint(ui, t, &format!("{count} smart preview{} · {:.1} MB", if count == 1 { "" } else { "s" }, bytes as f64 / 1048576.0));
hint(
ui,
t,
&crate::i18n::tr_format!(
"{count} smart preview{} · {:.1} MB",
if count == 1 { "" } else { "s" },
bytes as f64 / 1048576.0,
count = count
),
);
} else {
hint(
ui,
t,
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
crate::i18n::tr(
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
),
);
}
// what happens to the previews already built when the folder changes
let mode_id = egui::Id::new("smart-existing");
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
if count > 0 {
row(ui, t, "Existing previews", |ui| {
row(ui, t, crate::i18n::tr("Existing previews"), |ui| {
if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) {
ui.data_mut(|d| d.insert_temp(mode_id, mode));
}
@@ -447,7 +467,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.horizontal(|ui| {
ui.add_space(LABEL_W);
if app.services.pick_folder.is_some() {
let r = ui.button("Choose Folder…");
let r = ui.button(crate::i18n::tr("Choose Folder…"));
register(ui.ctx(), "button:settingsSmartChoose", r.rect);
if r.clicked()
&& let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f())
@@ -455,7 +475,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing})));
}
}
let r = ui.add_enabled(custom, egui::Button::new("Use Library Folder"));
let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder")));
register(ui.ctx(), "button:settingsSmartReset", r.rect);
if r.clicked() {
result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing})));
@@ -472,22 +492,22 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
("discard", n) => format!("Smart previews folder changed; deleted {n}"),
_ => "Smart previews folder changed".to_string(),
};
app.toast(ui.ctx(), if failed > 0 { format!("{msg} ({failed} failed)") } else { msg });
app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg });
}
Err(e) => app.toast(ui.ctx(), e),
}
}
hint(ui, t, "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.");
hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."));
}
// ---------------------------------------------------------------------------------- Interface
fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Filmstrip");
heading(ui, t, crate::i18n::tr("Filmstrip"));
check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names");
check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges");
heading(ui, t, "Grid");
row(ui, t, "Ratings & flags", |ui| {
heading(ui, t, crate::i18n::tr("Grid"));
row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| {
choices(
ui,
"settingsGridBadges",
@@ -496,9 +516,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
);
});
check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats");
heading(ui, t, "Detail");
heading(ui, t, crate::i18n::tr("Detail"));
check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in");
row(ui, t, "Info overlay", |ui| {
row(ui, t, crate::i18n::tr("Info overlay"), |ui| {
use crate::state::InfoOverlay as I;
choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay);
});
+7 -5
View File
@@ -19,7 +19,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0);
if !app.native_menu {
// leave room for the in-window menus left of the (centred) search field
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui, app.ui.language) + 24.0;
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0;
sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0);
}
let search_left = full.center().x - sw / 2.0;
@@ -59,7 +59,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let empty = app.ui.search.is_empty();
if empty && !focused {
let g = child.painter().layout_no_wrap("Search Photos".into(), t.font(13.5), t.text_dim);
let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim);
let w = g.size().x + 24.0;
let x0 = sr.center().x - w / 2.0;
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
@@ -102,7 +102,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas);
}
let fresp = fresp.on_hover_text(if active > 0 {
format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" })
crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active)
} else {
"Filter bar".into()
});
@@ -113,10 +113,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let mut x = full.right() - 18.0;
// saving is failing: the cloud icon turns into a warning until a save succeeds
let unsaved = app.session.unsaved().map(|(n, e)| {
format!(
crate::i18n::tr_format!(
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.",
if n == 1 { "" } else { "s" },
if n == 1 { "it" } else { "them" }
if n == 1 { "it" } else { "them" },
e = e,
n = n
)
});
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
+3 -2
View File
@@ -166,7 +166,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
let label =
if n == "0" { "Rating cleared".to_string() } else { crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
@@ -217,7 +218,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
if let Ok(r) = app.run("album.toggleTarget", json!({})) {
let n = app.session.targets(&json!({})).len();
let what = if n == 1 { "photo".to_string() } else { format!("{n} photos") };
let what = if n == 1 { "photo".to_string() } else { crate::i18n::tr_format!("{n} photos", n = n) };
let name = r["name"].as_str().unwrap_or("Quick Collection").to_string();
app.toast(ctx, if r["added"] == true { format!("Added {what} to {name}") } else { format!("Removed {what} from {name}") });
}
+6 -2
View File
@@ -200,7 +200,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
#[serde(default)]
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Language,
/// The Build Previews run last announced (its identity, finished?).
#[serde(skip)]
@@ -359,6 +359,9 @@ pub struct UiState {
/// Transient toast text and its expiry (seconds of app time).
#[serde(skip)]
pub toast: Option<(String, f64)>,
/// The result of the last Find Missing Photos (it searches in the background).
#[serde(skip)]
pub last_find_missing: Option<serde_json::Value>,
#[serde(skip)]
pub status: String,
#[serde(skip)]
@@ -510,7 +513,7 @@ pub enum Dialog {
impl Default for UiState {
fn default() -> Self {
UiState {
language: crate::i18n::Language::default(),
language: crate::i18n::default_language(),
preview_build_seen: None,
unsaved_seen: false,
luminance_map_restore: None,
@@ -589,6 +592,7 @@ impl Default for UiState {
compare: None,
reference: None,
toast: None,
last_find_missing: None,
status: String::new(),
dialog: None,
}
+112
View File
@@ -0,0 +1,112 @@
//! Background tasks for commands whose file-system work can take long on a slow or offline drive
//! (Find Missing Photos walking a folder, listing the auto-import folder…): the work runs on a
//! worker thread and its result is applied on the UI thread between frames, so the window keeps
//! answering. Imports, scans, exports and preview builds have their own tasks with progress.
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use crate::LightcraftApp;
/// Applies a task's result to the app (on the UI thread).
type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) + Send>;
struct Task {
label: String,
rx: Receiver<Finish>,
}
/// The background tasks in flight.
#[derive(Default)]
pub struct Tasks {
running: Vec<Task>,
/// Repaints the window when a task finishes (set every frame).
pub(crate) repaint: Option<egui::Context>,
}
impl Tasks {
pub fn is_empty(&self) -> bool {
self.running.is_empty()
}
/// What is running (`ui.inspect` → `tasks`).
pub fn labels(&self) -> Vec<String> {
self.running.iter().map(|t| t.label.clone()).collect()
}
pub fn is_running(&self, label: &str) -> bool {
self.running.iter().any(|t| t.label == label)
}
}
/// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser
/// build, which has no threads, `work` runs at once and `done` on the next frame).
pub fn spawn<T: Send + 'static>(
app: &mut LightcraftApp,
label: &str,
work: impl FnOnce() -> T + Send + 'static,
done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,
) -> Result<(), String> {
let (tx, rx) = channel::<Finish>();
let repaint = app.tasks.repaint.clone();
let job = move || {
let t = work();
let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)));
if let Some(c) = repaint {
c.request_repaint();
}
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?;
#[cfg(target_arch = "wasm32")]
job();
app.tasks.running.push(Task { label: label.to_string(), rx });
Ok(())
}
/// Apply finished tasks (called every frame).
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
app.tasks.repaint = Some(ctx.clone());
let mut i = 0;
while i < app.tasks.running.len() {
match app.tasks.running[i].rx.try_recv() {
Ok(finish) => {
app.tasks.running.remove(i);
finish(app, ctx);
}
Err(TryRecvError::Empty) => i += 1,
Err(TryRecvError::Disconnected) => {
// the worker died (a panic, already logged)
let t = app.tasks.running.remove(i);
app.toast(ctx, format!("{} failed", t.label));
}
}
}
if !app.tasks.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// Wait for every task and apply it (tests, and commands asked to `wait`). `false` on timeout.
pub fn wait(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
let t0 = std::time::Instant::now();
loop {
poll(app, ctx);
if app.tasks.is_empty() {
return true;
}
if t0.elapsed() > timeout {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
#[cfg(target_arch = "wasm32")]
{
// tasks ran inline: their results are already waiting
let _ = timeout;
poll(app, ctx);
app.tasks.is_empty()
}
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more