Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6408ccf244 Tests: the keyword-list test lets the list settle before clicking rows
keyword_list_filters_renames_and_suggests failed on FreeBSD CI (b35487a) and
locally (2 of 13 runs under load): ui.clickWidget clicks last frame's rect,
and right after opening the panel / expanding "travel" the rows could still
move, so the click hit "sunrise" instead of "travel|italy". Settling first:
30 of 30 runs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:58:39 -04:00
Brandon ThomasandClaude Opus 5.5 517c745fad Tests: the folder-tree test waits for the subfolder row, not three frames
local_folder_tree_expands_and_browses failed on FreeBSD CI (707138f) and under
local load: the expanded folder's listing can land after the fixed three frames.
It now steps until the row is laid out (bounded by SETTLE).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:18:53 -04:00
Brandon Thomas 30225fb6a0 Merge remote-tracking branch 'origin/main' into fix/flaky-profile-hover-test 2026-10-07 10:15:03 -04:00
Brandon Thomas b35487add2 Merge pull request #162 from GuintherKovalski/feat/sam3-object-masks
M12: SAM 3 Object and Describe masks (pure Rust, Metal)
2026-10-07 10:14:20 -04:00
Brandon Thomas 65bc68fd33 Merge remote-tracking branch 'origin/main' into land-162 2026-10-07 10:07:54 -04:00
Brandon ThomasandClaude Opus 5.5 d5be201431 M12: SAM 3 download dialog: no Download button without a location, clearer wording
Looked at in the running app: without a configured mirror the dialog
offered Download / Try Again that could only fail; it now shows the
manual-install hint and Close (an agent confirming still gets the
reason). "A one-time download of about 3.4 GB, saved in <folder>"; a
cancelled download is reported as stopped (resumable), not failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:06:46 -04:00
Brandon ThomasandClaude Opus 5.5 d379e780cc M12: AI masks never require SAM 3 — background worker, consented download, review fixes
Engine (crates/engine/src/segment/):
- One worker thread owns the model and the encoded photo; the session
  sends jobs (with the render job for the model input) and gets results
  over channels, never locking what the model holds. In the app
  (Segmenter::background) clicks, descriptions, prepares and detail
  passes return at once and Session::segment_poll applies results (one
  undo step per settled selection, pending clicks shown on the photo);
  the CLI/MCP wait for them. Newer requests for the same selection
  supersede queued ones.
- Every job runs under catch_unwind: a candle panic becomes an error and
  the model is reloaded next time; busy counters are reset by drop
  guards; a dead worker is restarted. The model unloads after 10 min idle.
- segment.model.status / .download {acknowledged: true} / .cancel: the
  download runs on its own thread; status via atomics (try_lock only).
  Without the model every AI request fails at once with "The SAM 3 model
  is not installed…"; renders/exports never need it; `seg` params make
  AI masks from stored segmentations without it.
- Hostile documents: at most 4 detail patches per component (capped on
  deserialize and in the renderer), at most 64 Object clicks.

UI: Object / Describe without the model open "Download the SAM 3
Model?" (size, folder, SAM License link; Not Now / Download, progress,
cancel, Try Again with the reason, Continue once installed); progress in
the Masking panel when the dialog is closed. Reverted the PR's unrelated
default change (mask overlay off + hover-red) to main's behaviour.

tools/install-sam3.*: --fail so error pages never land in files, 416
restarts instead of looping, the weights are verified before they are
moved into place (a bad file is deleted, never resumed from).

Docs: docs/ai-masks.md (in-app download, mirrors, SAM License summary,
no weights in the repo), NOTICE, parity LR-MASK-OBJECTS, What's New,
ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:56:50 -04:00
Brandon ThomasandClaude Opus 5.5 b52f43d1d5 M12: lightcraft-segment: Apache-2.0, checked weights, pure-Rust model download
- The crate ports Apache-2.0-only Hugging Face transformers code: it is
  now `license = "Apache-2.0"`, and every ported file carries the
  modification notice Apache-2.0 §4(b) asks for.
- Weights::open checks every tensor's byte range (and the header)
  against the file's length: a truncated or damaged download is an
  error at load, never a short read later; regression test with
  hostile offsets.
- fetch/: the model download, only when asked for. An ordered mirror
  list (env LIGHTCRAFT_SAM3_MIRRORS, a mirrors file, DEFAULT_MIRRORS —
  empty until LightCraft's CDN exists, TODO for the maintainer),
  fallback to the next mirror, resume via Range, redirects, connect and
  stall timeouts, cancel, size caps, exact size + SHA-256 (pinned from
  tools/install-sam3.sh) before an atomic rename; damaged files are
  deleted, HTTP error bodies never written.
- HTTP/1.1 client over std sockets + rustls with the RustCrypto provider
  (rustls-rustcrypto) and webpki-roots: no ring / aws-lc-rs, nothing
  compiled from C (no new `cc` users in the app's build graph).
- Tests against a local TcpListener server (no internet): fallback,
  redirects, chunked bodies, resume, 416, hash mismatch, too much data,
  stalls, cancel.
- *.safetensors are gitignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:56:49 -04:00
Brandon ThomasandClaude Opus 5.5 fefd61c967 M12: merge origin/main into the SAM 3 masks branch (PR #162)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:16:09 -04:00
Harald HoyerandClaude Opus 5.5 707138f3fe LR-PROF-CAMERACOLOR: built-in ILCE-7M4 camera profile (#202)
A built-in Sony ILCE-7M4 colour profile (assets/camera-profiles/ILCE-7M4.json, compact JSON, 39 KB), fitted by lightcraft-cli calibrate to the contributor's own 597 raw photos against their in-camera JPEGs; aggregate colour statistics only, MIT OR Apache-2.0. Compiled in via camera_profiles::BUNDLED; a local profile replaces it.

Co-authored-by: Harald Hoyer <harald@hoyer.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:16:08 -04:00
Brandon Thomas fb9fb7717e Merge pull request #201 from haraldh/fix/arw-saturated-hues
LR-PROF-CAMERACOLOR: camera-look hue table, chroma curve and per-model camera profiles
2026-10-07 08:36:09 -04:00
Harald HoyerandClaude Opus 5.5 623122c1a9 Merge origin/main into fix/arw-saturated-hues
Conflicts with issue #150 (NEF colour from the camera JPEG): the per-file
look, its hue/saturation/value table and chroma curve, and camera
profiles now apply to every `file_local_look` format (ARW, NEF, NRW);
`calibrate` also collects NEF/NRW. Render cache version 11 (main 9,
branch 10).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:51:08 +02:00
Harald HoyerandClaude Opus 5.5 a52c7448e4 LR-PROF-CAMERACOLOR: docs: describe the validation photos neutrally
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:46:42 +02:00
Harald HoyerandClaude Opus 5.5 27d6bc7790 LR-PROF-CAMERACOLOR: camera colour profiles pooled from many photos
One photo can show too little of a colour for its own fit: a lime shirt
covering a few dozen proxy pixels next to foliage at the same hue stayed
yellow (hue 68 deg vs the JPEG's 82 deg).

- `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS...` pools the
  colour pairs of many ARWs per model (each against its embedded JPEG,
  192 px proxies, <= 4000 pairs per photo) and fits one matrix from
  white-balanced camera RGB plus a hue/saturation/value table.
- Profiles live in $LIGHTCRAFT_CAMERA_PROFILES or
  <config>/camera-profiles/<model>.json; validated on load, damaged files
  ignored with a warning, part of the render cache keys.
- Photos of a profiled model take its colour and fit only their own tone
  and chroma curves; the acceptance gates still apply.
- The table fit buckets samples by hue and runs nodes in parallel; the
  app's config_dir moves to the engine.

Profile from 300 Feb-Aug ILCE-7M4 photos (2 min); 29 held-out September
photos: mean dE vs camera JPEG 3.54 -> 3.10, saturated 6.41 -> 5.20;
the shirt's hue 68 -> 82 deg (JPEG 81.5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:38:46 +02:00
Brandon Thomas f2f449611d Merge pull request #198 from sakamoto824/codex/zh-tw-localization
M16: add Traditional Chinese UI, menus and stock preset localization
2026-10-07 07:09:14 -04:00
Brandon Thomas 288fd3f125 Merge sakamoto824's original PR #198 commit (superseded by the port above) 2026-10-07 07:06:54 -04:00
Brandon Thomas 1779216514 Merge remote-tracking branch 'origin/main' into land-198 2026-10-07 07:06:50 -04:00
Brandon Thomas f0bb7c768e Merge pull request #184 from lzjtugjc/i18n/zh-hans
LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback
2026-10-07 07:06:42 -04:00
Brandon Thomas 2eff66dd1e Merge remote-tracking branch 'origin/main' into land-184
# Conflicts:
#	docs/parity.md
2026-10-07 07:04:00 -04:00
ca0c569863 LR-BEHAV-LOCALIZE: Traditional Chinese (Taiwan) UI, stock preset and date localisation
Port of PR #198 onto the language table:
- Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name
  繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command
  app.language.traditionalChinese
- display-only helpers: builtin_label (stock preset/profile names), history_label
  (generated history steps, user preset names verbatim), source_label (library
  headings, album names verbatim), date_group_label / display_time
- menu, dialog and panel strings (profiles, presets, curves, crop, versions,
  history, grid, date sidebar, About links) go through the catalogs
- a language command and ui.set apply the language at once; a native-menu test
  checks that every language switch rebuilds the menu titles

Adapted while porting (Claude):
- date headings and capture times are localised for every language from its
  *-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date
  patterns, weekday names and "Unknown Date", and zh-hans the new menu messages
  (ja's come from the PR)
- plain catalogs may cover different messages (English fallback); the test
  reports the gaps instead of failing. The test requiring every engine command
  label to be translated is dropped
- preset groups stay grouped by name: a user preset in a stock-named group is
  not split into a separate group (the group header is translated when it holds
  stock presets); the PR's split was a side effect of labelling
- no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified
  Chinese face (which carries the Traditional characters) over the Japanese ones
- zh-hant translations for the messages main added since the PR branched

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:54:26 -04:00
Brandon Thomas 549cbe728c Merge pull request #170 from voshart/pr/faces-1-regions
Faces: read face regions from XMP, show them in the loupe, and a People view
2026-10-07 06:49:45 -04:00
Brandon ThomasandClaude Opus 5.5 4e0f8ced0c Tests: the hover-preview tests wait for the hover render, not just a quiet spell
profile_browser_previews_on_hover_and_applies_on_click failed on FreeBSD CI
(loupe showed "render", not "hover"): settle() returns after 12 idle frames,
which can come before the hover render lands on a loaded VM. A step_until
helper waits for the condition itself; the preset hover test had the same race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:36:17 -04:00
Brandon ThomasandClaude Opus 5.5 862c1f253a LR-LIB-PEOPLE: document MWG face regions in xmp-interop and the parity row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:21:27 -04:00
Brandon ThomasandClaude Opus 5.5 4ded49ecd9 LR-LIB-PEOPLE: face boxes and People close-ups follow Rotate Left/Right
Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the
close-up crop include the user's orientation. The loupe overlay now maps each box into that frame
(and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a
quarter turn. Flips need nothing: they are applied after the normalized frame. Adds
Orientation::inverse and Orientation::map_norm_rect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:21:04 -04:00
Brandon ThomasandClaude Opus 5.5 269934caca LR-BEHAV-LOCALIZE: reinstall fonts on a language switch; restore the painted-UI tests
- the CJK fallback order follows the UI language's script, but the fonts were
  only installed on the first frame: switching 日本語 ↔ 简体中文 at runtime kept
  the previous language's Han forms. The app remembers the language its fonts
  were built for and reinstalls them when it changes.
- the native menu bar rebuilds on any language change (keyed on the language
  code, not on "uses CJK fonts", which is true for both CJK languages)
- restore the tests the language-table change dropped, for every language:
  core catalog entries, user-named menu items stay untranslated, the window
  paints each language and menu ids don't change with it, CJK glyphs in every
  UI family, and the UI runs without craft-fonts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:19:50 -04:00
Brandon ThomasandClaude Opus 5.5 8c253b28ad LR-LIB-PEOPLE: a sidecar that states mwg-rs:Regions replaces the photo's regions, even when empty
Re-reading a sidecar whose region list was emptied by a region-aware app now clears the stale
regions. A sidecar without mwg-rs:Regions at all (an app that doesn't do regions) says nothing about
them and the photo's regions are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:19:45 -04:00
Brandon ThomasandClaude Opus 5.5 87a58b4183 LR-BEHAV-LOCALIZE: one Language menu entry per language; parity reads the language table
- UI_COMMANDS still listed app.language.english/japanese next to the new
  LANGUAGE_COMMANDS table, so Edit > Language showed English and 日本語 twice
- LANGUAGE_COMMANDS is `pub const`, the form xtask parity scans for; a test
  reads the real menus.rs and checks every language id is found once
- the Simplified Chinese command is app.language.simplifiedChinese, in line
  with the other camelCase command ids (the settings code stays zh-hans)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:17:06 -04:00
Brandon ThomasandClaude Opus 5.5 60a8649779 LR-LIB-PEOPLE: leave empty face regions out of the catalog JSON
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:16:02 -04:00
Brandon ThomasandClaude Opus 5.5 313c555b50 LR-BEHAV-LOCALIZE: compile-checked tr_format! for every language catalog
Restore main's compile-checked formatting, generalised to N catalogs: build.rs
writes one tr_format! arm per English message that matches on the active
language and calls format! with that language's translation (English is the
fallback arm). The runtime substitution dropped format specs ({:.1}, {:+},
{:.0}%), e.g. the smart-preview size and the merge progress percentage.

- formats catalogs use main's convention again ({0}/{1} for reordering, {:.0}
  to consume an unused plural suffix); zh-hans converted, with the size and
  percentage placeholders fixed by hand
- build.rs: auto-discovers locales/*-formats.json, validates the code
  (^[a-z]+(-[a-z]+)*$), fails when the catalogs' keys differ, and reruns
  when the locales directory changes
- zh-hans: translate the strings main added since the PR branched
- test: precision, sign and reordered positional values survive in every
  language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:13:44 -04:00
Brandon ThomasandClaude Opus 5.5 3793e8fc33 LR-LIB-PEOPLE: clip XMP face regions to the photo, drop off-photo ones, read Rotation π
A region parsed from mwg-rs:Regions is now intersected with the 0..1 frame and dropped when it
misses the photo, so a hostile or stale sidecar (x=1e5, a 1e5-wide box) can't park a box far
outside the image. Lightroom's mwg-rs:Rotation of ±π (Exif orientation 3) is mapped to the upright
frame like the quarter turns; mirrored orientations can't be expressed in MWG and are taken as
written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:12:49 -04:00
Brandon Thomas 1b22a0e53b Merge origin/main into land-170 2026-10-07 06:09:24 -04:00
Brandon Thomas d814a31497 Merge remote-tracking branch 'origin/main' into land-184
# Conflicts:
#	crates/ui-egui/locales/ja-formats.json
#	docs/parity.md
2026-10-07 06:03:46 -04:00
Brandon Thomas 889b610e9f Merge pull request #192 from itsmunzir/fix/167-connect-hint
CLI: the connect hint names the port it actually tried (#167)
2026-10-07 06:02:12 -04:00
Brandon Thomas eacce855b9 Merge pull request #179 from coygeek/docs/star-history
docs: add star history chart to README
2026-10-07 06:02:08 -04:00
Brandon Thomas 3757506f46 Merge pull request #178 from cruzer45/feat/cr3-preview-metadata
CR3: full-size preview and metadata from the container
2026-10-07 06:02:03 -04:00
Brandon Thomas 01c9b743fe Merge remote-tracking branch 'origin/main' into fix-178
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/locales/ja.json
#	docs/parity.md
#	docs/whats-new.md
2026-10-07 06:01:50 -04:00
Brandon Thomas db2d94a233 Merge pull request #177 from cruzer45/fix/recently-deleted-restore
Restore from Recently Deleted; re-adding a deleted photo shows where it is
2026-10-07 06:01:33 -04:00
Brandon Thomas 2c2694ff1e Merge pull request #165 from treyreynolds/feat/nef-camera-look
Issue #150: Raw: NEF colour from the camera JPEG; 12-bit NEF black level
2026-10-07 06:01:27 -04:00
Brandon Thomas 136c1a0d89 Merge pull request #155 from kywk/feat/radial-masking-tuning
feat(ui): improve radial mask resizing and rotation
2026-10-07 06:01:23 -04:00
Brandon Thomas 9ef83ae3ae Merge pull request #152 from storybored379/fix/symlink-original-guard
Export: protect originals imported through differently named symlinks
2026-10-07 06:01:18 -04:00
Brandon ThomasandClaude Opus 5.5 51f0ef25ae Export: look up the library's symlinked originals once per guard
The symlink fallback ran read_link on every library entry for each existing
target; a large library made every Overwrite export check do thousands of
syscalls. The guard now finds the links on first need and keeps them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:41:28 -04:00
Brandon Thomas 1ce28c3aea Merge main into fix/symlink-original-guard
# Conflicts:
#	crates/engine/src/tests_export.rs
2026-10-07 05:41:26 -04:00
sakamoto824andCodex db640ed1d3 M16: add Traditional Chinese UI, menus and stock preset localization
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 17:32:12 +08:00
Harald HoyerandClaude Opus 5.5 987d14b6ad LR-PROF-CAMERACOLOR: ARW camera looks bleach highlights and shift hue by brightness
A sunny ILCE-7M4 waterfall shot rendered yellow: sunlit rock came out
cream, +4 b* in the brightest fifth of the picture. Camera JPEGs make
shadows up to 1.4x more colourful and bleach highlights to 0.1-0.3x
(a per-channel tone curve), which a luminance-only curve can't do.

- CameraTone gains an 8-node chroma-by-display-luminance curve, fitted
  per file (bright pixels included) and kept only when it lowers the
  held-out error; applied after the tone map on CPU and GPU, keeping
  luminance. Old smart-preview headers load with an identity curve.
- The hue/saturation table gets an sRGB-encoded value axis: dark yellows
  had to turn toward orange while bright yellow-greens turned toward
  green. It is applied after the baseline exposure, as it is fitted.
- Matrix-only and matrix+table looks are completed (tone, chroma) and
  compared in gamma-encoded values; acceptance gates stay linear.

29 local ARWs vs their camera JPEGs: mean dE 4.23 -> 3.54, every file
closer, saturated areas 7.65 -> 6.41. Render cache v10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 11:04:39 +02:00
Harald HoyerandClaude Opus 5.5 d4b73b7f64 LR-PROF-CAMERACOLOR: fit a hue/saturation table to ARW camera looks
A global 3x3 matrix can't follow the camera's hue-dependent rendering:
on an ILCE-7M4 photo of a lime shirt among grey rocks the best matrix
rendered the shirt olive (16 deg hue error), since the few saturated
pixels carry little weight and a matrix fitted to them breaks the rest.

After the matrix, fit a 72x5 hue/saturation table (DNG ProfileHueSatMap
layout, linear ProPhoto) to the residual hue shifts and saturation
ratios: kernel-weighted per node, shrunk to identity where samples are
sparse, identity at saturation 0. It preserves luminance (the tone curve
owns it) and is kept only when it lowers the held-out error.

28 local ILCE-7M4 ARWs: mean dE vs camera JPEG 4.74 -> 4.27, none worse
overall; the shirt's hue error drops to 6-8 deg. Render cache v9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:54:27 +02:00
Mohammed Munzir 489f78ba69 CLI: the connect hint names the port it actually tried (#167)
run --connect ADDR could not reach the app and always advised lightcraft --control 7980, whatever port ADDR named, so following the hint left the command connecting elsewhere. The hint now takes the port from ADDR itself, with an end-to-end test on a refused connection.
2026-10-07 10:41:45 +03:00
goujichengandDeepSeek V3.2 22eb4d3515 LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback
The localization layer was hardcoded to English and Japanese: an enum with two
variants, an `is_japanese()` gate compiled into the format macro, and one
catalog pair. Adding a language meant editing the menus, the settings row, the
checkmark logic, the format macro and the font stack — each in a different place.

A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym,
ISO 15924 script and catalog. The Language menu, the settings row, the shortcut
sheet and the control channel are all built from that table, and `build.rs`
picks up `locales/<code>-formats.json` by name, so a further language is a table
entry plus two catalogs (docs/localization.md).

Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json`
(1019 entries, key-set and placeholder-identical to `ja.json`) and
`zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto
Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones
for a Chinese reader, so shared Han characters keep the language's forms
(`fonts::cjk_fallback`); the export watermark accepts every CJK face.

`tr_format!` keeps its compile-time check. It formats the English source at the
call site — a runtime template cannot be handed to `format!` — and names those
values so a translation can place them by name (`{n}`) or position (`{_1}`).
Every translation is still checked by `format!`, so a translation with an
unknown placeholder fails the build. The Japanese catalog was rewritten from the
old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged.

Also: settings persist the BCP-47 code (never the Rust variant name),
`LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`),
`xtask parity` reads the second command table, and the glyph tests cover every
catalog character plus the interface symbols (`▸`/`▾` turned out to have no
glyph in any installed font and are gone from the Chinese text).

Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still
embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past
Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md.

Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
2026-10-07 12:22:15 +08:00
Maurice Rogers 9adb40908a LR-IMP-FORMATS: Reload from Disk in the Photo and context menus; fills missing camera metadata
photo.reload had no menu path, so the documented way to refresh CR3s imported
before their metadata was read (Photo > Reload from Disk) did not exist in the
app. It is now in the Photo menu and the photo context menu, and besides
size/preview-only it fills camera fields the catalog lacks (camera, lens,
exposure, GPS, capture time) from a fresh probe, never overwriting a set value.
2026-10-06 21:53:09 -06:00
Coy Geek c9fe78e178 docs: add star history chart to README
Show repository star growth at the bottom of the README. Link the date-based
chart with a top-left legend to the corresponding Star History page.
2026-10-06 19:27:15 -07:00
Maurice Rogers 13a3eff2e8 LR-IMP-FORMATS: CR3 full-size preview and metadata from the ISO-BMFF container
CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata
(no camera, capture time, exposure, lens or GPS): nothing read the container.

- crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid
  box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header
  and sample location, XMP uuid box). Every size/offset checked; depth and box
  count bounded.
- Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif
  block, so CR3 reads like any TIFF-based raw; XMP from its uuid box.
- Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before
  PRVW/THMB; imports report the full image size.
- Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep,
  JPEG track preferred over PRVW with fallback.

Raw (CRX) data is still not decoded; CR3 stays preview-only.
2026-10-06 20:00:48 -06:00
Maurice Rogers 6bda431c4b LR-LIB-DELETE: restore from Recently Deleted; re-adding a deleted photo shows where it is
Photos in Recently Deleted could not be restored from the app: photo.restore and
photo.deletePermanently existed only as engine commands, and adding the same file
again just said "1 duplicate skipped" (the duplicate check counts deleted photos),
with the side panel that lists Recently Deleted collapsed by default.

- Restore and Delete Permanently in the Photo menu and the photo context menu,
  in place of Delete, when the selection is in Recently Deleted.
- The filmstrip gets the photo context menu (it had none).
- An import that only finds files the library already has selects those photos:
  in Recently Deleted (side panel opened, how to restore or delete permanently and
  import afresh) or in All Photos. Mixed imports note how many are in Recently
  Deleted. The message stays 6 s.
- Import review badges say "In Recently Deleted"; badges are translated.
- Japanese strings, parity rows, what's new; headless regression test.
2026-10-06 19:30:23 -06:00
Trey ReynoldsandClaude Opus 5.5 22c6aa6918 Issue #150: Raw: NEF colour from the camera JPEG; 12-bit NEF black level
LightCraft has no colour matrix for Nikon cameras. Because of this, NEF
files showed colours that were too gray and too green.

- Colour: LightCraft now calculates a colour matrix and a tone curve from
  the JPEG preview in each NEF file. Sony ARW files use the same
  procedure and the same quality checks. White balance is relative to
  the as-shot look, as for ARW files.
- One function, camera_preview::file_local_look, selects the formats for
  the fit and for the relative white balance. Photo::relative_wb in the
  catalog uses the same formats.
- Black level: Nikon stores the black level (maker note 0x003d) in
  14-bit units, also in 12-bit files. The decoder read it as a 12-bit
  value. Because of this, 12-bit files from the D750, D780, D850, D7500
  and Z 50 were too dark. Now the decoder divides the value by 4 for
  12-bit files.
- The corpus gets the D7500 12-bit and 14-bit lossless samples. A test
  compares their levels. The render cache version increases to 9.

Results on 13 NEF files that LightCraft can decode: LightCraft accepted
the fit for all 13 files. The mean colour difference from the camera
JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed
light improved by a small quantity only (12.7 to 12.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:26:45 -05:00
Daniel VoshartandClaude Sonnet 5.5 9bb06cc9dc Faces: resize a face box with eight drag handles
Hovering a box shows handles at its corners and edge midpoints; dragging one
previews the new box live and commits once on release, as one undo step, through
the new `photo.setRegion {id?, index, rect}`: catalog-only like removal, never
rewriting the sidecar, with the rectangle validated (finite numbers only,
clamped to the photo, at least 0.5 % each way) and a test with a real sidecar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:15 -04:00
Daniel VoshartandClaude Sonnet 5.5 e5848aa1b2 Filters: open a library unfiltered, and show the active filters in the loupe
A date, keyword or person left over from the last session silently hid photos
behind a small badge, so view.json no longer stores or restores the filter (the
source, sort and selection still come back). In the loupe a small pill at the
canvas' top left names the active filters (the filmstrip and Next / Previous
follow them) and clears them on a click; it sits in the canvas margin, so the
photo does not move.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:15 -04:00
Daniel VoshartandClaude Sonnet 5.5 e2e6db0f14 Faces: switch face boxes on/off and remove one with the × shown on hover
View > Face Boxes (and a toolbar button in the loupe) hides or shows the boxes.
Hovering a box shows a × in its corner; clicking it runs the new undoable
`photo.removeRegion {id?, index}`, a catalog-only edit. The XMP sidecar is
never rewritten for it, even with auto-write on (a `skip_auto_write` flag
consumed when the command ends), because the sidecar writer builds a fresh
packet that does not carry the regions: reading the metadata from the file
brings a removed region back. A test with a real sidecar proves the file stays
byte for byte, that ordinary edits still auto-write, and that undo restores it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:14 -04:00
Daniel VoshartandClaude Sonnet 5.5 b9621eb5a3 People view: offer only people in the photos the active filters let through
The view listed everyone in the library, so with another filter active (a
date, a keyword…) picking a person could end in an empty grid. The list now
covers the photos matching every filter except `person` itself, counts are
within them, and the filter chips show above the cards (removable, Clear all).
`Catalog::people_in(&Filter)`; the cache is keyed on the catalog revision and
the filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:14 -04:00
Daniel VoshartandClaude Sonnet 5.5 8859f797d8 People view: a card per named person with a cropped face, replacing the sidebar list
A dedicated view mode (View > People, and a toolbar button) shows a card per
person named on faces: a close-up of their largest face, the name and how many
photos they are in. A click shows that person's photos in the grid. Only the
cards on screen render their face. The sidebar section is gone (it would get
crowded).

`Catalog::people()` now returns `Person { name, count, photo, face }`, the
photo and face being the person's largest. `Session::face_job` renders a
square close-up through the engine (the photo's own look, crop replaced, the
smallest source level that keeps it sharp, cached like variant thumbnails); it
clamps hostile rectangles, with a test for corners, oversized, degenerate and
non-finite faces.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:14 -04:00
Daniel VoshartandClaude Sonnet 5.5 7c27c3a920 People MVP: list named faces from XMP in the sidebar and filter by person
Read-only and names-only: the left panel gets a People section built from the
named Face regions read from XMP, with photo counts (once per photo, case
insensitive, pets and unnamed faces left out). A click filters the grid via a
new `person` field on `library.filter`, also reachable as a `person:` search
token and shown as a filter chip. The list is cached on the catalog revision.
No face detection, identity or writing regions back yet. Parity rows for the
People list, filter and view are 🟡 with that scope noted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:00 -04:00
Daniel VoshartandClaude Sonnet 5.5 f5f8bc5d34 UI: draw face labels as a dark caret label above a white/black keyline box
The name sits in a dark rounded label centred above the box with a small
caret (below it when there is no room above), replacing the offset-shadow
text. The box is a 1px white outline with a 1px black keyline so it shows on
any background.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:42:32 -04:00
Daniel VoshartandClaude Sonnet 5.5 9715c90413 Meta: map Lightroom's quarter-turn region Rotation to the upright frame
Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in
the sensor's own frame and records the orientation as mwg-rs:Rotation
(-pi/2 for 6, +pi/2 for 8, 0 for 1) while AppliedToDimensions names the
upright size. Ignoring Rotation put every portrait photo's face box in the
wrong place. Convert such regions into the upright frame, with a synthetic
regression test for -pi/2, +pi/2 and 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:42:32 -04:00
Daniel VoshartandClaude Sonnet 5 f1b2d9af11 Catalog/UI: show XMP face/pet regions in the loupe view
Threads the Vec<Region> that lightcraft-meta already reads from XMP
(MWG-RS) through to the screen, so it's no longer inert:

- catalog::Meta gains a `regions` field (catalog now depends on
  lightcraft-meta, which doesn't depend back on catalog, so no cycle).
- crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar
  / merge_into) populate it for both embedded-XMP and external-sidecar
  photos, mirroring how `keywords` is already handled.
- ui-egui's loupe view (panels/detail.rs) draws each region as a
  rectangle over the photo via the existing CanvasMap, color-coded by
  kind (Face/Pet/other) with its name label if present, right alongside
  the existing info overlay. Read-only, no new interaction.

New end-to-end test (regions_from_sidecar_are_read_on_import) covers a
region written to a sidecar reaching Photo.meta.regions through real
import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 19:42:32 -04:00
Daniel VoshartandClaude Sonnet 5 f29436a1f6 Meta: read MWG-RS face/pet/focus regions from XMP (read-only)
Adds Metadata::regions: Vec<Region>, populated by parse_xmp from mwg-rs:Regions
(MWG Region Guidelines v2.0) — the format Lightroom, digiKam and Picasa write
for face/pet/focus/barcode regions. Read-only: LightCraft does not write
regions yet (blocked on #92, which would otherwise clobber existing sidecars).

- Canonicalises the mwg-rs/stArea/stDim namespaces so any prefix a writer
  picks resolves consistently, reusing the existing generic struct/array XMP
  machinery (handles both the element and attribute encodings transparently).
- Area center-coordinates are converted to lightcraft_geom::Rect via
  Rect::from_center. Normalized units (the default, and the only unit we've
  seen in practice) need no AppliedToDimensions cross-reference; the rare
  pixel-unit region is converted when AppliedToDimensions is present, and
  dropped (not stored as a nonsense fraction) when it isn't.
- Hostile/malformed regions (missing Area, NaN/absurd/zero/negative extents)
  are skipped individually rather than failing the whole list or panicking.
- regions merges like keywords in fill_missing/overlay_user_fields, since it
  only ever comes from XMP today.

Not included: nothing yet reads Metadata::regions (no catalog field, no UI,
no command) — this is plumbing for a later PR, with no user-visible effect
and no docs/parity.md row to update.

cargo xtask ci passes (fmt, clippy -D warnings, test, parity, layers, assets,
wasm).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 19:42:31 -04:00
Guinther KovalskiandClaude Opus 5.5 6468bfc06b M12: SAM 3 Object and Describe masks (pure Rust, Metal)
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.

- Object: click to include, ⌥-click to leave a part out (SAM 3's
  interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
  "car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
  buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
  KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
  area around a selection from a sharper source: 5-10x finer edges on
  small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
  shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
  image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
  download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
  app (LIGHTCRAFT_LOG=info).

candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
2026-10-06 20:15:04 -03:00
Brandon Thomas 5cee0c08d0 Merge pull request #158 from storytold/fix/issue-148-rx100m3-green
Issue #148: Raw: read white balance, black level and crop of pre-2017 Sony ARWs
2026-10-06 18:25:50 -04:00
Yu Zhi Chen 6ad215e5b9 feat(ui): improve radial mask resizing and rotation
- Added 4 control handles on the edge of the radial gradient mask to resize rx and ry independently.
- Added a rotation handle to adjust the mask angle.
- Changed drag gesture handling to store the original mask shape to prevent drift from accumulated relative deltas.
- Holding the Option/Alt key while dragging a handle scales the mask proportionally.
- Snaps the mask to a perfect circle (1:1 aspect ratio) when the aspect ratio is within 5%.
2026-10-07 04:21:14 +08:00
Brandon ThomasandClaude Opus 5.5 33a84b8427 Issue #148: Raw: read white balance, black level and crop of pre-2017 Sony ARWs
Sony bodies from before ~2017 (RX100–RX100M5, RX10, NEX, SLT, ILCE-6000,
A7/A7II/A7RII, ...) write no plain 0x7313/0x7310/0x74c7 tags in the raw IFD,
so the decoder used unit WB multipliers (strong green cast, e.g. the CC0
DSC-RX100M3 sample), black 512 (800 on 1-inch sensors), and kept padding
columns at the right edge.

- White balance: WB_RGBLevels from the maker note's enciphered Tag2010
  block (layouts per ExifTool's Tag2010a..i tables; byte substitution
  p^3 mod 249 checked black-box on 15 CC0 files: SonyISO matches Exif ISO,
  ILCE-7M3 levels match its plain 0x7313).
- Black level: recovered from the encrypted SR2SubIFD by known-plaintext
  analysis (constant key in every file; RX100M6/ILCE-7M3 plain 0x7310 as
  plaintext; 8 keystream bytes at offset 2510, plausibility-checked).
- Crop: DNG DefaultCrop or the maker note's FullImageSize.
- "12-bit uncompressed" ARWs hold 14-bit-scale data: black/white follow it.
- Render cache version bumped; corpus manifest + corpus test, unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:27:33 -04:00
Brandon Thomas 265248c86f Merge pull request #151 from storytold/fix/snapshot-test-settle
Tests: snapshot-determinism test asserts both runs settled
2026-10-06 15:14:10 -04:00
Brandon ThomasandClaude Opus 5.5 1130e99095 Tests: the snapshot-determinism test asserts both runs settled before comparing pixels
snapshot() ignored settle()'s result, so on a loaded machine (seen on the FreeBSD VM and
under local load) a timed-out settle compared half-rendered frames and failed as a
misleading pixel diff. The test now settles explicitly with a 300 s budget, asserts it
settled, then paints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:13:58 -04:00
Brandon Thomas 07a7316d8b Merge pull request #149 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
2026-10-06 14:49:22 -04:00
Brandon ThomasandClaude Opus 5.5 2aa236537f Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
The Japanese fonts (BIZ UDPGothic Regular/Bold for the UI, BIZ UDMincho for text
watermarks; #110, #114) now come from storytold/craft-fonts instead of assets/fonts/.

- crates/engine/build.rs: the craft-fonts recipe. With CRAFT_FONTS_DIR set it embeds
  the manifest's fonts as lightcraft_engine::CRAFT_FONTS (wasm32: BIZ UDPGothic Regular
  only, for the web size budget); unset, CRAFT_FONTS is empty. Local files only, no
  network; craft-fonts is not in any Cargo.toml.
- UI: theme::font_definitions adds the Japanese faces after Inter and egui's defaults
  in every family (BIZ UDPGothic first, Bold first for semibold). About box lists the
  embedded families.
- Watermarks: Inter, then the craft-fonts Japanese faces (Mincho first).
- Removed BIZUDPGothic-Regular/Bold.ttf, BIZUDMincho-Regular.ttf and their OFL files,
  and their ATTRIBUTION rows; added an attribution row for craft-fonts as an optional
  build input; NOTICE updated.
- Tests: Japanese glyph tests use CRAFT_FONTS and skip without it; new tests for
  Japanese in the UI with craft-fonts and for the UI and watermarks without it.
- CI: the FreeBSD job builds/tests with craft-fonts. Releases check out craft-fonts at
  a pinned commit, build with CRAFT_FONTS_DIR + CRAFT_FONTS_REQUIRED=1, and ship the
  fonts' OFL licences (plus NOTICE) in every package.
- Docs: AGENTS.md, CLAUDE.md, README, docs/web.md, docs/localization-ja.md, ROADMAP,
  parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:35:02 -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
storybored379andOpenAI Codex 2c94b7fa1f Export: protect originals imported through differently named symlinks
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-05 23:51:43 -07: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 Thomas 6c066b66f2 Merge main into fix/issue-97-folder-undo
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/parity.md
#	docs/whats-new.md
2026-10-05 18:58:13 -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 e11f77d9ca Merge pull request #129 from storytold/fix/issue-94-control-close
Issue #94: Control: close the connection on malformed input
2026-10-05 18:57:59 -04:00
Brandon Thomas f383d1e736 Merge main into fix/issue-94-control-close
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:57:50 -04:00
Brandon Thomas edf05789ed Merge pull request #128 from storytold/fix/issue-103-settings-files
Issue #103: Settings files: keep damaged ones, never overwrite unread ones, write ui.json atomically, confirm quit with unsaved changes
2026-10-05 18:57:30 -04:00
Brandon Thomas bccccfb92a Merge main into fix/issue-103-settings-files
# Conflicts:
#	crates/engine/src/library.rs
#	docs/whats-new.md
2026-10-05 18:56:50 -04:00
Brandon Thomas 4939d9b994 Merge pull request #127 from storytold/fix/issue-100-open-failure
Issue #100: Ask what to do when the library can't be opened, never a silent demo session
2026-10-05 18:56:35 -04:00
Brandon Thomas e0ab41ede0 Merge main into fix/issue-100-open-failure 2026-10-05 18:56:00 -04:00
Brandon Thomas 8690b51fcf Merge pull request #126 from storytold/fix/issue-99-library-lock
Issue #99: Library: exclusive lock so two processes can't open one library
2026-10-05 18:55:44 -04:00
Brandon Thomas e1d3b56bb6 Merge main into fix/issue-99-library-lock
# Conflicts:
#	crates/catalog/src/lib.rs
2026-10-05 18:54:56 -04:00
Brandon Thomas 6100237cdb Merge pull request #125 from storytold/fix/issue-102-format-version
Issue #102: Catalog format version 2; refuse newer libraries untouched
2026-10-05 18:54:41 -04:00
Brandon Thomas 4579599745 Merge main into fix/issue-102-format-version 2026-10-05 18:54:03 -04:00
Brandon Thomas 8506fecf5f Merge pull request #124 from storytold/fix/issue-101-torn-append
Issue #101: catalog.log: cut a partial append back before retrying
2026-10-05 18:53:49 -04:00
Brandon Thomas 0f49ebf8f0 Merge main into fix/issue-101-torn-append
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:52:48 -04:00
Brandon Thomas ab86862011 Merge pull request #123 from storytold/fix/issue-92-xmp-merge
Issue #92: XMP: merge into an existing sidecar instead of replacing it
2026-10-05 18:52:25 -04:00
Brandon Thomas 577330a08b Merge main into fix/issue-92-xmp-merge
# Conflicts:
#	crates/engine/src/sidecar.rs
#	docs/whats-new.md
2026-10-05 18:50:42 -04:00
Brandon Thomas 30697cfc26 Merge pull request #122 from storytold/fix/issue-92-rename-shared-sidecar
Issue #92: Rename Photos copies a stem sidecar another file still shares
2026-10-05 18:50:26 -04:00
Brandon Thomas ddbf139e5e Merge main into fix/issue-92-rename-shared-sidecar 2026-10-05 18:49:29 -04:00
Brandon Thomas ee77490dd2 Merge pull request #121 from storytold/fix/issue-105-rename-rollback
Issue #105: Rename Photos reports failed rollbacks; Find Missing matches by content
2026-10-05 18:49:15 -04:00
Brandon Thomas f31a55c8a7 Merge main into fix/issue-105-rename-rollback
# Conflicts:
#	docs/parity.md
2026-10-05 18:48:27 -04:00
Brandon Thomas 7218b990f5 Merge pull request #120 from storytold/fix/issue-95-case-only-rename
Issue #95: Rename Photos: case-only renames never overwrite another file
2026-10-05 18:48:06 -04:00
Brandon Thomas d16d9aadeb Merge main into fix/issue-95-case-only-rename 2026-10-05 18:47:18 -04:00
Brandon Thomas 1caa04acbc Merge pull request #119 from storytold/fix/issue-98-thumb-cache-own-files
Issue #98: thumbnail cache only lists, counts and deletes its own files
2026-10-05 18:47:03 -04:00
Brandon Thomas bef2060b24 Merge main into fix/issue-98-thumb-cache-own-files 2026-10-05 18:46:59 -04:00
Brandon Thomas 40303b999c Merge pull request #118 from storytold/fix/issue-96-verified-copy-import
Issue #96: Import > Copy verifies each copy like Move
2026-10-05 18:46:42 -04:00
Brandon Thomas ce5488ad99 Merge main into fix/issue-96-verified-copy-import
# Conflicts:
#	docs/whats-new.md
2026-10-05 18:46:20 -04:00
Brandon Thomas f6bc1ae5ae Merge pull request #117 from storytold/fix/issue-106-dng-smart-previews
Issue #106: Convert to DNG and smart previews: verified, atomic writes
2026-10-05 18:46:06 -04:00
Brandon Thomas 6c8a65c337 Merge main into fix/issue-106-dng-smart-previews 2026-10-05 18:45:48 -04:00
Brandon Thomas fe386f4f15 Merge pull request #116 from storytold/fix/issue-93-export-overwrite
Issue #93: Export: never overwrite an original; atomic writes
2026-10-05 18:45:31 -04:00
Brandon Thomas a6f421aafb Merge main into fix/issue-93-export-overwrite 2026-10-05 18:44:02 -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 5e618c553a Issue #100: Ask what to do when the library can't be opened, never a silent demo session
When open_library failed, the desktop app (and the web build) quietly ran
an in-memory session seeded with the demo photos: it looked like a reset
library and everything done in it was lost at quit.

- The session now starts empty and in memory (no demo photos) and
  LightcraftApp::library_problem shows a blocking window with the path and
  the error: Try Again, Choose Another Library… (app.openLibrary), Continue
  Without Saving, Quit. Command-line files are imported once the user has
  chosen.
- A temporary session shows a banner under the top bar (with Open
  Library…) for as long as it lasts; it has no library, so nothing is
  written anywhere. Opening a library ends it.
- ui.inspect → libraryProblem; widget ids for automation; documented in
  control-protocol.md.
- Web build: same window (Try Again disabled: reloading the page retries).
- Headless UI test: locked library → window → Continue Without Saving →
  banner, nothing written to the library → lock released → Try Again opens
  it. Checked in the real app over the control channel too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00
Brandon ThomasandClaude Opus 5.5 cbebabccad Issue #102: Catalog format version 2; refuse newer libraries untouched
main added Op::SetBrowsed, Catalog.browsed and Photo.local_baseline while
the snapshot still said version 1, so v0.2.0 read such a library in part
(unknown ops as log damage, unknown fields dropped on its next snapshot).

- journal::VERSION = 2 (now public), with a format table and rules in the
  journal docs; a test with an exhaustive match over Op makes adding a
  variant fail until it is assigned a version.
- Journal::open reads the snapshot header first and refuses a newer
  version with CatalogError::Newer ("written by a newer version of
  LightCraft"), even when its catalog wouldn't parse.
- A log record whose CRC matches but whose op doesn't parse is "newer",
  not damage or a torn tail: refused before any file is modified.
- Opening an older (or snapshot-less) library writes a snapshot in this
  format before anything is appended (LoadReport::upgraded_from); a new
  library gets one at once, so v0.2.0 refuses it instead of reading a bare
  log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00
Brandon ThomasandClaude Opus 5.5 08c2acda26 Issue #101: catalog.log: cut a partial append back before retrying
A failed append (disk full, dropped share) could leave part of its batch in
catalog.log; the retry (same seqs) then shared a line with the fragment and
the next load treated it as mid-log damage, setting later edits aside.

- Journal::append cuts the log back to the last whole record on error (or,
  if that fails too, before the next append).
- FsStore drops a failed append handle so the next attempt reopens the file;
  truncate of a never-created file is a no-op.
- The loader recognises a CRC-valid record glued after a fragment (logs
  written by older builds) and skips only the fragment.
- log_bytes now counts the newline added to an unterminated last record.
- close_library writes the closing snapshot even when appending fails; the
  snapshot absorbs the queued ops (Journal::snapshot_with_unlogged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:13:00 -04:00
Brandon ThomasandClaude Opus 5.5 1a05c3eed2 Issue #103: Settings files: keep damaged ones, never overwrite unread ones, write ui.json atomically, confirm quit with unsaved changes
- Library settings (prefs.json, presets.json, view.json): only a missing
  file means "defaults". A file that doesn't parse is kept as
  <name>.corrupt-<unix time> before the defaults are used; one that can't
  be read (locked, I/O error) is never written this session (save_prefs
  refuses, presets/view saves skip it). Warnings go to library.info →
  settingsWarnings and Session::take_library_warnings; the UI shows each
  once in a window (ui.inspect → notices, button:noticeOk).
- Desktop ui.json (holds the library location): written atomically (temp +
  fsync + rename + dir fsync), saved as soon as the library path changes and
  every few seconds when it changed, failures reported; a damaged file is
  renamed to ui.json.corrupt-<time> and reported, an unreadable one is not
  overwritten.
- Quit/close with changes only in memory: retry the save (log, then a
  snapshot of everything), and if it still fails cancel the close and ask:
  Try Saving Again / Quit Anyway / Cancel (ui.inspect → quitPrompt).
- Tests: engine (damaged prefs kept, unreadable presets/prefs untouched),
  desktop (damaged ui.json kept, atomic write), headless UI (quit prompt
  buttons, settings notice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:12:26 -04:00
Brandon ThomasandClaude Opus 5.5 ee4b78f646 Issue #99: Library: exclusive lock so two processes can't open one library
Two processes on one library each kept their own catalog and seq counter;
the last snapshot silently dropped the other's edits.

- lightcraft_catalog:🔒 LibraryLock holds an exclusive OS lock
  (std File::try_lock: flock / LockFileEx) on <library>/catalog.lock for
  the session; who holds it goes to catalog.lock.owner (separate file: a
  Windows lock also blocks reads). Released on drop and by the OS when the
  process dies, so leftover files from a crash never lock anyone out. File
  systems without lock support open unprotected with a warning.
- Session::open_library takes the lock before reading anything and fails
  with EngineError::LibraryInUse ("this library is already open in
  LightCraft 0.x (process N on host)"); reopening the session's own
  library hands its lock over.
- lightcraft-cli (mcp/run/snapshot --library) adds how to drive the
  running app instead (--control + mcp --connect).
- Tests: in-process, leftover files, a killed child process, two engine
  sessions, CLI e2e (mcp --library holds it, run --library is refused).
  Existing tests that reopened a library while it was open now drop the
  first session.
- docs: parity row LRC-LIB-CATALOG-LOCK, mcp.md, web.md (no guard across
  browser tabs yet), whats-new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:48:34 -04:00
Brandon ThomasandClaude Opus 5.5 d592b29d12 Issue #97: Rename Folder / Move Folder are undo steps
The folder move and the photos' relinks form one undo step: undo renames
the folder back on disk (files and sidecars along) and restores the
paths, redo repeats it. A destination taken in the meantime is never
overwritten; the step is refused, reported and stays on the stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:46:30 -04:00
Brandon ThomasandClaude Opus 5.5 cddcb905e0 Issue #94: Control: close the connection on malformed input
A line that is not a JSON object with a string `method` (HTTP request
line, invalid UTF-8, non-object JSON, >4 MiB line) now gets one error
reply and the connection is closed, so a browser's cross-origin fetch
can't smuggle a command in its body. Connections are capped at 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:46:27 -04: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
Brandon ThomasandClaude Opus 5.5 e96a2750a1 Issue #96: Import > Copy verifies each copy like Move
Copy import used exists() then std::fs::copy: no sync, no comparison, and
a file appearing at the chosen name in between was replaced. It now goes
through import_move::copy_new, which reuses Move's copy_verified: a new
file (create_new, retrying -1, -2… on AlreadyExists), synced and compared
byte for byte with the source; a copy that fails or differs is removed and
reported as a failed import. Copy as DNG's copy step uses the same path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:22:35 -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
Brandon ThomasandClaude Opus 5.5 771b0b98d7 Issue #106: Convert to DNG and smart previews: verified, atomic writes
- Convert to DNG / Copy as DNG: the encoded DNG must decode to the same raw
  data, then goes through safe_file::write_new_unique (temp file, sync,
  read-back, hard-linked to the first free <stem>.dng / -2 / -3 name, never
  replacing a file). On failure no DNG is left, the photo is not relinked
  and the raw copy is kept.
- Photo Merge writes its DNG the same way.
- Smart previews are written atomically; smart::is_valid (header + complete
  JPEG) decides "built", has_smart_preview and the offline fallback, so a
  proxy cut short is rebuilt (reported as `repaired`); moving proxies to a
  new folder never deletes a good one in favour of a damaged copy and
  copies across drives atomically.

Stacked on the #93 branch (shared lightcraft_catalog::safe_file helper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:17:41 -04:00
Brandon ThomasandClaude Opus 5.5 f4df914731 Issue #92: Rename Photos copies a stem sidecar another file still shares
Renaming IMG_1.CR3 alone took the shared IMG_1.xmp along and left
IMG_1.JPG without it. A Stem-named sidecar that a sibling photo file
still uses is now copied (independently, never linked) instead of moved;
the last file renamed takes it. A rollback removes the copy.

Stacked on fix/issue-105-rename-rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:12:25 -04:00
Brandon ThomasandClaude Opus 5.5 882a059fac Issue #92: XMP: merge into an existing sidecar instead of replacing it
Saving (Cmd+S or auto-write) now merges LightCraft's properties into an
existing sidecar (lightcraft_meta::merge_xmp): owned properties (rating,
label, dc/IPTC/rights fields, lc:*) are replaced or removed, capture time
and GPS only when LightCraft has a value; everything else (crs:,
xmpMM:History, unknown namespaces, packet wrapper) is kept byte for byte.
The result is re-parsed and checked before it is written (temp + fsync +
rename). A sidecar that isn't valid XMP is copied to .xmp.bak-<time>
(never overwriting) before it is replaced; one that can't be read at all
is left alone and the save fails.

Shared stems: when several catalogued files map to one stem sidecar
(IMG.CR3 + IMG.JPG), it belongs to the raw (else the first by name) and
the others write and read <file>.xmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:08:18 -04:00
Brandon ThomasandClaude Opus 5.5 46732cb0fe Issue #93: Export: never overwrite an original; atomic writes
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes
  (temp file in the same folder, sync, rename, folder sync; temp removed on
  failure), write_new/write_new_unique that never replace a file and read
  the result back, same_file, and a per-thread fault injection for tests.
- engine::originals::OriginalGuard: refuses a write whose target is a
  catalogued photo's original or XMP sidecar (by file identity), whatever
  the conflict policy or exact path. Used by run_batch (export, Export with
  Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge
  previewPath, MCP render_photo/screenshot paths (new query command
  export.checkTarget) and lightcraft-cli render.
- The conflict policy now covers an export's sidecars together with the
  file (Unique gives both the same free name, Skip skips when either is
  taken).
- export::write_file replaces the plain std::fs::write in the desktop app,
  CLI, MCP and engine writers; XMP sidecar writes use the shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:03:07 -04: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
Brandon Thomas 572b55a44a Merge pull request #110 from midasdf/feat/japanese-interface-and-watermarks
Add Japanese menus and vertical Japanese text watermarks
2026-10-05 16:49:18 -04:00
Brandon Thomas bd10f89d02 Merge main into PR #110 2026-10-05 16:48:57 -04:00
Brandon ThomasandClaude Opus 5.5 fb5b18843f Issue #105: Rename Photos reports failed rollbacks; Find Missing matches by content
Rollbacks in move_file, apply_rename and undo/redo no longer ignore
errors: every move that can't be undone is reported (old -> new), and the
catalog follows the files that kept their new names (an undoable partial
rename; a logged change for undo/redo), so nothing shows as missing.
A file that can't go back keeps the sidecars that moved with it.

library.findMissing: same name + size confirmed by the stored content
hash first, then renamed files by size + content hash, then a unique
name + size match; several look-alikes are skipped and reported in
'ambiguous' instead of guessed. Files already used by a photo are skipped.

Stacked on fix/issue-95-case-only-rename (same functions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:47:50 -04:00
Brandon ThomasandClaude Opus 5.5 dfbb1ad935 Issue #95: Rename Photos: case-only renames never overwrite another file
Whether an existing target is the photo itself is decided by file identity
(dev+inode on Unix; an exact directory entry check elsewhere), not by a
lower-case name comparison, so on case-sensitive volumes img_1.JPG next to
IMG_1.JPG is a collision (-1 suffix). Moves use a no-replace rename (hard
link + unlink where supported) and case changes go through a unique temp
name. Tests model case-sensitive and case-insensitive volumes and check
the real disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:36:35 -04: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
Brandon ThomasandClaude Opus 5.5 0fd0ffb783 Issue #98: thumbnail cache only lists, counts and deletes its own files
scan() now accepts only <2 hex>/<32 hex>.jpg (matching shard) and the
cache's own <32 hex>.tmp<id> temp files, so prune/clear never delete and
size() never counts foreign files in a shared thumbs/ folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:23:32 -04:00
Brandon Thomas e99aa61fc4 Merge pull request #111 from storybored379/fix/unicode-timezone
Metadata: gracefully ignore invalid Unicode timezone offsets
2026-10-05 16:22:21 -04:00
Brandon ThomasandClaude Opus 5.5 58626b982d Fonts: replace Shippori Mincho with BIZ UDMincho (no Adobe-derived glyphs)
Shippori Mincho's name table credits 'FONTDASU / Google Inc. / but / Adobe' — its kanji
come from Adobe's Source Han Serif, which our asset rules exclude even under the OFL
(CLAUDE.md: no Source Han fonts). BIZ UDMincho (Morisawa Inc., design TypeBank Co., Ltd.;
OFL-1.1, from google/fonts at 63833b7) is a Mincho of its own with no Adobe glyphs, and
smaller (6.2 MB vs 8.7 MB). Attribution, licence text and NOTICE updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 -04: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
midasdf e00025aaf6 i18n: add Japanese menus and vertical Japanese watermarks 2026-10-06 01:07:26 +09:00
Brandon Thomas 294012742e Merge pull request #108 from storytold/pr/freebsd-ci-sync
FreeBSD CI: match the shared template
2026-10-05 10:19:07 -04:00
Brandon ThomasandClaude Opus 5.5 4d13061580 FreeBSD CI: match the shared template (git + safe.directory, 4 vCPUs, pinned vmactions/freebsd-vm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:24 -04:00
Brandon Thomas 63380829b1 Merge pull request #91 from storytold/pr/freebsd
FreeBSD: build and test the workspace in a FreeBSD VM
2026-10-05 09:30:12 -04:00
Brandon ThomasandClaude Opus 5.5 62e16d7adc FreeBSD CI: 4 vCPUs, git in the VM, pinned vmactions/freebsd-vm
The headless UI snapshot test settles for at most 120 s; on a 2-vCPU VM
under the full test load it timed out mid-render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:58:05 -04:00
Brandon ThomasandClaude Opus 5.5 d47e941a0c docs: point agents to the shared test corpora (#90)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:23 -04:00
Brandon ThomasandClaude Opus 5.5 83fb206a29 FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:23:26 -04:00
Brandon Thomas 3567980442 Merge pull request #89 from storytold/chore/ignore-target-dirs
Ignore /target-*/ build dirs
2026-10-05 07:38:00 -04:00
Brandon ThomasandClaude Opus 5.5 8cd791100b Ignore /target-*/ build dirs
Agents sometimes build into target-<name>/ next to target/; ignore those too so a
'git add -A' can never commit build artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:37:49 -04:00
Brandon Thomas ff1e51e4d4 Merge pull request #88 from storytold/fix/issue-85-cr2-cfa
Issue #85: Raw: CR2 colour pattern from the file's CR2CFAPattern tag, not a constant
2026-10-05 07:37:19 -04:00
Brandon Thomas c05f2de251 Merge main into fix/issue-85-cr2-cfa
# Conflicts:
#	docs/parity.md
2026-10-05 07:37:00 -04:00
Brandon ThomasandClaude Opus 5.5 e87ee8e36d Issue #85: Raw: CR2 colour pattern from the file's CR2CFAPattern tag, not a constant
The Bayer layout of Canon CR2 files differs by model (GBRG on the 7D, 50D,
60D, 550D, 600D, 1200D, 1300D, 5D Mark II, 1D Mark IV; RGGB on the 40D,
5D Mark III, 6D, 5DS R and others). Read it from raw IFD tag 0xc5e0
(CR2CFAPattern: 1 RGGB, 2 BGGR, 3 GBRG, 4 GRBG), anchored at the full
decoded sensor; fall back to RGGB when the tag is missing. No model table.

Checked on 35 decoded CC0 raw.pixls.us CR2s (35 models): the tag agrees
with the mosaic's green-diagonal statistic on every file; the SensorInfo
border parity does not predict the layout. Corpus manifest gains the
reporter's samples; a corpus test asserts each layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:24:26 -04:00
Brandon Thomas efef3a5df1 Merge pull request #87 from storytold/docs/roadmap-assessment
Docs: honest parity assessment — where we stand, where we're going
2026-10-05 06:49:16 -04:00
Brandon ThomasandClaude Opus 5.5 b50fbf1285 Docs: honest parity assessment — where we stand, where we're going
ROADMAP.md: new 'Where we stand' (by dimension and by kind of user; checklist 79% vs
~60-70% as a day-to-day Lightroom replacement) and 'Where we're going' (ordered priorities:
camera colour calibration, CR3 and compressed raws, verified camera coverage, render fidelity
suite, lens profiles, AI model strategy, then HDR / Classic modules / video). Current
parity numbers, M11 status, risks (raw-source decision of 2026-10-05), log.

docs/parity.md: new rows LR-PROF-CAMERACOLOR, LR-IMP-CAMERA-COVERAGE (P0) and
LR-BEHAV-RENDER-FIDELITY (P1) so quality gaps are tracked; Top gaps reordered by user impact.

README.md: honest status summary above the feature table; stale rows fixed (compressed NEF,
DNG/original export, image watermark, Move import), camera colour and AI/HDR/video rows.

AGENTS.md / CLAUDE.md: agents read the assessment before picking work, and downgrade
✅ rows they find wrong or incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:48:54 -04:00
Brandon Thomas 92bd3aa688 Merge pull request #86 from storytold/fix/issue-10-nef-compressed
Issue #10: Raw: Nikon compressed NEF (lossless + lossy)
2026-10-05 06:08:28 -04:00
Brandon ThomasandClaude Opus 5.5 6592e62912 Issue #10: Raw: Nikon compressed NEF (lossless + lossy)
Decode Nikon Huffman-compressed NEF (compression 34713): lossless and lossy
(type 1 and 2) at 12 and 14 bits, with the maker-note 0x96 linearization
curve, predictor seeds and either byte order. Clean-room: 0x96 layout and
encoding-table sizes from prose write-ups (Clevy, Claff); the fixed Huffman
tables were recovered by black-box analysis of CC0 raw.pixls.us samples
(compressed vs uncompressed shots of the same scene) and verified on ~45
files from ~35 bodies. Lossy-after-split files stay preview only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:58:31 -04:00
Brandon Thomas 8532d4ea56 Merge pull request #84 from storytold/fix/issue-78-gpu-black-export
Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
2026-10-05 05:09:57 -04:00
Brandon ThomasandClaude Opus 5.5 2218ca5a4c Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
A GPU render no longer hands back an image it cannot vouch for:
- set the wgpu device-lost callback (wgpu never reports a lost device as an
  error, so a driver reset went unnoticed); readback waits with a timeout and
  checks the poll result and map callback instead of blocking forever
- each render runs in OutOfMemory/Validation/Internal error scopes; errors
  fail that render (OOM and limit failures for that render only, device
  errors for the process) and it is redone on the CPU
- every buffer is checked against the device limit when created (no
  validation error, no process-wide disable); LIGHTCRAFT_GPU_LIMITS=webgpu|<MiB>
  creates the device with lower limits to reproduce small adapters
- the output buffer is cleared and the per-pixel kernel writes alpha 255:
  any unwritten pixel (work silently dropped, the issue's all-black export)
  fails the render; an all-black result is re-rendered on the CPU
- renders submit per stage and every 16 M invocations, the per-pixel stage
  runs in ~4 MP bands: no long submission for a slow iGPU's watchdog to reset
- ui.inspect perf.gpuReason / perf.gpuFallback, app.gpu reason/lastFallback,
  System Info and Settings say why the GPU is off (e.g. llvmpipe skipped) and
  why the last render fell back

Tests: crates/gpu/tests/fallback.rs (limit, mid-render limit, validation
error, dropped work, black photo), engine export falls back to the CPU image,
banded 3000x2000 GPU/CPU equivalence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:06:24 -04:00
Brandon Thomas 14053b2e40 Merge pull request #83 from storytold/fix/local-record-eviction
Local: forget untouched browse records of folders not browsed for 30 days
2026-10-05 02:59:23 -04:00
Brandon Thomas 1f2f25356a Merge main into fix/local-record-eviction
# Conflicts:
#	crates/engine/src/cmd/library.rs
#	crates/engine/src/library.rs
#	docs/whats-new.md
2026-10-05 02:59:05 -04:00
Brandon Thomas 2ed6af155d Merge pull request #82 from storytold/fix/persist-errors
Persistence: a failed save fails the command (change kept in memory, queued, retried)
2026-10-05 02:58:49 -04:00
Brandon Thomas cd35ebdace Merge pull request #81 from storytold/pr/release-0.2.0
Release: LightCraft v0.2.0
2026-10-05 02:41:21 -04:00
Brandon ThomasandClaude Opus 5.5 65e638ce8a Release: LightCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:41:10 -04:00
Brandon ThomasandClaude Opus 5.5 b14cf58630 Local: forget untouched browse records of folders not browsed for 30 days
Local browse records can be most of a large catalog (85k photos, 90 %
Local: 313.5 MB snapshot). Untouched Local records of folders whose last
browse is older than library.preferences forgetLocalDays (default 30,
0 = never) are now removed from the catalog when the library opens (and by
library.forgetLocal {dryRun?, days?}), as journaled, non-undoable ops, then
compacted in the background. Files and XMP sidecars are never touched;
browsing the folder again catalogues them afresh.

Untouched = a Local file record, not deleted, not a virtual copy, whose
state still hashes to the fingerprint taken when the browse catalogued it
(any change keeps it); records from before fingerprints need every
user-editable field empty/default. Kept too: records in albums (or as
cover), stacks, sources of virtual copies, visible/selected/edited ones,
ones referenced by undo/redo, ones with a smart preview.

Per-folder last-browsed times live in the catalog (Op::SetBrowsed,
normalised with folder_key; re-stamped at most hourly). Old catalogs load
unchanged; folders without a time are stamped now, so nothing is forgotten
on the first open after the upgrade.

persist_bench (85k photos, 76.5k Local, every 7th folder recent, 1 in 50
rated): 63.8k forgotten, plan 162 ms, snapshot 313.5 MB -> 94.9 MB.
Settings -> Performance -> Local folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:26:49 -04:00
Brandon ThomasandClaude Opus 5.5 104444edcb Persistence: a failed save fails the command (change kept in memory, queued, retried)
A command whose journal append fails now returns EngineError::NotSaved
("saved in memory but not written to disk: <reason>; LightCraft will retry")
instead of OK. The change stays applied and undoable, its ops stay queued and
are written by the next save (any command, or the frame loop with a 2 s
backoff). Queries and commands that change nothing are unaffected; a failed
compaction is reported in lastError, not as a failed command (and a failed
synchronous snapshot now backs off like a background one).

Session::unsaved(), library.info unsavedOps/unsavedError, ui.inspect unsaved;
the desktop UI toasts the failure and shows a warning on the top bar's cloud
icon until a save succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:26:49 -04:00
Brandon Thomas b312319c78 Merge pull request #80 from storytold/pr/unsafe-helper-crate
Never crash: move the one unsafe FFI call into crates/sysmem
2026-10-05 02:06:26 -04:00
Brandon ThomasandClaude Opus 5.5 1ef74a787d Never crash: move the one unsafe FFI call into crates/sysmem
The macOS malloc_zone_pressure_relief call (returns freed allocator pages
after raw decodes) lived in identical copies inside the desktop app and the
CLI. It now lives in lightcraft-sysmem, an isolated L0 helper crate and the
workspace's only crate allowed unsafe: one #[allow(unsafe_code)] module, a
SAFETY comment, a safe API that returns bytes released (0 off macOS), and
tests (repeat calls, release after large frees, concurrent calls). Both apps'
alloc_release.rs now just install the safe hook, and their crate roots gain
#![forbid(unsafe_code)], so every production crate except sysmem forbids it.
AGENTS.md/CLAUDE.md name the exception, per craftrules standards/never-crash.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:06:08 -04:00
storybored379andOpenAI Codex 35e38661a7 Metadata: gracefully reject invalid Unicode timezone offsets
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-04 21:11:55 -07:00
Brandon Thomas 5525ae667a Merge pull request #77 from storytold/fix/issue-29-move-import
Issue #29: Import: Move mode with rename and template folders
2026-10-04 16:15:40 -04:00
Brandon Thomas f4316dcfb8 Merge main into fix/issue-29-move-import
# Conflicts:
#	docs/parity.md
2026-10-04 16:15:25 -04:00
Brandon Thomas 116c26d8ee Merge pull request #76 from storytold/fix/issue-35-grid-frames
Issue #35: Grid: unchanged frames reuse view data and visit only nearby rows
2026-10-04 16:14:14 -04:00
Brandon Thomas 8061e0019e Merge pull request #75 from storytold/fix/issue-37-persistence
Issue #37: Catalog: compact the log on a background thread with ordered journal boundaries
2026-10-04 16:14:02 -04:00
Brandon ThomasandClaude Opus 5.5 4788f3bfff Issue #37: Catalog: compact the log on a background thread with ordered journal boundaries
At the 2,000-record threshold the UI thread serialised and fsynced the whole catalog (1-2 s
at 85k photos after streaming). Now `persist()` starts `Journal::snapshot_in_background`:

- the worker writes `catalog.snap` for op N from a clone of the catalog (photos are Arc-shared,
  so the clone is cheap) through `Store::background_writer` (a second FsStore handle; MemStore
  opt-in; none on wasm/web, which stays synchronous);
- appends go on as before (fsynced before every command returns) and are also kept in memory
  as the "tail" (records > N);
- `Journal::poll` (from `persist` and the per-frame `persist_if_dirty`) sees the worker done and
  atomically replaces the log with exactly the tail.

Crash points: before the snapshot rename -> old snapshot + whole log; between the rename and the
log rewrite -> new snapshot + whole log (records <= N skipped as stale, existing rule); after ->
new snapshot + tail. A failed snapshot keeps the log whole and retries after another
max_records records (no retry storm). At most one snapshot in flight; a synchronous snapshot
(close, Optimize Library), reopening a library and dropping the Journal first wait for it, so an
older snapshot can never replace a newer one. On-disk format unchanged.

Tests (crates/catalog/src/tests_background.rs, a gated store holds the worker mid-flight and
"crashes" by copying the files): edits during a background snapshot survive crashes at each
point and the trimmed log holds exactly seq N+1.. in order; failure loses nothing and backs
off; sync snapshot waits (no regression); drop waits; real FsStore with a leftover partial
temp file; a proptest over interleavings of edits / start / land / finish / sync snapshot /
crash. Mutations checked: trimming the log to empty, or not waiting in `snapshot`, fail them.
Engine: `threshold_compaction_runs_in_the_background` (crash copy mid-run, reopen without close).
`library.info` -> persistence gains snapshotRunning, failedSnapshots, maxBlockingMs and
lastSnapshot.{background, blockingMs}.

persist_bench, same session (load avg ~160), UI-thread time per threshold compaction:
  85k photos: synchronous 0.85-1.7 s (median 1.25 / 1.34 s) -> background: blocking median
              45 / 74 ms (clone 1.3-154 ms + log rewrite 9-23 ms); worker 1.2-2.4 s; 230-380
              single-op appends during it at median 4-6 ms (same as without)
  10k photos: synchronous 72-233 ms -> blocking median 12.5 / 14 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:01:28 -04:00
Brandon ThomasandClaude Opus 5.5 ae1cbbe87b Issue #37: Catalog: stream the snapshot into the temp file instead of building two strings
Journal::snapshot serialised the catalog into a String and then copied it into a second
`format!`-ed String before writing (2x the snapshot size in memory, ~620 MB at 85k photos).
It now streams `{"format":…,"seq":N,"catalog":` + serde_json::to_writer + `}\n` through a
1 MiB BufWriter into the store (new `Store::write_atomic_with`; FsStore writes the temp file
directly, then flush + sync_all + rename + dir fsync as before; other stores buffer, as before).

Durability and format are unchanged: same temp + fsync + atomic rename; a failed write leaves
the old snapshot (and now removes the temp file); the bytes are identical to the old format
(test `streamed_snapshot_keeps_the_on_disk_format`, which also loads a legacy snapshot), so
no version bump. Also tested: `failed_streamed_write_keeps_the_old_file`.

persist_bench, 85k photos (310 MB snapshot), compaction at the 2,000-record threshold,
before/after binaries alternated in the same session (load avg ~130-170), building+compacting
only (PERSIST_BENCH_NO_LOAD):
  compaction median: before 3.5 s / 4.6 s, after 1.9 s / 2.5 s
  peak RSS:          before 926 MB / 1263 MB, after 308 MB / 366 MB
  10k photos peak RSS: 149-235 MB -> 61-68 MB (times too noisy to compare)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:43:13 -04:00
Brandon ThomasandClaude Opus 5.5 db6242d2d3 Issue #37: Catalog: instrument persistence (append, snapshot stages) and whole-frame update time
- Journal::stats(): last/max append time (encode + write + sync_data), snapshot count, last
  snapshot by stage (serialise, write+sync+rename, log reset, bytes, records), slowest
  snapshot. Reported by `library.info` → `persistence`; printed per write to stderr under
  LIGHTCRAFT_PROFILE.
- UI: `perf.frame_ms` only covers layout (`LightcraftApp::ui`); `logic()` (control channel,
  shortcuts, render polling, `persist_if_dirty`) ran untimed. Add `logicMs`, `updateMs`
  (logic + layout) and `maxUpdateMs` to `ui.inspect` → perf and a "Frame update" row to
  Help ▸ System Info.
- `crates/catalog/examples/persist_bench.rs`: synthetic 1k/10k/85k-photo catalogs (90 % Local
  browse records) on FsStore: append latency, compaction at the 2,000-record threshold, load.

Baseline (release, load avg ~240, so absolute times are inflated; median of 3 compactions):
  1k photos  (3.6 MB snap): append 7 ms, compaction 31 ms (serialise 5, write+sync 12, reset 14)
  10k photos (36.5 MB):     append 15 ms, compaction 528 ms (serialise 463, write+sync 56)
  85k photos (310 MB):      append 12 ms, compaction 4.6 s (serialise 2.2-4.4 s, write+sync
                            0.25-0.8 s); peak RSS 963 MB; load 4.7 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:43:13 -04:00
Brandon ThomasandClaude Opus 5.5 73a2c01676 Issue #35: Grid: unchanged frames reuse view data and visit only nearby rows
An unchanged grid frame used to copy every visible id, hash them twice
(date-run and layout cache keys), clone the date runs, build a full
Vec<Cell>, rebuild the stack index, loop over every cell for the
viewport test and, in Local folder views, look up every photo to count
the browsed ones for "Add N to My Photos".

- engine: Session keeps the visible list as Arc<[PhotoId]> with a
  process-unique generation that changes whenever the list is
  recomputed; `visible_shared()` hands both out without copying.
- grid: date runs, layout, stack index and the Local count are cached
  by that generation (GridCache; replaces the id-hashing date_runs and
  grid_layout caches). The layout gains a row index (GridRow), so the
  visible + prefetch rows and the date headers are found by binary
  search; the sticky header too.
- GridStats counters (frames, cells visited, view/layout/stack-index
  builds, last frame time) for the bench and tests.

Measured with the new `cargo run --release -p lightcraft-ui-egui
--example grid_bench` (synthetic library, 1400x900, thumbs 160, missing
files so no decoding is timed, scrolled to the middle, 120 unchanged
frames; machine at load ~200-250, so medians of the whole app frame,
before/after interleaved in the same session):

  photos layout  source  median frame ms  allocs/frame   cells visited
                         before -> after  before -> after  before -> after
     200 photo   all      1.29 -> 1.28    4747 -> 4729     200 -> 93
     200 square  all      1.09 -> 1.09    4771 -> 4753     200 -> 74
  10 000 photo   all      1.52 -> 1.41    5425 -> 4911   10000 -> 100
  10 000 photo   local    1.90 -> 1.42    5475 -> 4948   10000 -> 100
  10 000 square  local    1.62 -> 1.07    5365 -> 4838   10000 -> 76
  85 000 photo   all      2.41 -> 1.29    8996 -> 4729   85000 -> 93
  85 000 square  all      2.29 -> 1.08    9020 -> 4753   85000 -> 74
  85 000 photo   local   17.6-34.0 -> 1.32  9049 -> 4766   85000 -> 93
  85 000 square  local   16.2-25.0 -> 1.10  9073 -> 4790   85000 -> 74

Frame cost no longer depends on the number of results; what is left
(~1 ms, ~4.7k allocs) is drawing the ~75-100 cells near the screen.
Headless snapshots of the demo grid (justified with a sticky header,
after keyboard navigation, square) are byte-identical before/after.

Tests: layout rows cover every cell in order and the binary search
matches a linear scan (square + justified, groups); a 3000-photo
headless library checks that unchanged frames build nothing and visit
< 300 cells, plus date headers, scroll-to-selection, stack badges,
keyboard navigation and a rotation re-laying the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:40:19 -04:00
Brandon ThomasandClaude Opus 5.5 589741b21e Issue #29: Import: Move mode with rename and template folders
library.import mode `move` (UI: Transfer ▸ Move, MCP import tool): reuses
Copy's destination, folder organisation (incl. custom templates) and rename
template. Each photo and its XMP sidecars (both namings) are placed without
touching the source (hard link on the same volume, else a new file that is
copied, synced and compared byte for byte; taken names get -1, -2…, never
overwritten). Sources are removed only after the catalog records are
committed and saved, and only when the destination is still intact; failed,
duplicate, unchecked and in-library files keep their sources; sources that
can't be removed are kept and reported (`kept`). Undo removes the catalog
records and leaves the moved files at the destination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:39:30 -04:00
Brandon Thomas 840b294e23 Merge pull request #60 from storybored379/feat/gpx-tracklog
LRC-MAP-TRACKLOG: Auto-Tag from Tracklog (GPS from a GPX file by capture time)
2026-10-04 15:32:00 -04:00
Brandon Thomas f7361a7d01 Merge main into feat/gpx-tracklog
# Conflicts:
#	docs/parity.md
#	docs/whats-new.md
2026-10-04 15:31:45 -04:00
Brandon Thomas 8011396e91 Merge pull request #51 from storybored379/feat/copyright-status
LRC-LIB-METADATA: Copyright Status, Rights Usage Terms and Copyright Info URL
2026-10-04 15:30:24 -04:00
Brandon Thomas efdd8a4e10 Merge main into feat/copyright-status
# Conflicts:
#	crates/engine/src/tests_xmp.rs
#	docs/parity.md
2026-10-04 15:26:53 -04:00
Brandon Thomas ba5fff320b Merge pull request #59 from storytold/pr/never-crash-ui
Never crash: ui-egui and the web app
2026-10-04 14:24:24 -04:00
Brandon Thomas 528b4b46db Merge pull request #58 from storytold/pr/never-crash-guard
Never crash: last-resort guard around command execution
2026-10-04 14:24:15 -04:00
Brandon Thomas 815be4ef7f Merge pull request #57 from storytold/pr/never-crash-engine-mcp
Never crash: engine (outside export.rs) and mcp
2026-10-04 14:23:58 -04:00
Brandon Thomas 7b75cf4f91 Merge pull request #55 from storytold/pr/never-crash-gpu-merge
Never crash: gpu and merge
2026-10-04 14:23:40 -04:00
Brandon Thomas 3f20f98931 Merge pull request #56 from storytold/pr/never-crash-core
Never crash: color, geom, raster, develop, scenes, preview, pipeline
2026-10-04 14:23:22 -04:00
Brandon Thomas 2700c8a814 Merge pull request #54 from storytold/pr/never-crash-codecs-catalog
Never crash: codecs and catalog
2026-10-04 14:23:05 -04:00
Brandon Thomas 2e2f7ee5ac Merge pull request #53 from storytold/pr/never-crash-raw-tiff
Never crash: raw and tiff decoders
2026-10-04 14:22:47 -04:00
Brandon Thomas 302ed02d94 Merge pull request #52 from storytold/pr/never-crash-steering
Never crash: steering rule and clippy.toml
2026-10-04 14:22:29 -04:00
Brandon Thomas c02b39a15d Merge pull request #73 from storytold/fix/issue-30-import-entry-point
CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing)
2026-10-04 14:01:10 -04:00
Brandon Thomas b56e40e8d5 Merge main into fix/issue-30-import-entry-point
# Conflicts:
#	crates/ui-egui/src/import.rs
#	docs/parity.md
2026-10-04 14:00:55 -04:00
Brandon Thomas 42c19ddd1a Merge pull request #72 from storytold/fix/issue-32-folder-template
Issue #32: Import: custom date-based folder template for Copy
2026-10-04 14:00:34 -04:00
Brandon Thomas 6bf64277c4 Merge pull request #71 from storytold/fix/issue-31-rename-tag-help
Issue #31: Import: tag help beside the Rename field
2026-10-04 14:00:20 -04:00
Brandon Thomas fb47b35837 Merge pull request #70 from storytold/fix/issue-19-sidecar-capture-time
Issue #19: XMP sidecar capture time fills in when the file has none
2026-10-04 14:00:07 -04:00
Brandon Thomas 3b60b5bbc7 Merge pull request #69 from storytold/fix/issue-47-masking-fit
Issue #47: Masking: keep the panel's contents within its width when a mask is selected
2026-10-04 13:59:55 -04:00
Brandon Thomas 3165abf162 Merge pull request #68 from storytold/fix/issue-20-resizable-panels
Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge
2026-10-04 13:59:41 -04:00
Brandon Thomas 00308b50f9 Merge pull request #67 from storytold/fix/issue-33-async-devices
Issue #33: Devices: serve the cached card list and rescan in the background
2026-10-04 13:59:27 -04:00
Brandon Thomas e7722296d3 Merge pull request #66 from storytold/fix/issue-27-quit-last
Issue #27: Menus: keep Quit last in the in-window File menu
2026-10-04 13:59:13 -04:00
Brandon Thomas f1dce86609 Merge pull request #65 from storytold/fix/issue-21-local-roots
Issue #21: Local: kept folders stay roots while browsing below them
2026-10-04 13:59:00 -04:00
Brandon Thomas 9d756b145d Merge pull request #64 from storytold/fix/issue-22-local-path-identity
Issue #22: Local: recognise equivalent folder paths
2026-10-04 13:58:47 -04:00
Brandon Thomas 9698defed6 Merge pull request #63 from storytold/fix/issue-36-smart-album-counts
Issue #36: cache smart-album counts in the sidebar
2026-10-04 13:58:33 -04:00
Brandon Thomas f940e0d03a Merge pull request #62 from storytold/fix/issue-34-missing-scope
Issue #34: Missing Photos checks library photos only
2026-10-04 13:58:21 -04:00
Brandon Thomas 40a599dac0 Merge pull request #61 from storytold/fix/clippy-1.99
CI: clean under clippy 1.99
2026-10-04 13:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 ef57ae208b Never crash: the per-crate deny attribute is the end state, not workspace lints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:57:00 -04:00
storybored379andClaude Opus 5.5 1901a756bb LRC-MAP-TRACKLOG: Auto-Tag from Tracklog — GPS from a GPX track log by capture time
GPX 1.0/1.1 reader in lightcraft-meta (timed track points per segment; interpolation only within a
segment, nearest point within maxGap otherwise). Engine command photo.autoTagTracklog (path or gpx
text, camera time-zone offset, maxGap, replace, dryRun; one undo step) and Photo ▸ Auto-Tag from
Tracklog… with a time-zone prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:52:12 -07:00
Brandon ThomasandClaude Opus 5.5 bd2e756b24 Never crash: gpu and merge free of panicking calls
gpu (its render already runs under catch_unwind and falls back to the CPU):
- a failed readback (device lost, out of memory) logs, marks the GPU
  broken and returns zeros, so crate::render discards the result and
  renders on the CPU, instead of panicking;
- an unknown kernel name does the same instead of panic!;
- finish-parameter fields are looked up as Option (debug_assert on an
  unknown name) instead of panic!;
- the warp sampler matches (mode, warp) like the CPU pipeline;
- Buf::raw keeps one documented expect (Some from construction to Drop).

merge:
- pyramid building (push_pull, Blender::finish), camera graph growth,
  bundle adjustment, focal median and alignment use let-else / filter_map
  instead of expect;
- synth::bracket_dngs / pano_views return Result (a missing demo scene
  or a DNG write error is an error); lightcraft-cli synth-merge and the
  engine merge tests handle it.

lightcraft-gpu, lightcraft-merge and lightcraft-cli now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the
steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 fc13807f91 Never crash: color, geom, raster, develop, scenes, preview, pipeline
- color: RgbSpace::to_xyz/from_xyz and bradford() no longer expect a
  matrix inverse. Degenerate (collinear) primaries, which readers reject
  before building a space from a file, leave the primaries unscaled /
  give the identity; the constant Bradford and OKLab matrices fall back
  to the identity, which can't happen (round trips are tested).
- pipeline geometry: sample() matches (mode, warp) instead of
  expect("warp"); a warp plan without a warp (sample_plan never makes
  one) samples through the equivalent affine.
- color, geom, raster, develop, scenes, preview and pipeline now #![deny]
  unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in
  the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 685cf6f2e8 Never crash: ui-egui and the web app free of panicking calls
- import / merge polling: let-else on the task instead of expect("task").
- the merge worker catches a panic and reports "the merge failed
  unexpectedly" instead of dropping its channel, which left "Merging…"
  up forever.
- web: window() returns Option; its ten callers handle a missing window.
- lightcraft-ui-egui and lightcraft-web now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:33:45 -04:00
Brandon ThomasandClaude Opus 5.5 5218981c7b Never crash: last-resort guard around command execution
Session::execute (the entry point of the UI, CLI, control channel and
MCP) runs each command through lightcraft_engine::guard::catch: a panic
that escapes a command becomes that command's error (EngineError::Other,
shown like any failed command) and the session, catalog and undo history
stay usable. The desktop app installs guard::install_hook, which keeps
the default stderr report and appends the panic location and message to
<temp>/lightcraft-panics.log.

The desktop app crate (already clean) now #![deny]s unwrap/expect/panic/
unimplemented/todo/unreachable; clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:22 -04:00
Brandon ThomasandClaude Opus 5.5 a74b1b32ff Never crash: engine panic sites outside export.rs; mcp denies panics
- merge output_path: a bounded search for a free name instead of
  `for k in 1..` + unreachable!().
- library open: Option::insert returns the new library, no expect.
- relink: a one-op batch unwraps through <[Op; 1]>::try_from, no expect.
- lightcraft-mcp (already clean) now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml as in the steering PR.

crates/engine/src/export.rs keeps one expect (open PR #51 edits that
file), so lightcraft-engine doesn't get the deny attribute yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:20:44 -04:00
Brandon ThomasandClaude Opus 5.5 eb378c491a Never crash: codecs and catalog free of panicking calls
- catalog store: the journal appender is a let-else with an io::Error,
  not expect("appender just opened").
- catalog dates: group_label slices with get(), so a non-ASCII key of a
  date's length no longer panics mid-character (regression test).
- codecs jpeg_par: rows come from map_while instead of next().unwrap().
- lightcraft-codecs and lightcraft-catalog now #![deny] unwrap/expect/
  panic/unimplemented/todo/unreachable (one test let-else uses panic!
  instead of unreachable!); clippy.toml as in the steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:18:11 -04:00
Brandon ThomasandClaude Opus 5.5 b42ca76777 Never crash: raw and tiff decoders free of panicking calls
- ljpeg: a scan naming an undefined Huffman table is an error, not an expect.
- ARW2/RW2 block reads use get() + try_from instead of slice + expect.
- Vendor fallback CFAs use Cfa::bayer_static (literal names, tested)
  instead of Cfa::bayer(..).expect("static").
- dngwrite matches (data, compression) instead of a let-else unreachable!.
- lightcraft-raw and lightcraft-tiff now #![deny] unwrap/expect/panic/
  unimplemented/todo/unreachable; clippy.toml (same as the steering PR)
  keeps them allowed in tests.
- tests/vendor_robust.rs: synthetic NEF, ARW, PEF, SRW, ORF, RW2, CR2 and
  RAF files, truncated at every length and randomly mutated, must decode
  to an error, never panic (no new crash found).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:00:26 -04:00
Brandon ThomasandClaude Opus 5.5 a8c3f01e1e CI: clean under clippy 1.99 (as_chunks for constant chunk sizes, ? in export resize parsing)
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens
clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the
remainder that chunks_exact skipped is the remainder as_chunks returns separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:53:43 -04:00
Brandon ThomasandClaude Opus 5.5 4f4426e498 Issue #30: Import Photos… as the clear import entry point
File now opens with Import Photos… (Cmd+Shift+I), Import from Folder… and
Import from Device (command ids file.addPhotos / file.addFolder /
file.addFromDevice unchanged; library.import's label is Import Photos).
The review dialog is titled Import Photos, names its source (a scanned
folder is not added to Local; Local -> Browse Folder... is the persistent
location), labels the transfer mode and explains Add in place vs Copy, and
its button reads Import N Photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:44:15 -04:00
Brandon ThomasandClaude Opus 5.5 0ad313d445 Issue #47: Masking: keep the panel's contents within its width when a mask is selected
Selecting a mask added the Add/Subtract/Intersect/Invert/Delete row in
one non-wrapping horizontal layout (~273 pt in a ~220 pt column), and the
Create New Mask grid needed 226 pt: the panel's contents grew wider than
the panel (a 250 pt panel came out 275 pt wide) and were drawn
shifted left, clipped by the photo area. The action row now wraps, the
tile grid sizes its tiles and columns to the available width (four tiles
of 48–52 pt, else three), and a component's name is truncated before its
options button. A headless test checks that every widget stays inside the
right panel at 250, 270, 330 and 520 pt, with and without a selected mask
and with the brush settings open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:41:42 -04:00
Brandon ThomasandClaude Opus 5.5 e95e9259ea Never crash: steering rule, clippy.toml for the no-panic lints
AGENTS.md/CLAUDE.md gain a "Never crash" section ahead of the
non-negotiables (no unwrap/expect/panic/unreachable/todo/unimplemented or
unsafe in non-test code, Result + ?, checked indexing and arithmetic on
input-derived values, bounded recursion, lock poisoning, the last-resort
guard, a regression test per crash fix), linking
craftrules/standards/never-crash.md. README's Contributing list points to it.

clippy.toml allows unwrap/expect/panic/indexing in tests only, so crates
can carry #![deny(clippy::unwrap_used, ...)] without touching their tests.

unsafe_code stays "deny" for now: apps/lightcraft and apps/lightcraft-cli
call malloc_zone_pressure_relief (libSystem) through a two-line FFI block
to return freed memory on macOS, and no safe wrapper crate exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:39:13 -04:00
Brandon ThomasandClaude Opus 5.5 24cf3abb62 Issue #32: Import: custom date-based folder template for Copy
library.import organize now also takes a folder template, e.g.
{date:%Y}/{date:%Y%m%d} -> 2026/20260114 (Organize::Template). The
template's own / (or \) make the folder levels; each level is expanded
with the rename tokens and sanitized, so metadata can never add a level or
climb out of the destination; absolute paths, drive letters, ~ and . / ..
levels are refused. Dated by capture time, else the import time; a level
whose metadata is missing becomes 'unknown'. The import dialog's Folders
menu gains 'Custom template...' with a template field, tag help, live
validation and an example full destination for every mode; the dialog
stays open (with a toast) when the import is refused. The MCP import
helper passes mode/destination/organize/rename through (docs/mcp.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:36:38 -04:00
Brandon ThomasandClaude Opus 5.5 d6476e7497 Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge
Both side panels had an exact width. They are now resizable egui panels
whose width lives in UiState (leftWidth / rightWidth, serde, sanitized on
load) so it survives panel switches, view changes and restarts; egui's
own remembered size is dropped each frame and only a drag of the edge
writes back (measured from the fixed edge to the pointer, so content
wider than the panel can't creep it). Limits: left 200–480 pt, right
250–520 pt, and the photo area keeps at least 360 pt; a narrower window
shrinks the panels without forgetting the chosen width. The bottom bar,
which sits between the panels, now drops Copy/Paste Edit Settings and
then the rating pill when its centre has no room instead of overlapping
the zoom controls. The unused left_w theme token is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:18:04 -04:00
Brandon ThomasandClaude Opus 5.5 a16a81ad08 Issue #21: Local: kept folders stay roots while browsing below them
Browsing a folder inside a listed Local location (built-in or kept) no
longer adds it as a root of its own: it is highlighted inside that
location's tree, which opens down to it once per browsed folder, so its
ancestors and siblings stay reachable. A folder outside every listed
location gets a session-only row that stays while browsing below it and
offers Keep in Local; subfolder rows offer Add to Local. New UI command
local.addRoot keeps a folder in Local (saved with ui.json, so it
survives restarts; it un-hides it too); Browse Folder... uses it.
Removal stays sidebar-only (local.hide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:15:12 -04:00
Brandon ThomasandClaude Opus 5.5 8be82cacf4 Issue #31: Import: tag help beside the Rename field
rename::TOKENS / DATE_DIRECTIVES / TEMPLATE_NOTES are now the one list of
file-name template tags (a test checks expand_tokens knows each, and that
the rename/import command descriptions mention them all). A Tags toggle
beside the import Rename field (also Rename Photos and export File name)
lists every tag with its meaning and a live example, the {date:...}
directives and the rules (extension appended, blank keeps names, missing
date/metadata, unknown tags stay literal); clicking a tag inserts it at
the text cursor. Unknown tags are flagged under the field. New query
command photo.renameTokens returns the same help as JSON. The import
preview now uses the probe's metadata and the import time for undated
photos, as the import itself does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:04:27 -04:00
Brandon ThomasandClaude Opus 5.5 ce3e2215fe Issue #22: Local: recognise equivalent folder paths
Local compared folder paths as raw strings, so a built-in location built
as "{home}/{sub}" and the same folder returned by library.browse with
native separators could be listed twice. Folder identity now goes
through catalog::query::folder_key (both separators, trailing/doubled
separators, ./.., verbatim and UNC prefixes, drive-letter case; whole
path case-folded on Windows): the sidebar's root list, the highlighted
and expanded-on-the-way folder, saved Browse Folder roots and
local.hide / local.restoreHidden all compare by it. Built-in paths are
joined with the platform separator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:04:21 -04:00
storybored379andClaude Opus 5.5 18153682f2 LRC-LIB-METADATA: Copyright Status, Rights Usage Terms and Copyright Info URL
Catalog Meta gets copyright_status (unknown / copyrighted / publicDomain), usage_terms and
copyright_url, read and written as XMP Rights Management fields (xmpRights:Marked, UsageTerms,
WebStatement) on import, sidecars and exports (kept by "copyright only"). photo.setMeta,
copy/paste metadata, metadata presets, a Copyright Status smart-album rule and the Info panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:01:00 -07:00
Brandon ThomasandClaude Opus 5.5 889583433c Issue #36: cache smart-album counts in the sidebar
The album rows read their counts from Caches::album_counts, recomputed
only when the catalog revision changes (photos, metadata and album rules
all bump it) and, while a smart album uses an "in the last..." rule,
when the session clock enters a new minute. Catalog::album_count counts
smart-album matches in place instead of building the id list for its
length. RuleSet/Filter::depends_on_now tell clock-relative rules apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:10 -04:00
Brandon ThomasandClaude Opus 5.5 1a6a9de7ee Issue #33: Devices: serve the cached card list and rescan in the background
Menus are rebuilt every frame, and File → Add from Device called
devices(), which scanned every mount (D:..Z: on Windows) while holding
the cache mutex once its 2 s cache expired. devices() now returns the
last scan at once and starts at most one background rescan when it is
stale; the scan runs without the lock. A changed result calls an
on_change hook (the UI requests a repaint), so hot-plugged cards still
appear. library.devices and Add from Device without a path scan
synchronously (devices_now). Volume roots are probed for DCIM directly
instead of listing them (Windows never lists a root). No threads or
clock on wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:29 -04:00
Brandon ThomasandClaude Opus 5.5 37ab41478d Issue #19: XMP sidecar capture time fills in when the file has none
A sidecar's exif:DateTimeOriginal / photoshop:DateCreated / xmp:CreateDate
now reaches Photo.captured on import and on Read Metadata from File when
the file has no embedded capture time (embedded wins). On import the
sidecar is read before copying, so the date also files and names copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:40:54 -04:00
Brandon ThomasandClaude Opus 5.5 20d156c878 Issue #34: Missing Photos checks library photos only
Missing Photos (sidebar count, the view, library.missing, Find Missing)
now shares one scope rule, cmd::missing::checked_path: non-deleted
library photos backed by a file. Local browse records, which can
outnumber the library by orders of magnitude, are never stat'ed. The
Missing view checks only the photos its query kept instead of every
catalog record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:11 -04:00
Brandon ThomasandClaude Opus 5.5 7c662bc5d6 Issue #27: Menus: keep Quit last in the in-window File menu
Registered File commands the explicit layout doesn't list were appended
after Quit LightCraft. Items in LAST (app.quit) are now held back and
placed after the fallback entries, in their own separator group. The
macOS native menu filters Quit into the app menu and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:38:44 -04:00
Brandon Thomas fb8e1e93aa Merge pull request #46 from GlemNL/fix/issue-24-hide-local-locations
Local: Remove from Local hides a sidebar location, with a way to restore
2026-10-04 10:24:27 -04:00
Brandon Thomas 5b73e4f08b Merge main into fix/issue-24-hide-local-locations
# Conflicts:
#	crates/ui-egui/src/panels/left.rs
2026-10-04 10:24:12 -04:00
Brandon Thomas 0467eb6c58 Merge pull request #43 from GlemNL/fix/issue-23-local-folder-async
Local: browse a folder in the background with progress
2026-10-04 10:24:03 -04:00
Brandon ThomasandClaude Opus 5.5 65bc8bcb42 Headless: a running folder scan counts as busy, so settle waits for a background browse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon ThomasandClaude Opus 5.5 4267a68b30 Local: use ? for the browse path (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon ThomasandClaude Opus 5.5 3ac1e55a6c Local: drop the needless struct update in library.import (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:19:54 -04:00
Brandon Thomas 94fd0e6994 Merge main into fix/issue-23-local-folder-async
# Conflicts:
#	crates/ui-egui/src/panels/grid.rs
2026-10-04 10:19:54 -04:00
Brandon Thomas 4b863e9d47 Merge pull request #45 from GlemNL/fix/issue-25-active-filters
Show active filters as chips and count matching of total photos
2026-10-04 10:19:04 -04:00
Brandon Thomas cdaacc1e83 Merge pull request #48 from GlemNL/fix/issue-26-smart-previews-location
Smart Previews: choose the folder per library
2026-10-04 10:18:57 -04:00
Brandon Thomas e1857511af Merge pull request #44 from storybored379/feat/export-naming-tokens
LR-EXP-NAMING: export file names use the Rename Photos tokens (+ num, folder, lens, iso, rating, creator)
2026-10-04 10:18:50 -04:00
Brandon Thomas 1d7d1f66ab Merge pull request #49 from GlemNL/fix/issue-28-menu-spacing
UI: more spacing between in-window menu headings
2026-10-04 10:18:44 -04:00
Brandon Thomas cea57e2dc7 Merge pull request #42 from storybored379/pr/mcp-crop-requires-args
MCP: crop returns an error when given no rect, angle or reset instead of a silent no-op
2026-10-04 10:18:37 -04:00
Brandon Thomas 14594ee635 Merge pull request #50 from storytold/pr/license-standard
Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
2026-10-04 10:09:00 -04:00
Brandon ThomasandClaude Opus 5.5 320bcad19a Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
Add LICENSE-MIT and LICENSE-APACHE (Copyright (c) 2026 ArtCraft Team and the
LightCraft contributors) and docs/brand/LICENSE-brand.txt from the Crafting Apps
licensing standard. Rewrite NOTICE to the standard template and drop the stale
Source Sans 3 entry (only Inter is bundled). Attribute docs/brand/ in
assets/ATTRIBUTION.md so `cargo xtask assets` passes again. README gets the
standard "License and credits" section; AGENTS.md/CLAUDE.md note the brand
exception to the asset rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:05:02 -04:00
Clément LandClaude Sonnet 5.5 00766f8c21 UI: more spacing between in-window menu headings (#28)
The headings are frameless buttons, which egui renders without padding, so
the gap now comes from the layout's item spacing (24 px). The width check
that falls back to the single "Menu" button uses the same gap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:42:39 +02:00
Clément LandClaude Sonnet 5.5 78f627228f Smart Previews: choose the folder per library, with a move/leave/delete choice (#26)
Smart previews always lived in <library>/Smart Previews, so a large set of proxies
filled the library's drive. The folder is now a per-library setting (prefs.json):

- library.smartPreviewsLocation shows the effective folder (path, count, size,
  availability) or changes it; changing it needs an explicit choice for the
  previews already built (move, leave or discard), and reset returns to the
  library's own folder.
- The new folder must accept writes before anything moves. A folder whose parent
  is missing (an unplugged drive) is an error and is never created on the current
  drive; building also refuses while a chosen folder is unavailable.
- Building, discarding, status and offline rendering all use the chosen folder.
- Settings -> Performance -> Smart previews shows the path before building, what
  it holds, the existing-previews choice, Choose Folder... and Use Library Folder.

The thumbnail cache stays in the library.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:21:25 +02:00
Clément LandClaude Sonnet 5.5 1c50d538c3 Local: Remove from Local hides a sidebar location; a row brings hidden ones back (#24)
Right-clicking a top-level Local location (built-in or the browsed folder) offers
Remove from Local. It only hides the shortcut: the folder, its photos and catalog
membership are untouched. The hidden list is saved with the UI state. While any
location is hidden, "Show N hidden locations" under Browse Folder… restores them,
and choosing a hidden folder with Browse Folder… puts it back. Both actions are
commands (local.hide, local.restoreHidden).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:10:24 +02:00
Clément LandClaude Sonnet 5.5 431186a24e Library: show active filters as removable chips and count matching of total photos (#25)
A filter set from the sidebar (By Date), the search field or the filter bar
narrows the current source, but nothing said so: a folder with a hidden date
filter looked empty.

- Chips under the grid header, one per active filter, each with an X that
  clears only that filter; "+N more" lists the ones that do not fit; Clear all.
- The header counts "N of M photos" (matching of the source's total) while a
  filter is on; the total is cached per source/catalog revision.
- A badge on the top-bar filter icon counts the active filters, filter bar
  open or not.
- An empty result says no photos match the active filters, and how many the
  source holds, instead of implying the folder is empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:53:25 +02:00
Clément LandClaude Sonnet 5.5 257a982678 Say when a filter hides the photos of an empty grid or filmstrip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:42:34 +02:00
Clément LandClaude Sonnet 5.5 d6be9f6342 Local: clicking the folder being read again keeps the running read instead of restarting it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:35:48 +02:00
Clément LandClaude Sonnet 5.5 e076f65df8 Sidebar: count missing files on a worker thread; read browsed folders in smaller batches
The Missing count stat()ed every catalog photo on the UI thread, which on a network share
(and with browsed NAS photos in the catalog) froze the window for seconds during a folder
browse. It now runs in the background and shows the last finished count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:32:18 +02:00
storybored379andClaude Opus 5.5 309922eac9 LR-EXP-NAMING: export file names use the Rename Photos tokens
rename::expand_tokens is shared by Rename Photos, import renaming and export naming, so {seq:N},
{date:%…}, {camera}, {title}, {ext} work in exports too (a bare {seq} still pads to 3 digits there).
New shared tokens: {num} (number at the end of the name), {folder}, {lens}, {iso}, {rating},
{creator}. An export name that expands to nothing keeps the original name. The MCP export tool
passes startNumber through and documents the tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:26:57 -07:00
Clément LandClaude Sonnet 5.5 919e65efaf Local: browse a folder in the background with progress; keep picked folders in the sidebar
Browse Folder listed, probed and imported the whole folder on the UI thread, so a
network share froze the window with no feedback. It now lists and reads on a worker
(progress window with Cancel), then adds the photos in small batches reusing the
scan's probes. Folders picked with Browse Folder… stay in the Local list after
browsing elsewhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:22:47 +02:00
storybored379andClaude Opus 5.5 d53025cc34 MCP: crop returns an error when given no rect, angle or reset instead of a silent no-op
The crop tool fell through to crop.set with an empty object when none of
its parameters were given, so a call with a guessed parameter (e.g.
`aspect: "1:1"`) or `reset: false` answered "ok" and left the photo
unchanged. It now returns an error naming the parameters it needs, as
set_develop already does for an empty call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
Brandon Thomas e3b61fc883 Merge pull request #41 from storytold/fix/issue-12-14-curve
Tone curve: drag points, reset curves, point-curve presets (fixes #12, #13, #14)
2026-10-04 03:05:47 -04:00
Brandon Thomas b80bec6e3c Merge pull request #40 from storytold/fix/issue-10-preview-only
Mark raws shown from their embedded preview as "preview only" (refs #10)
2026-10-04 03:05:18 -04:00
Brandon Thomas ad0b947f07 Merge pull request #39 from storytold/fix/issue-9-luminar
Import Luminar looks (.lmp, .mplumpack) (fixes #9)
2026-10-04 03:04:50 -04:00
Brandon ThomasandClaude Opus 5.5 966db71295 LR-EDIT-CURVE: point-curve presets (built-in, save, delete, import/export)
A Point Curve dropdown under the curve graph shows the preset the photo's
point curves match (or Custom) and lists our own built-ins (Linear, Medium
Contrast, Strong Contrast: symmetric S-curves, values chosen here) and the
user's presets; Save Point Curve... names and stores the current four
channels, right-click deletes, Import/Export Presets... read and write
.lccurve JSON files. Presets set only the point curves (parametric curve and
Refine Saturation stay) and are saved with the library preferences.

Engine commands: curve.presets, curve.applyPreset, curve.savePreset,
curve.deletePreset, curve.importPresets, curve.exportPresets; UI commands
file.importCurvePresets / file.exportCurvePresets (file dialogs on desktop).

Fixes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 1169a1beda LR-IMP-FORMATS: mark raws shown from their embedded preview as "preview only"
Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF
from a D3200/D5100) silently fell back to the camera's embedded JPEG, so
users edited a JPEG with the camera's picture style (e.g. Monochrome) baked
in without being told.

- catalog: Photo.preview_only (serde default; old catalogs load), set /
  cleared by Op::SetContent with an exact inverse; Photo::develops_raw().
- engine: ProbeInfo.preview_only carries the decoder's Unsupported reason
  into import, the import review (previewOnly) and catalog.query
  (previewOnly); photo.inspect includes it. photo.reload and photo.relink
  re-probe and clear/set it when the file decodes (or not) now.
- source_info: a preview-only raw is a rendered source (relative WB scale,
  display tone curve) instead of raw:true/5500 K + filmic tone map on a
  display-referred JPEG; import uses the JPEG default preset for it; the
  Edit panel shows the rendered-file WB sliders.
- UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in
  Edit and Info (amber caution token, Info icon drawn in code).
- Tests: catalog serde/undo, engine import -> reopen -> reload/relink,
  headless UI badge/notice widgets.

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 4a70fcfe4f LR-PRE-IMPORT: bound plist nesting (a hostile look can't overflow the stack)
Nested <dict>/<array> recursed without limit; a crafted .lmp with deep
nesting would crash the app on import. Reading stops with an error past
64 levels. Test: hostile_nesting_is_an_error_not_a_crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 ae29c0569f LR-EDIT-CURVE: reset a curve channel or the whole tone curve
New engine command curve.reset {channel?: all|point|parametric|master|red|
green|blue} (default all = the whole Tone Curve), one undo step, usable from
CLI / MCP / control channel. In the editor: double-click a channel selector
resets that channel, a Reset button under the graph resets every curve
(point curves and parametric), and right-clicking the graph offers
Reset <channel> / Reset All Curves.

Fixes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 6c3c155c99 LR-PRE-IMPORT: import Luminar looks (.lmp, .mplumpack)
An .lmp look is an XML property list of adjustment layers with named
sliders (newer looks: a Name.lmp/Contents/preset.lmp bundle); an
.mplumpack is a zip of looks plus PresetsInfo.plist (GroupName).
preset.import, the file dialog and drag & drop now accept both. Sliders
with a clear counterpart (exposure, contrast, highlights/shadows,
whites/blacks, WB shift, saturation/vibrance, clarity/structure,
dehaze, HSL, point curves, vignette, grain) map onto develop settings;
the rest is reported in `unmapped`. Looks are named after the look and
grouped by the pack. Tests use synthetic looks and packs only.

Fixes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon ThomasandClaude Opus 5.5 e1acdb876d LR-EDIT-CURVE: drag point-curve points in both axes
The editor stored the dragged point index as Option<usize> but read it back
as usize, so the index was lost after the first frame: pressing on a point
did nothing and only click-to-add (overwrite) worked. Points now drag in X
and Y (X clamped between the neighbours), picked at the press origin, with a
grab cursor, a highlighted point and an input/output readout; one undo step
per drag via develop.beginInteraction / endInteraction.

Fixes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:59:25 -04:00
Brandon Thomas 6eb5335a27 Merge pull request #38 from storytold/fix/issue-11-scroll
Grid and filmstrip keep your scroll position; mouse wheel scrolls the filmstrip (fixes #11)
2026-10-04 02:58:28 -04:00
Brandon ThomasandClaude Opus 5.5 103f53724f LR-FILMSTRIP: grid and filmstrip keep the user's scroll position; wheel scrolls the filmstrip
Fixes #11.

Root cause: the grid and the filmstrip remembered the last active photo in egui temp data
with mismatched types (inserted `Option<PhotoId>`, read back as `PhotoId` because of the
`let last: Option<PhotoId> = get_temp(..)` annotation), so the read always missed, "the
active photo changed" was true on every frame and both called `scroll_to_rect` on the
active photo every frame. Any wheel or scroll-bar scroll was undone as soon as the user
stopped (the animated scroll back read as a delayed snap).

- `grid::follow_active` (shared by grid and filmstrip): scroll to the active photo only
  when it changed since the strip was last drawn, or when the strip was not drawn on the
  previous pass (view switch). Time passing, finished renders, edits and a released scroll
  bar no longer move either strip.
- Filmstrip: a vertical mouse wheel scrolls it sideways (horizontal trackpad scrolling
  still works); it recentres on a new active photo only when that is off screen, so a
  click on a visible thumbnail leaves it alone.
- `ui.inspect` reports `scroll: {grid, filmstrip}`; headless tests in `tests_scroll.rs`.
- docs/parity.md (LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP), docs/control-protocol.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:56:32 -04:00
Brandon Thomas 296c67bf9b Merge pull request #17 from storytold/fix/issue-8-export-dialog-width
Export dialog no longer widens every frame (fixes #8)
2026-10-04 02:47:12 -04:00
Brandon Thomas 0a1e38db70 Merge pull request #16 from storytold/fix/issue-7-windows-console
Windows: the desktop app opens no console window (fixes #7)
2026-10-04 02:32:06 -04:00
Brandon ThomasandClaude Opus 5.5 b8c2d16a06 LR-EXP-DIALOG: export dialog no longer widens every frame
Rows with a trailing button (Folder / Choose…, watermark Graphic /
Choose…, Save preset / Save) sized their text field as the available
width minus a guessed button width. When the real button was wider
(e.g. Linux fonts, a folder picker present), the auto-sized dialog grew
a little every frame. These rows now lay out right to left: the button
first, then a field taking exactly the width that's left.

Test: export_dialog_keeps_its_width (grew 492 → 693 px in 60 frames
before; stable now).

Fixes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:34 -04:00
Brandon ThomasandClaude Opus 5.5 3f8dbb41c0 Windows: the desktop app opens no console window
Release builds of lightcraft.exe are GUI-subsystem programs
(windows_subsystem = "windows"), so launching the app no longer opens
a terminal. Debug builds keep the console; lightcraft-cli.exe stays a
console program for terminal use (--help / --version / scripting).
Checked with cargo check --release --target x86_64-pc-windows-msvc.

Fixes #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:34 -04:00
Brandon Thomas f5a4bb609d Merge pull request #1 from leipsfur/fix/arw-lossless-tiles
M3.7: Sony lossless-compressed ARW tiles (2×2 CFA cells per LJ92 sample)
2026-10-04 01:39:29 -04:00
Brandon Thomas c0794b95e8 Merge remote-tracking branch 'origin/main' into review/pr-1 2026-10-04 01:36:45 -04:00
Brandon Thomas c5c08c6af9 Merge pull request #15 from storybored379/pr/library-state-album-source
Library: an album source serializes (library.state no longer panics)
2026-10-04 01:26:26 -04:00
Brandon ThomasandClaude Opus 5.5 d6eac6685c M3.7: header probe agrees with decode on subsampled lossless JPEG; lossless ARW corpus samples
- tiffraw: Mode::Header parses the first LJ92 frame header (Compression 7),
  so probe_info reports Unsupported for subsampled frames (Sony lossless
  M/S) as the full decode does, instead of claiming the file decodes.
- corpus: CC0 ILCE-7M4 lossless L/M/S samples from raw.pixls.us in
  `cargo xtask corpus`; M/S listed as known unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:21:33 -04:00
Brandon Thomas 2956f97352 Merge branch 'pr-1' into review/pr-1 2026-10-04 01:13:20 -04:00
storybored379andClaude Opus 5.5 6a83818192 Library: an album source serializes (library.state no longer panics)
LibrarySource was an internally tagged enum (tag = "kind") whose Album
variant holds an AlbumId, and serde cannot serialize an integer newtype
in an internally tagged enum. With an album open:

- library.state unwrapped the error and panicked; over the control
  channel this closed the desktop app.
- view.json (source, filter, sort, selection) was written empty
  (to_vec_pretty(...).unwrap_or_default()), so the view was lost on the
  next launch.

Adjacent tagging (content = "id") keeps the unit variants as
{"kind": "all"} and writes the album as {"kind": "album", "id": 8}: the
same shape library.source takes, so the reported source can be passed
back as is. Existing view.json files read unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 17:38:43 -07:00
Brandon ThomasandClaude Opus 5.5 8146a11cec LR-IMP-ADD-DIALOG: folder scan follow-ups (Cancel, busy guard, inspect)
- Cancel drops the scan at once (the worker stops at its next file) and
  has a widget id (button:scanCancel) for agents.
- Add from Device while a scan runs no longer flips the running scan to
  copy-into-library.
- ui.inspect reports the scan's {done, total}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:07:44 -04:00
Brandon Thomas fc66026ef0 Merge pull request #5 from GlemNL/feat/click-zoom-ratio
Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
2026-10-03 19:07:36 -04:00
Brandon ThomasandClaude Opus 5.5 a4b168550d Loupe click-zoom: 1:1 default, Z/Space use the ratio, render once at the final size
Review follow-ups on the contributor's click-zoom:
- Ratios 1:1 (default), 2:1, 3:1, 4:1, 8:1; set through the new
  view.clickZoom {ratio} command (bottom-bar dropdown, CLI, MCP).
- Z / Space zoom to the same ratio as a click, with the same ease.
- The loupe requests its render at the final size; the animation only
  changes how it is drawn (no render per animation frame). The test
  checks every animation frame asks for one render.
- docs/parity.md: LR-VIEW-ZOOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:07:18 -04:00
Brandon Thomas b49e32c094 Merge remote-tracking branch 'origin/main' into pr5-work 2026-10-03 18:36:14 -04:00
Brandon Thomas 542343099e Merge pull request #4 from GlemNL/feat/crop-double-click-apply
Crop: double-click inside the crop box applies the crop
2026-10-03 18:35:46 -04:00
Brandon Thomas ad567f6632 Merge pull request #6 from GlemNL/feat/zoomed-scrub-cursor
Loupe: zoomed Grab cursor only over the photo
2026-10-03 18:35:27 -04:00
Brandon Thomas d473a4d9bf Merge pull request #2 from GlemNL/fix/nas-folder-import
Import: scan folders in the background (fixes Add Folder on network shares)
2026-10-03 18:15:21 -04:00
Clément LandClaude Sonnet 5.5 97ba282044 Import: update copy-import test for the background folder scan
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:25:05 +02:00
Clément LandClaude Sonnet 5.5 3e70c0edfe Loupe: zoomed Grab cursor only over the photo, so slider cursor wins in side panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:12:35 +02:00
Clément LandClaude Sonnet 5.5 7b8df44947 Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:04:58 +02:00
Clément LandClaude Sonnet 5.5 ca1f4dbd8a Crop: double-click inside the crop box applies the crop
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:58:04 +02:00
Clément LandClaude Sonnet 5.5 5828a7d2d3 Import: scan folders on a background thread with progress and Cancel
Add Folder read every file on the UI thread before opening the review; on a
NAS (~26k files) the window froze and was dropped. The scan now runs in a
worker (ScanInput/scan_with) and the review opens when it finishes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:43:02 +02:00
Brandon ThomasandClaude Opus 5.5 7bf24ff856 Release 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:55:38 -04:00
Brandon ThomasandClaude Opus 5.5 439cbd6ca0 LRC-DEV-SOFTPROOF, MENU-CTX-MASK: soft proofing with gamut warnings; mask component menu
Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack):
- pipeline Proof {space, destWarning, displayWarning}: gamut-map into the
  proof space, show it in the output space; red/blue gamut warnings
  (neutral clipping excluded). CPU only; the GPU path declines proofs.
- RenderJob::with_proof; paper-white surround and Proof Preview label;
  Soft Proofing strip under the histogram (profile, warnings, Create
  Proof Copy = a named virtual copy). view.softProof for control/MCP.
- photo.virtualCopy takes an optional name.

Mask components: MaskComponent.name; mask.component {component, action:
invert|duplicate|delete|rename|op}; right-click / … menu per component,
double-click rename, Intersect button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:01:01 -04:00
Brandon ThomasandClaude Opus 5.5 db8a574fea LRC-LIB-QUICKDEV, LRC-LIB-VISUALSEARCH, LRC-DEV-MATCHEXP: Quick Develop, Find Similar, Match Exposures
- develop.quickAdjust {control, delta}: adds a step to every target photo's own value (one undo
  step); the Edit panel shows Quick Develop rows (exposure ⅓ / 1 stop, contrast, highlights,
  shadows, whites, blacks, clarity, vibrance, temperature) in the grid with several selected.
- library.findSimilar {id?, similarity?}: look-alike photos by their culling signature (cached
  per content), most similar first, as a view filter (Filter.only); photo menu entry.
- develop.matchExposure: selected photos' Exposure set so shutter × ISO ÷ aperture² + slider
  matches the active photo; photos without exposure data are skipped.
- Impromptu slideshow row marked (View ▸ Slideshow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:40:34 -04:00
Brandon ThomasandClaude Opus 5.5 81d015b5f2 LRC-LIB-REFVIEW: Reference view (⇧R)
A reference photo (photo menu ▸ Set as Reference Photo, or the active photo) shown fixed on
the left beside the active photo on the right, which the Edit panel keeps working on — for
matching a look by eye. View ▸ Reference View (⇧R); reference pane menu ▸ Clear Reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:26:53 -04:00
Brandon ThomasandClaude Opus 5.5 5d864c0374 LR-MASK-FEATHER-EDGE: Refine Edges for masks
Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are
combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long
edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no
kernel for it yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:16:54 -04:00
Brandon ThomasandClaude Opus 5.5 8d40b3e372 LR-REM-DUST: Find Dust Spots
lightcraft-pipeline dust.rs: sensor-dust detection on a rendered image — pixels darker than
their blurred surroundings by more than 3× the local texture, grouped into small (0.15–2 % of
the long edge), roughly round blobs. spot.findDust {sensitivity?, add?} renders the uncropped
photo, detects and adds a heal spot on each (auto source; one undo step). Remove panel ▸
Find Dust Spots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:07:49 -04:00
Brandon ThomasandClaude Opus 5.5 26b00014a8 LR-LIB-CULL: assisted culling — focus scores, bursts, best-of-burst
lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:58:52 -04:00
Brandon ThomasandClaude Opus 5.5 1b4f623e55 LRC-LIB-FOLDERS: rename and move folders on disk
folder.rename {path, name} and folder.move {path, into}: the folder is renamed / moved on disk
in one step (its files and sidecars go along) and the photos inside are relinked (and a
browsed folder follows); like the file manager it is not an undo step. Refuses clashes, moves
into itself and the library's own folders. Local folder tree: Rename Folder…, Move Folder To…,
Show in Finder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:40:44 -04:00
Brandon ThomasandClaude Opus 5.5 df4c7d7c3a Perf: chroma-only colour ops skip the OkLCh round trip
When only vibrance / saturation (and colour grading) are active, chroma is scaled on OkLab
a/b directly — the same result without sin/cos (and atan2 unless vibrance needs the hue).
Bench (loaded machine): finish 1920×1280 + vibrance/saturation 71.8 → 47.7 ms wall on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:29:19 -04:00
Brandon ThomasandClaude Opus 5.5 4a490db261 LR-PROF-IMPORT: .cube 3D LUTs as creative profiles
lightcraft-pipeline gains lut.rs: a .cube parser (LUT_3D_SIZE, DOMAIN_MIN/MAX, TITLE), a
trilinear lookup and an in-process registry; the finishing stage applies a `lut:` profile to
the display-encoded colour, blended by the profile amount (0–200 %); LUT profiles render on the
CPU. profile.import {paths} reads files, folders and zips, copies each LUT into the library's
Profiles/ folder, lists them in prefs and registers them when the library opens; the profile
browser shows them under their folder / zip name; profile.deleteImported. File ▸ Import
Profiles & Presets…, the file picker and drag & drop take .cube files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:20:39 -04:00
Brandon ThomasandClaude Opus 5.5 81f77d74df LRC-LIB-KEYWORDS + LRC-LIB-VIEWS: keyword painter, square-grid captions
- Keyword painter (tool.keywordPainter {keyword}): Keywords panel ▸ Paint; clicking a photo in
  the grid toggles the keyword on it (no selection change), Esc / Stop ends.
- View ▸ Grid Info (view.gridInfo): the square grid's caption shows the file name, the
  exposure (1/250 · f/2.8 · ISO 100 · 18 mm) or the capture date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:41:10 -04:00
Brandon ThomasandClaude Opus 5.5 1aaafb6218 LR-EXP-DNGOPT: DNG export compression (lossless / ZIP / none)
ExportOptions.dng_compression (`dngCompression`: lossless (default) | deflate | uncompressed),
in the export dialog when the format is DNG. Corpus-gated test: all three decode, uncompressed
is the biggest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:27:45 -04:00
Brandon ThomasandClaude Opus 5.5 b3249bfb57 Perf: cache the photo grid's layout — 43 → 5 ms per frame at 100k photos
The justified layout (and the aspect ratio of every photo it needs) was rebuilt every frame.
It is now kept in LightcraftApp::caches, keyed by catalog revision, the photos in view, the
width, the thumbnail size, the mode and the date groups. Headless measurement (ignored test
grid_frame_100k, machine under load): 43.1 → 5.3 ms on the UI thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:21:26 -04:00
Brandon ThomasandClaude Opus 5.5 c24ed6e94a Perf: cache the left panel's counts / By Date tree and the filter bar's value lists
Both walked the whole library every frame (the filter bar even cloned every photo to list
cameras, lenses and keywords). They now come from LightcraftApp::caches, recomputed when the
catalog revision changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:11:08 -04:00
Brandon ThomasandClaude Opus 5.5 ec78cb8236 Perf: big libraries — keyword tree 5.6× faster, per-frame library queries cached
A 100k-photo scaling check (ignored test `scale_100k_library_queries`) showed the left
panel rebuilding the keyword tree every frame (323 ms), the grid recomputing date headers
(57 ms) and the Keywords panel its suggestions (44 ms) every frame.
- keyword_tree builds with hash maps instead of linear sibling scans: 323 → 57 ms at 100k.
- LightcraftApp::caches keeps the keyword tree, date runs and keyword suggestions per catalog
  revision (and inputs), so each is computed once per change instead of every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:05:38 -04:00
Brandon ThomasandClaude Opus 5.5 1fe5dea3ab LR-META-LOCATION: edit GPS by hand, Show on Map
photo.setMeta {gps: "lat, lon" | [lat, lon] | null} — decimal, or degrees / minutes / seconds
with N/S/E/W ("48°51'30"N 2°17'40"E"); applied to all target photos, saved in XMP / exports.
Info: the GPS line is an editable field, and Show on Map opens the place in OpenStreetMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:56:13 -04:00
Brandon ThomasandClaude Opus 5.5 6b6a925ac7 LR-BEHAV-WHATSNEW + MENU-HELP-FEEDBACK: Help ▸ What's New, Send Feedback
docs/whats-new.md (release highlights, built into the app) shown by Help ▸ What's New;
Help ▸ Send Feedback… opens a new GitHub issue; the Help menu lists What's New, Keyboard
Shortcuts and System Info together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:50:56 -04:00
Brandon ThomasandClaude Opus 5.5 9adfb61819 LR-BEHAV-ACCESS: screen-reader names for the custom widgets
AccessKit (already on in the desktop build) now sees what the painted widgets are: sliders
announce their control and value, text / icon buttons and dropdowns their label or tooltip and
state, section headers and flyouts open / closed, left-panel sources their name and count, grid
thumbnails file name, rating, flag and label. Test reads the AccessKit tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:45:25 -04:00
Brandon ThomasandClaude Opus 5.5 9adcfe1a2c LR-VIEW-SECONDWINDOW: Window ▸ Second Window (⌘F11)
The active photo, fitted, in its own native window (an egui immediate viewport) with its own
render slot sized for that window; closing the window turns it off. Without native windows
(web, headless) egui shows it as a floating panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:35:18 -04:00
Brandon ThomasandClaude Opus 5.5 59090387aa ROADMAP: evening parity estimate (75.6% by rows; ≈ 270–505 agent-hours / 65–125 h wall clock left)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:26:15 -04:00
Brandon ThomasandClaude Opus 5.5 544914bb66 LR-CLOUD-SMARTPREVIEW + LR-LIB-DUPLICATE: smart previews for offline originals, Duplicate
- Smart previews (smart.rs): a ~1 MB proxy per photo in the library's "Smart Previews" folder
  (the decoded preview-size source, scaled, sRGB-encoded JPEG behind a one-line header). When
  a photo's original is missing (an unplugged drive), its source loads from the proxy: the
  photo renders, edits and exports at proxy size. library.smartPreviews {discard?},
  photo.smartPreview status; File ▸ Previews ▸ Build / Discard Smart Previews; Info shows
  "Original offline · editing the smart preview".
- Photo ▸ Duplicate (photo.duplicate): a real "-copy" file with the same settings, metadata,
  rating, flag, label and albums; one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:35 -04:00
Brandon ThomasandClaude Opus 5.5 608fb6dc13 LR-IMP-AUTO: watched-folder auto import
library.autoImport {folder, copy, album} (in prefs) and library.autoImportScan: files in the
watched folder the library doesn't have are added once their size held between two scans
(still-copying files wait), in place or copied into Originals, into the named album; each file
is tried once; arrivals don't take the selection. The desktop app scans every 3 s with a toast;
Settings ▸ Import ▸ Auto Import picks the folder.
Fix: library.import into an album no longer fails when nothing was selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:15:34 -04:00
Brandon ThomasandClaude Opus 5.5 a98ea7858b LR-PRE-PREMIUM + KEYC-CATALOG: 41 built-in presets, F2 renames
- 23 more own-authored presets: Portrait (Moody Portrait, Golden Glow, Clean Studio),
  Landscape (Crisp Vista, Deep Sky, Lush Greens, Desert Warmth, Misty Morning), Urban,
  Food, Seasons, Vintage (Instant '70s, Cross Process, Bleach Bypass), Sepia B&W.
- F2 opens Rename Photos…; ⌘' / ⌘R / ⇧⌘E Classic keys verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:09:09 -04:00
Brandon ThomasandClaude Opus 5.5 27387af44c LR-EXP-PSD: pick up files saved by the external editor (photo.reload)
Op::SetContent records a photo's file facts (size, dimensions, content hash; undoable).
photo.reload re-probes the photos' files and, for those that changed on disk, updates them and
drops their cached sources; render keys now include the file's content key, so the grid and
loupe re-render. The app reloads this session's external-edit copies whenever its window
regains focus (with a toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:03:39 -04:00
Brandon ThomasandClaude Opus 5.5 9affd0e8d2 LR-EXP-PSD + KEY-PHOTOSHOP: Edit in External Editor (⇧⌘E)
photo.editExternal renders the active photo with its edits as a 16-bit TIFF "<name>-Edit.tif"
(Adobe RGB by default; ProPhoto / P3 / sRGB) next to the original, never overwriting, adds it
stacked on top of the original and selects it. Photo ▸ Edit in External Editor (⇧⌘E, as
observed; the export dialog stays on ⇧E) then opens it in the application set in Settings ▸
General ▸ External Editor, or the system default (desktop: open -a / start / xdg-open,
spawned, never waited for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:58:19 -04:00
Brandon ThomasandClaude Opus 5.5 0fafacfccd LR-VER-AUTO, LR-VIEW-SLIDESHOW, LRC-LIB-CONVERT and more: auto versions, slideshow, DNG
- Auto versions: leaving an edited photo keeps its settings as an Auto version (when they
  differ from its latest version; 20 kept); saved with the library, not an undo step.
- View ▸ Slideshow (⌥⌘↩, `interval`): the photos in view full screen, wrapping; Space
  pauses, ←/→ step, Esc ends.
- Photo ▸ Convert to DNG (photo.convertToDng): lossless DNG next to the raw with the settings
  embedded; the photo and its virtual copies are relinked (Relink gains an optional format),
  originals kept, undoable. Import: Copy as DNG (`dng`) for copy imports.
- A fine grid over the photo while a geometry slider is dragged.
- View ▸ Show Photo Counts; Help ▸ System Info… (app.systemInfo, JSON for agents).
- Native menus: modified Enter accelerators.
- Parity rows refreshed (target / quick collection, auto sync, location rules, bring to front).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:49:39 -04:00
Brandon ThomasandClaude Opus 5.5 1c4a00c35d LRC-LIB-FOLDERS: expandable folder tree in Local
Each place in Local (Pictures, Desktop, Downloads, Home, the browsed folder) has a disclosure
triangle listing its subfolders (hidden ones skipped; listed lazily, at most every 2 s), to any
depth; folders on the way to the one in view open by themselves; clicking a folder browses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:34:24 -04:00
Brandon ThomasandClaude Opus 5.5 b163d0e16e LRC-LIB-COLLECTIONS: Quick Collection and target album (B)
Albums gain a `quick` flag: the Quick Collection is created the first time B adds to it.
album.toggleTarget adds the selection to the target album (the Quick Collection unless
album.setTarget names another regular album) or removes it when all of it is already there;
album.clearQuick empties it. B in the photo / square grids does this (with a toast); in the
loupe and edit views B stays the masking brush. Album menu: Set as Target Album / Stop Using
as Target Album / Clear Quick Collection; the target is marked + in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:28:44 -04:00
Brandon ThomasandClaude Opus 5.5 def15b0346 LR-META-EXIF-FULL: All Metadata — every EXIF / GPS / XMP field of the file
lightcraft-meta gains tags.rs: every tag of IFD0 and the EXIF, GPS and interoperability IFDs
as (group, name, value) rows, named per the public TIFF / EXIF / DNG specifications, with
common values spelled out (exposure as 1/250 s, f/2.8, 35 mm, orientation, metering, flash,
compression…) and blobs summarised. photo.allMetadata adds the XMP fields (sidecar, else the
file's packet). Info ▸ All Metadata… opens a searchable dialog grouped by TIFF / EXIF / GPS /
XMP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:20:43 -04:00
Brandon ThomasandClaude Opus 5.5 af30fee4d6 KEY-MASKCOLOR + MENU-FILE-CLOSE: ⇧O cycles the mask colour, native Close Window
- While masking, ⇧O cycles the overlay colour as observed in the reference (the mode stays;
  View ▸ Cycle Mask Overlay Mode cycles modes).
- macOS: File ends with the system's Close Window (⌘W). Hide / Hide Others / Show All,
  Minimize (⌘M) and Zoom were already the system's own items; parity rows verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:10:10 -04:00
Brandon ThomasandClaude Opus 5.5 f65c2310f2 LR-MASK-COLORRANGE: pick colour-range samples on the photo
The Color mask tile adds an empty colour range and arms the picker: click the photo to sample
(⇧-click adds, up to five); Pick in the Masking panel re-arms it. mask.sampleColor {x, y, add?}
samples via lightcraft_pipeline::color_range_sample, which reads the exposed scene colour at
that point in the same OkLab space the mask compares in (so a click selects what was clicked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:03:14 -04:00
Brandon ThomasandClaude Opus 5.5 140d7cbc67 LR-MASK-LUMRANGE: range bar, Smoothness and Show Luminance Map for luminance-range masks
The Masking panel lists each component with its controls: luminance ranges get a dark → light
bar with two handles (the nearer one follows the pointer; one undo step per drag), a Smoothness
slider (both falloffs) and Show Luminance Map (the photo in black & white with the selected
range tinted; turning it off restores the previous overlay); colour ranges get Refine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:51 -04:00
Brandon ThomasandClaude Opus 5.5 3b4308040f KEY-GUIDEDUPRIGHT + KEY-GRID: ⇧G starts Guided Upright, G toggles Photo / Square Grid
As observed in the reference: G switches between Photo Grid and Square Grid (from other
views: the photo grid); ⇧G opens Crop & Geometry with Guided Upright on and the guide tool
active (tool.guidedUpright). Square Grid no longer takes ⇧G.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:47:55 -04:00
Brandon ThomasandClaude Opus 5.5 6a0d5346e8 LRC-LIB-PREVIEWS: build standard-sized / 1:1 previews in the background
library.buildPreviews {size: standard (Settings → Performance preview size) | full, ids?,
wait?} renders each photo's grid thumbnail and loupe view into the memory + disk preview cache
(selected photos, else all in view) on a background thread; library.previewProgress /
cancelPreviews. File ▸ Previews: Build Standard-Sized / 1:1, Stop, Discard Preview Cache;
toasts when a build starts and finishes. Also silences the wasm-only unused `threads` warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:42:33 -04:00
Brandon ThomasandClaude Opus 5.5 b8b2379215 LRC-LIB-FILTERBAR: colour-label multi-select (any of the chosen labels)
Filter.labels: a photo matches when its label is any of them. The filter bar's label toggles
add / remove labels from the set instead of replacing the single label; the summary reads
"label red or yellow". The filter already persists across albums (locked, as in the desktop
app).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:33:43 -04:00
Brandon ThomasandClaude Opus 5.5 a3d487d22d LRC-LIB-SMARTCOLL: smart-album rule editor — all / any / none, nested groups, 26 fields
lightcraft-catalog gains rules.rs: RuleSet {match: all|any|none, rules: [{field, op, value} |
{group}]} with text, keyword, number, date (is / before / after / between / in the last N
hours…years) and choice operators over 26 fields (rating, flag, label, type, edits, keywords,
any text, filename, title, camera, lens, location, dates, ISO, aperture, focal length,
megapixels, GPS, virtual copy, album…). Filter.ruleSet: smart albums and library.filter use it;
"in the last" counts from the session clock. album.ruleFields lists fields and operators for
agents; rules are validated.
UI: File ▸ New Smart Album… / album context menu ▸ Edit Smart Album… open the rule editor
(field / operator / value rows with per-kind editors, + / − / ⌥+ for groups, live match count).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:27:56 -04:00
Brandon ThomasandClaude Opus 5.5 b8344aa5eb LRC-LIB-KEYWORDS + KEY-SECTIONS: keyword sets with ⌥1–⌥9, ⌘1–⌘5 edit sections
- Keyword sets: Recent Keywords (the nine most recently added, newest first) plus user sets in
  prefs (keyword.sets, useSet, saveSet, deleteSet). keyword.toggleFromSet {index}: adds keyword N
  to the selection, or removes it when every photo has it; applying from Recent Keywords keeps
  the numbering. Keywords panel: set picker (save current as a set, delete) and a 3×3 grid of
  toggles; ⌥1–⌥9.
- ⌘1–⌘5 expand/collapse Light, Color, Effects, Detail, Optics as observed in the reference;
  Zoom 100% moves to ⌥⌘0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:17:15 -04:00
Brandon ThomasandClaude Opus 5.5 86c4048ad6 LRC-LIB-IMPORT: copy to any folder, organise by day / month / flat, rename on import, metadata preset
library.import gains destination (copies go there instead of the library's Originals/; no
library needed), organize (date | month | flat), rename (file-name template with the rename
tokens, numbered from renameStart; copies are catalogued under their new names) and
metadataPreset. The Add Photos dialog: Copy → Copy to (Choose… / Library), Folders, Rename with
a live example, Metadata preset; batches keep the numbering going.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:08:33 -04:00
Brandon ThomasandClaude Opus 5.5 8a3489da6b LR-IMP-FORMATS: decode lossy (DCT JPEG) DNG — Smart Previews and lossy DNG exports
DNG compression 34892: each tile / strip is a baseline JPEG of 8-bit samples (decoded with
zune-jpeg, pure Rust), then the usual linearization table, levels and crop apply. Per the DNG
specification. Lossy DNGs (Smart Previews, "lossy compression" DNG exports, many phone DNGs)
now open as raw files instead of falling back to their embedded preview. Unit test with
synthetic JPEG tiles (edge tile cut short); the corpus sample decodes (5760×3840 in 144 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:51:15 -04:00
Brandon ThomasandClaude Opus 5.5 f81c81b7a0 LRC-DEV-AUTOSYNC + LRC-LIB-LABELS + KEY-STRAIGHTEN: auto sync, label sets, ⌘-drag straighten
- develop.autoSync (Edit ▸ Auto Sync, ⌥⇧⌘A): an edit of the active photo carries only the
  settings that changed to the other selected photos, in one undo step; slider drags sync on
  release; spots / red eye and history restores stay per photo. Edit ▸ Sync Settings in the
  menu; Edit panel banner while it's on with several photos selected.
- Colour-label sets: built-in Colors / Review sets plus user sets in prefs (label.sets,
  applySet, saveSet, deleteSet); listed in Photo ▸ Set Color Label; the names dialog can start
  from a set and save one. XMP sidecars and exported originals write the label's name, and
  reading resolves custom names back to their colour.
- Crop: ⌘-drag draws a straighten line and keeps the crop tool active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:41:05 -04:00
Brandon ThomasandClaude Opus 5.5 b7d497d049 LR-IMP-PROFILES: carry masks over from presets, .lrtemplate and XMP sidecars
Local corrections stored as crs: structures (MaskGroupBasedCorrections and the older
gradient / circular gradient / paint containers) become our masks: linear and radial
gradients, brushes (dab runs → strokes), Subject / Sky, luminance and depth ranges, with
add / subtract / intersect and invert, and all local sliders. lightcraft-meta's XmpData gains
`values`, a structured view of every property (arrays of structs survive); .lrtemplate Lua
tables convert into the same shape.
Applying a preset adds its masks to the photo's own (once; Amount scales them); sidecar masks
replace them and radial masks are fitted to the photo's aspect. Unsupported components
(colour range, other AI selections) are reported as "Mask: <kind>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:31:40 -04:00
Brandon ThomasandClaude Opus 5.5 1dc501bc3a LR-IMP-PROFILES: import presets from .lrtemplate, DNG presets, zip bundles and folders
preset.import (File ▸ Import Presets…, Presets panel ⋯, drag & drop, CLI, MCP) now reads
everything people actually download or carry over from other editors:
- classic .lrtemplate files (Lua table literal parser; value.settings uses crs: field names),
  including old process-version fields (Exposure, Contrast, FillLight, HighlightRecovery,
  Shadows, Brightness, Clarity, ToneCurve) approximated with today's sliders;
- photos carrying their edits in XMP ("DNG presets" from mobile apps, edited JPEG/TIFF): the
  look only, without the photo's crop, geometry or custom white balance;
- .zip bundles (stored + deflate) of any of these, skipping __MACOSX / hidden files;
- folders: the pack's folder name becomes the group, skipping app/format container folders
  ("Lightroom Classic", "XMP", "Mobile (DNG)"…).
Each imported preset reports the settings that had no counterpart (`unmapped`: profiles,
looks, local masks); the app's toast names them. dryRun previews, group overrides.
Dropped .lcpreset/.lrtemplate/.xmp/.zip files import as presets instead of photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:14:51 -04:00
Brandon ThomasandClaude Opus 5.5 5d6b889e45 docs/mcp: quote bracketed run values for the shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:46:05 -04:00
Brandon ThomasandClaude Opus 5.5 9e8d75300f LR-EDIT-COLOR-MIXER-BW + LR-FILT-TYPE: Auto B&W mix, HDR / panorama type filters
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
  the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
  colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
  by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
  Panoramas, HDR Panoramas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:45:30 -04:00
Brandon ThomasandClaude Opus 5.5 478f328cc0 LR-FILT-SAVED + KEY-COMMIT: filter presets, Return commits a tool
- filter.savePreset / applyPreset / presets / deletePreset: the filter bar's settings saved
  under a name with the library; filter bar → Presets (apply, save current, delete).
- Return (tool.done) closes Crop / Remove / Red Eye / Masking back to the Edit panel.
- Parity: zoom while cropping verified (works), hover previews now include versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:41:04 -04:00
Brandon ThomasandClaude Opus 5.5 deff6b6d06 LR-EXP-WATERMARK + LR-LIB-RECENT-ADDED: graphic watermarks, Recently Added by import
- Export watermark can be a graphic (watermark {image, imageWidth}): a logo with transparency,
  scaled premultiplied to a share of the photo's width, converted into the output colour
  space, placed with the same anchor / inset / opacity as text; decoded once per file.
  Export dialog: Text / Graphic, Choose…, width; size and shadow apply to text only.
- Recently Added: everything imported in the 30 days up to the latest import, newest import
  first, grouped by import day (was the latest import day only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:37:26 -04:00
Brandon ThomasandClaude Opus 5.5 b27c3bc20f LRC-LIB-METADATA + LRC-LIB-STACKS: metadata presets, split stack, move within a stack
- metadata.savePreset / applyPreset / presets / deletePreset: named sets of copyright,
  creator, title, caption, place and keywords (text replaces, keywords add; one undo step),
  saved with the library; Photo → Metadata Preset (apply, Save Metadata Preset… from the active
  photo's copyright, creator and place); Settings → Import applies one to every import
  (library.preferences import.metadataPreset).
- stack.split (this photo and those after it become their own stack; single leftovers are
  unstacked), stack.moveUp / moveDown; in the Stack menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:33:11 -04:00
Brandon ThomasandClaude Opus 5.5 b2c8d9611f KEY-SLIDER-NUDGE + KEY-FLAGADVANCE: slider nudges, ⇧Z pick and advance, Missing Photos source
- ↑/↓ over any slider nudge it by ≈ 1/200 of its range (exposure 0.05, most sliders 1,
  temperature 50 K; ⇧ ×5), one undo step each; the key doesn't also move the selection.
- ⇧Z picks and moves to the next photo (with ⇧X / ⇧U).
- library.source {kind: missing}: photos whose file can't be found; a "Missing Photos" row
  appears in the left panel when there are any (checked every 5 s).
- Loupe context menu: Zoom submenu. Parity: stale rows fixed (compare/survey, per-camera
  defaults, paste from previous, capture-time edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:27:35 -04:00
Christian BretzandClaude Opus 5.5 5d143cf108 M3.7: Sony lossless-compressed ARW tiles (2×2 CFA cells per LJ92 sample)
Sony's lossless-compressed raw (Compression 7, e.g. ILCE-7M4) stores
512×512 tiles as LJ92 frames of 256×256 with four components: each
frame sample is one 2×2 CFA cell (top-left, top-right, bottom-left,
bottom-right). The generic TIFF path reads a frame as a row-major sample
stream (DNG's convention), which interleaved each tile's left and right
halves row by row: vertical bands, scrambled CFA, purple cast.

arw.rs now detects the layout from the first tile's frame header and
places the cells itself; other lossless-JPEG layouts keep the generic
path. Component order verified on a real ILCE-7M4 file (green
differences to the diagonal neighbour cell match those within a cell).
RENDER_CACHE_VERSION bumped so cached thumbnails of affected files are
re-rendered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCLtGBctp1CxX2MdCuKCrm
2026-10-02 17:51:58 +02:00
319 changed files with 50571 additions and 2695 deletions

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# Optional font build input, so the Japanese font tests run (they skip without it).
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+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"
+40 -2
View File
@@ -70,6 +70,12 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -87,6 +93,8 @@ jobs:
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -112,11 +120,20 @@ 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 }}
steps:
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -133,6 +150,8 @@ jobs:
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -167,6 +186,12 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
@@ -184,6 +209,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -212,6 +240,12 @@ jobs:
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
@@ -221,13 +255,17 @@ 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 != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
+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'
+5
View File
@@ -1,8 +1,13 @@
*~
plan/
/target
/target-*/
/dist
/corpus
/log
.DS_Store
.claude/
/craft-fonts
# model weights are never committed (SAM 3: docs/ai-masks.md)
*.safetensors
*.safetensors.part
+49 -3
View File
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
## Non-negotiables
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
- **Pure Rust** in the product. No C/C++ dependencies.
@@ -29,9 +62,21 @@ and often so a crash loses minutes, not hours.
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese fonts come from craft-fonts (below).
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
@@ -57,6 +102,7 @@ and often so a crash loses minutes, not hours.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## Testing & performance (do this often)
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
+49 -3
View File
@@ -5,8 +5,13 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -17,6 +22,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
and often so a crash loses minutes, not hours.
## Never crash (outranks feature work)
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
of it. Full standard: `../craftrules/standards/never-crash.md`
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
integration tests, examples and benches. New crates start with the attribute.
## Non-negotiables
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
- **Pure Rust** in the product. No C/C++ dependencies.
@@ -29,9 +62,21 @@ and often so a crash loses minutes, not hours.
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese fonts come from craft-fonts (below).
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
@@ -57,6 +102,7 @@ and often so a crash loses minutes, not hours.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
- Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## Testing & performance (do this often)
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
Generated
+1487 -29
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
# internal
lightcraft-geom = { path = "crates/geom" }
lightcraft-sysmem = { path = "crates/sysmem" }
lightcraft-color = { path = "crates/color" }
lightcraft-raster = { path = "crates/raster" }
lightcraft-tiff = { path = "crates/tiff" }
@@ -28,6 +29,7 @@ lightcraft-merge = { path = "crates/merge" }
lightcraft-engine = { path = "crates/engine" }
lightcraft-ui-egui = { path = "crates/ui-egui" }
lightcraft-mcp = { path = "crates/mcp" }
lightcraft-segment = { path = "crates/segment" }
# foundation
thiserror = "2"
@@ -61,6 +63,10 @@ jxl-oxide = { version = "0.12", default-features = false }
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
# Oniguruma C library into candle-core, and the product is pure Rust.
candle-core = { version = "=0.9.2", default-features = false }
candle-nn = { version = "=0.9.2", default-features = false }
wgpu = { version = "30", default-features = false, features = ["std", "parking_lot", "metal", "vulkan", "dx12", "wgsl"] }
pollster = "1"
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+29 -7
View File
@@ -1,12 +1,34 @@
LightCraft
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
This product bundles third-party material under the following licences:
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Third-party material
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in assets/ATTRIBUTION.md.
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
The port was modified by the LightCraft contributors (2026): translated from Python/PyTorch to
Rust on candle and restructured; each ported file says so. Because of this, crates/segment is
licensed under the Apache License 2.0 only (LICENSE-APACHE), not under the MIT option.
The SAM 3 weights are not part of LightCraft and are never distributed with it. A user downloads
the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), under Meta's SAM
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
licence. See docs/ai-masks.md.
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
licence as OFL-<family>.txt.
+54 -10
View File
@@ -280,9 +280,18 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft is young and moving fast; see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row
Lightroom parity tracker (every feature, menu item and shortcut with its status, evidence and the top gaps) is
[docs/parity.md](docs/parity.md).
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
[docs/parity.md](docs/parity.md).
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
|---|---|
@@ -292,17 +301,20 @@ Lightroom parity tracker (every feature, menu item and shortcut with its status,
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, ARW, NEF (uncompressed), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 decode 🚧 |
| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ |
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -322,6 +334,19 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
```
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
@@ -344,9 +369,13 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
work from public specs and black-box observation.
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
(one task id per commit).
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
@@ -396,12 +425,23 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
## License and credits
LightCraft is licensed under **MIT OR Apache-2.0**.
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
@@ -409,3 +449,7 @@ Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTR
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
+123 -33
View File
@@ -4,6 +4,71 @@ Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
## Where we stand
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
**quality of results and camera coverage**, not missing buttons.
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
### By dimension
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **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. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). 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 measured camera calibration database**: ARW and NEF have 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; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; 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 fidelity and coverage (ARW and NEF preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
## Where we're going
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. Sony ARW and Nikon NEF have the file-local fit (matrix + tone
curve from their own JPEG); generalise it to the other makes' raws (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.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
checked for plausible colour; fix per-model bugs (#85).
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise; then pure-Rust inference. Unblocks M12 and Enhance. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People and Enhance are still open.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
## Milestones
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
| # | Milestone | Scope (summary) | Estimate (h) | Status |
@@ -11,7 +76,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed, embedded previews ✅; NEF Huffman ⬜) |
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
@@ -19,60 +84,66 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| 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 ✅; CR3, compressed NEF/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 and NEF 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 ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (2026-10-02)
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
| Scope | Weighted completion | Rows |
|---|---:|---:|
| P0 (core) | **98.5%** | 198 |
| P1 (important parity) | **79.9%** | 144 |
| P2 (later / AI / niche, incl. Classic modules) | **11.7%** | 158 |
| **All in-scope rows** | **65.7%** | 500 |
| Scope | Weighted completion | Rows | 2026-10-02 |
|---|---:|---:|---:|
| P0 (core) | **98.5%** | 200 | 98.7% |
| P1 (important parity) | **95.9%** | 147 | 94.4% |
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
camera coverage, render fidelity), which is why P0 dipped slightly.
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
UI/feature work in ≈ 5 h on 2026-10-01, and four to six parallel agents built M0–M13 in ≈ 25 active hours):
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (version hover previews, filter presets, quick develop, missing-file relink, metadata presets, keyword sets…) | ≈ 30 | 20–35 | low |
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
| Classic modules: Map, Book, Slideshow, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: watched folders, Enhance dialog, external-editor round trip, export to Photos, help / what's new, accessibility, localisation | ≈ 25 | 20–35 | low |
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
| **Total remaining** | | **≈ 295–540** | |
| **Total remaining** | | **≈ 270–505** | |
Spent so far ≈ 90–130 agent-hours, so **by effort the project is ≈ 20–30% of the way to complete Lightroom + Classic
parity**, and ≈ 40–55% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 135–235 h).
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
most of it the raw codecs, lens data, video and HDR).
**Wall clock:** ≈ 300–540 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 75–130 h of continuous work**. The AI package and the raw codecs carry most of the
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
## Totals
Remaining from 2026-10-02 (see *Parity estimate* above for the breakdown):
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|---|---:|---:|
| Cloud-Lightroom parity without AI or Classic modules | ≈ 135–235 h | ≈ 35–60 h |
| Full parity incl. AI, video and the Classic modules | ≈ 295–540 h | ≈ 75–130 h |
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
@@ -83,30 +154,32 @@ The milestone estimates in the table above were made before work started and are
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
then our own calibration; long-tail camera/lens coverage grows over time.
- **Legal decisions pending:** whether GPL-licensed *prose* format descriptions (e.g. the public CR3 write-up) may be read
by a designated engineer to produce an internal spec; freedom-to-operate review for local Laplacian filters, PatchMatch and
HEVC (HEIC).
- **Look parity** with Adobe's default rendering is subjective tuning against our own reference targets.
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
turns it into measured comparisons against local-only Lightroom references.
## Raw format coverage and known gaps
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
it can't decode yet.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon Huffman NEF** (lossless / lossy). Black-box findings so far: maker note `0x0096` holds per-parity predictor
seeds (2048 for 14-bit lossless); ≈ 9 bits per pixel; the category-0 code word is a 6-bit rotation of `011111`. The
Huffman tables are not stored in the files, and an automatic table search (beam search over prefix codes scored by
smoothness and bit rate, validated on Pentax files whose tables *are* stored) did not converge.
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
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.
@@ -116,3 +189,20 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
+6 -26
View File
@@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}
+187 -52
View File
@@ -9,7 +9,10 @@
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
//! ```
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
@@ -82,6 +85,12 @@ USAGE:
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
lightcraft-cli commands [--json] list every command id with its parameters
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
lightcraft-cli --version | --help
";
@@ -89,6 +98,17 @@ USAGE:
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
/// that one instead (issue #99).
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
match e {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e => format!("{dir}: {e}"),
}
}
fn main() -> ExitCode {
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
@@ -112,6 +132,7 @@ fn main() -> ExitCode {
Some("merge") => merge(&args[1..]),
Some("synth-merge") => synth_merge(&args[1..]),
Some("controls") => controls(&args[1..]),
Some("calibrate") => calibrate(&args[1..]),
Some("--version" | "-V" | "version") => {
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -131,6 +152,114 @@ fn main() -> ExitCode {
}
}
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
}
return;
}
let Ok(entries) = std::fs::read_dir(path) else { return };
if depth > 32 {
return;
}
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(['.', '@']) {
continue;
}
raw_files(&entry.path(), out, depth + 1);
}
}
fn calibrate(args: &[String]) -> Result<(), String> {
let mut max = 300usize;
let mut out: Option<std::path::PathBuf> = None;
let mut inputs = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--max" => max = take_value(args, &mut i, "--max")?.parse().map_err(|_| "--max needs a number".to_string())?,
"--out" => out = Some(take_value(args, &mut i, "--out")?.into()),
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
f => inputs.push(f.to_string()),
}
i += 1;
}
if inputs.is_empty() {
return Err("calibrate needs folders or raw files".into());
}
let mut files = Vec::new();
for input in &inputs {
raw_files(Path::new(input), &mut files, 0);
}
files.sort();
files.dedup();
let found = files.len();
if max > 0 && files.len() > max {
// spread over all folders (dates, scenes) rather than the first N
files = (0..max).filter_map(|k| files.get(k * found / max).cloned()).collect();
}
eprintln!("calibrate: {} of {found} raw files", files.len());
let dir = out.or_else(lightcraft_engine::camera_profiles::dir).ok_or("no profiles folder: pass --out DIR")?;
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
let next = std::sync::atomic::AtomicUsize::new(0);
let done = std::sync::atomic::AtomicUsize::new(0);
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
match result {
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
}
}
pool
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
});
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
for p in pools {
pool.merge(p);
}
let mut written = 0;
for result in pool.fit(5) {
match result {
Ok(profile) => {
let path = lightcraft_engine::camera_profiles::save(&profile, &dir)?;
println!(
"{}: {} photos, {} colour pairs, hue/saturation table {} → {}",
profile.model,
profile.files,
profile.samples,
profile.hue_sat.is_some(),
path.display()
);
written += 1;
}
Err(e) => eprintln!("calibrate: {e}"),
}
}
for (model, n) in pool.files() {
if n < 5 {
eprintln!("calibrate: {model}: only {n} usable photo(s), at least 5 needed");
}
}
if written == 0 {
return Err("no profile written".into());
}
Ok(())
}
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut demo = false;
@@ -180,7 +309,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
h
}
@@ -283,14 +412,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
let mut written = Vec::new();
match kind {
"hdr" => {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
std::fs::write(&p, b).map_err(|e| e.to_string())?;
written.push(p);
}
}
"panorama" | "pano" => {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
.map_err(|e| e.to_string())?
.into_iter()
.enumerate()
{
let p = Path::new(&dir).join(format!("view-{k}.png"));
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
@@ -399,39 +532,39 @@ fn run(args: &[String]) -> Result<(), String> {
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
let mut backend: Box<dyn Backend> = match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
)
}
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
let mut backend: Box<dyn Backend> =
match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(Remote::connect(&addr).map_err(|e| {
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
})?)
}
Box::new(h)
}
};
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
}
Box::new(h)
}
};
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
@@ -457,6 +590,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
@@ -527,11 +666,17 @@ fn render(args: &[String]) -> Result<(), String> {
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?;
for (ext, bytes) in &e.sidecars {
let sc = Path::new(&output).with_extension(ext);
std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?;
eprintln!("lightcraft-cli: wrote {}", sc.display());
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
let guard = s.original_guard();
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
}
lightcraft_engine::export::write_file(&output, &e.bytes)?;
for (sc, bytes) in &sidecars {
lightcraft_engine::export::write_file(sc, bytes)?;
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
Ok(())
@@ -574,7 +719,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs();
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs(),
@@ -587,18 +732,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
}
let services = lightcraft_ui_egui::Services {
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
})),
write: Some(Box::new(|p: &str, b: &[u8]| {
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
})),
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
+92
View File
@@ -86,6 +86,24 @@ fn render_subcommand() {
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
}
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
#[test]
fn render_refuses_to_overwrite_its_input() {
let input = tmp("self-in.png");
gradient_png(&input);
let before = std::fs::read(&input).unwrap();
let dir = input.parent().unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
let other_spelling = dir.join("sub/../self-in.png");
for out in [&input, &other_spelling] {
let o =
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
}
assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched");
}
#[test]
fn render_export_options() {
let input = tmp("o-in.png");
@@ -165,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 {
@@ -242,3 +296,41 @@ fn devices_are_listed_and_imported_from() {
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
}
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
/// with who has it and how to drive the running app instead; free again once the first exits.
#[test]
fn a_library_open_in_another_process_is_refused() {
let lib = tmp("locked-lib");
let _ = std::fs::remove_dir_all(&lib);
let lib_s = lib.to_str().unwrap();
let mut holder =
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
let mut err = BufReader::new(holder.stderr.take().unwrap());
let mut line = String::new();
while !line.contains("opened library") {
line.clear();
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
}
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
assert!(!ok);
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
assert!(stderr.contains("mcp --connect"), "{stderr}");
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
assert!(holder.wait().unwrap().success());
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
assert!(ok, "{stderr}");
assert_eq!(lines[0]["ok"], true);
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
#[test]
fn connect_failure_hint_names_the_attempted_port() {
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
assert!(!err.contains("--control 7980"), "{err}");
}
+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();
+4
View File
@@ -14,7 +14,9 @@
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
//! persistence and `?workers=0` renders on the main thread.
#![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;
@@ -23,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();
}
+175 -35
View File
@@ -15,37 +15,53 @@ 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;
fn window() -> web_sys::Window {
web_sys::window().expect("no window")
/// The page's window (`None` only outside a browser page, e.g. in a worker).
fn window() -> Option<web_sys::Window> {
web_sys::window()
}
/// Milliseconds since navigation start.
fn perf_now() -> f64 {
window().performance().map(|p| p.now()).unwrap_or(0.0)
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
}
fn set_status(text: &str) {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
el.set_text_content(Some(text));
}
}
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`).
@@ -59,7 +75,7 @@ struct Options {
impl Options {
fn from_url() -> Options {
let q = window().location().search().unwrap_or_default();
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let params: Vec<(String, String)> = q
.trim_start_matches('?')
.split('&')
@@ -85,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();
@@ -102,7 +119,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
/// Show the browser's open dialog; picked files are imported asynchronously.
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
let Some(doc) = window().document() else { return };
let Some(doc) = window().and_then(|w| w.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_multiple(true);
@@ -131,7 +148,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(crate::store::mime_for(name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
let doc = window().document().ok_or("no document")?;
let doc = window().and_then(|w| w.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);
@@ -140,20 +157,59 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
if let Some(w) = window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
}
log::info!("exported {name} ({} bytes)", bytes.len());
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
})),
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
pick_tracklog: None,
save_preset_file: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
// downloads happen on the main thread: exports run in the foreground on the web
write_shared: None,
@@ -161,6 +217,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
@@ -169,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;
}
}
@@ -197,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 {
@@ -214,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}"),
@@ -231,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
@@ -259,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 {
@@ -269,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,
@@ -277,17 +373,18 @@ 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,
};
let mut problem = None;
match session.open_library_in(stores, true).cloned() {
Ok(r) => log::info!(
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
@@ -298,21 +395,37 @@ impl WebApp {
r.replayed
),
Err(e) => {
log::error!("opening the library failed: {e}; starting a temporary one");
session = Session::with_demo();
// never a silent demo session (issue #100): an empty one, and the window says why
log::error!("opening the library failed: {e}");
session = Session::new();
originals.install(&mut session);
problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem {
can_retry: false,
..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new(
"the browser's storage for this page",
format!("{e}. Reload the page to try again."),
)
});
}
}
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;
}
app.ui = app.ui.sanitized();
app.library_problem = problem;
let n = opts.workers.unwrap_or_else(|| {
let cores = window().navigator().hardware_concurrency() as usize;
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
cores.saturating_sub(1).clamp(1, 4)
});
let workers =
@@ -330,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,
@@ -391,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;
@@ -412,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));
}
}
}
@@ -447,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,
@@ -473,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);
@@ -510,11 +639,22 @@ 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 {
let Some(canvas) =
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
// 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"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
return;
@@ -524,7 +664,7 @@ pub fn start() {
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
match r {
Ok(()) => {
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
el.remove();
}
}
+26 -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,13 +56,15 @@ 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 {
level: job.level,
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,
@@ -78,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 {
@@ -91,6 +101,7 @@ impl WireJob {
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
}
}
@@ -117,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>>,
}
@@ -148,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()),
})
}
}
@@ -293,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,
+2 -1
View File
@@ -10,7 +10,8 @@ default-run = "lightcraft"
workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-engine = { workspace = true, features = ["sam"] }
lightcraft-sysmem = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
+6 -26
View File
@@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}
+237 -32
View File
@@ -1,14 +1,28 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
//!
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use lightcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@@ -19,44 +33,235 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// One parsed line.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
return;
}
continue;
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
},
};
if writeln!(out, "{reply}").is_err() {
break;
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
return;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
(replies, calls)
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
assert_eq!(r[1]["id"], 2);
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the connection
// closed, so the JSON body is never executed.
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n";
let (r, calls) = run(http);
assert!(calls.is_empty(), "nothing may run: {calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
}
#[test]
fn non_request_json_closes_the_connection() {
for junk in [
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
b"{\"id\":1}\n{\"method\":\"a\"}\n",
b"{\"method\":5}\n{\"method\":\"a\"}\n",
b"\xff\xfe\n{\"method\":\"a\"}\n",
] {
let (r, calls) = run(junk);
assert!(calls.is_empty());
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
}
}
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
}
#[test]
fn overlong_line_is_refused_without_buffering_it_all() {
// An endless line without a newline: reading stops after MAX_LINE + 1 bytes.
let endless = std::io::repeat(b'x');
let mut out = Vec::new();
let mut ran = false;
serve_lines(endless, &mut out, |_, _| {
ran = true;
None
});
assert!(!ran);
let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap();
assert!(reply["error"].as_str().unwrap().contains("longer than"));
}
#[test]
fn line_at_the_limit_is_accepted() {
let mut buf = Vec::new();
let line = vec![b'a'; 10];
let mut input = line.clone();
input.push(b'\n');
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
assert_eq!(buf, line);
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
}
}
+387 -52
View File
@@ -7,11 +7,20 @@
//! `~/Pictures/LightCraft Library`; a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts).
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
//! that can't be opened is never replaced silently: the window says why and asks what to do
//! (`lightcraft_ui_egui::panels::library_problem`).
//!
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `lightcraft_ui_egui::control` for the methods.
//!
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
mod control_server;
@@ -19,6 +28,7 @@ mod control_server;
mod native_menu;
use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
@@ -34,15 +44,16 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
if let Some(m) = self.1.as_mut() {
if let Some(m) = self.2.as_mut() {
m.update(&mut self.0, ctx);
}
self.0.logic(ctx);
self.1.tick(&mut self.0, ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@@ -51,50 +62,199 @@ impl eframe::App for App {
self.0.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0);
if let Err(e) = self.1.save(&self.0) {
eprintln!("lightcraft: {e}");
}
if let Err(e) = self.0.session.close_library() {
eprintln!("lightcraft: saving the library failed: {e}");
}
}
}
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"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
/// 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
}
/// The saved UI state and app settings (`<config>/ui.json`), if any.
fn load_prefs() -> Option<UiState> {
/// 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;
}
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
config_dir().map(|d| d.join("gpu-init.marker"))
}
fn save_prefs(app: &LightcraftApp) {
/// 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> {
lightcraft_engine::camera_profiles::config_dir()
}
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
/// when the file exists but can't be used (issue #103): a damaged file is kept as
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
/// one that can't be read at all is not written this session (`keep_file`).
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return;
return (None, None, false);
}
if let Some(d) = config_dir() {
let _ = std::fs::create_dir_all(&d);
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(d.join("ui.json"), bytes);
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
load_prefs_at(&path)
}
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
Err(e) => {
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
return (None, Some(msg), true);
}
};
match serde_json::from_slice::<UiState>(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
Err(e) => {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
let kept = std::fs::rename(path, &keep);
let what = match &kept {
Ok(()) => format!("it was kept as {}", keep.display()),
Err(r) => format!("it couldn't be set aside ({r})"),
};
let msg = format!(
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
path.display()
);
(None, Some(msg), kept.is_err())
}
}
}
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
/// disk leaves the old file or the new one, never a truncated one).
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let tmp = path.with_extension("json.tmp");
let written = (|| {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()
})();
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)?;
if let Some(dir) = path.parent()
&& let Ok(d) = std::fs::File::open(dir)
{
let _ = d.sync_all();
}
Ok(())
}
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
/// changes, every few seconds when anything else changed, and at exit.
#[derive(Default)]
struct PrefsWriter {
written: Vec<u8>,
library: String,
checked: f64,
/// The last write failed (reported once until a write works).
failing: bool,
/// `ui.json` couldn't be read at launch: never overwrite it this session.
keep_file: bool,
}
impl PrefsWriter {
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return Ok(());
}
let Some(d) = config_dir() else { return Ok(()) };
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
}
std::fs::create_dir_all(&d)
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
.map_err(|e| format!("saving the app settings failed: {e}"))?;
self.written = bytes;
self.library = app.ui.settings.library_path.clone();
Ok(())
}
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let moved = app.ui.settings.library_path != self.library;
if !moved && now - self.checked < 3.0 {
return;
}
self.checked = now;
match self.save(app) {
Ok(()) => self.failing = false,
Err(e) => {
eprintln!("lightcraft: {e}");
if !self.failing {
app.notices.push(format!("{e}. LightCraft keeps trying."));
}
self.failing = true;
// don't retry every frame
self.library = app.ui.settings.library_path.clone();
}
}
}
}
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();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
})),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
@@ -124,7 +284,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",
@@ -138,49 +298,76 @@ fn services() -> Services {
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets", &["lcpreset", "xmp"])
.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()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
.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())
})),
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| e.to_string())
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.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()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
write: Some(Box::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(p, b).map_err(|e| e.to_string())
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.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())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
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,
}
}
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
///
/// If the library can't be opened the session is empty and in memory — never seeded with the
/// demo photos, never written anywhere — and the problem is returned: the window then says so
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
if in_memory {
return fallback();
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
}
let unopened = || Session::new().with_fs().with_system_clock();
let Some(dir) = dir else {
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
return fallback();
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
};
let t0 = std::time::Instant::now();
let mut s = Session::new().with_fs().with_system_clock();
@@ -194,11 +381,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
if torn > 0 { ", torn tail repaired" } else { "" },
t0.elapsed().as_secs_f64() * 1000.0
);
s
(s, None)
}
Err(e) => {
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
fallback()
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
}
}
}
@@ -215,9 +402,46 @@ 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.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
optional: LightCraft offers to download it when first needed)
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
";
/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info`
/// (or `debug`) shows more.
struct StderrLog(log::LevelFilter);
impl log::Log for StderrLog {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft"))
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args());
}
}
fn flush(&self) {}
}
fn install_log() {
let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() {
"debug" => log::LevelFilter::Debug,
"info" => log::LevelFilter::Info,
_ => log::LevelFilter::Warn,
};
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() {
log::set_max_level(level);
}
}
fn main() -> eframe::Result {
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
install_log();
alloc_release::install();
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
@@ -246,7 +470,7 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
let prefs = load_prefs();
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
@@ -256,6 +480,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")
@@ -268,23 +497,48 @@ 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(
"LightCraft",
options,
Box::new(move |cc| {
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
session.segmenter.dir =
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
let mut app = LightcraftApp::new(session, services());
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
let writer = PrefsWriter {
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
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);
}
if !files.is_empty() {
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
let _ = app.run("library.import", serde_json::json!({"paths": files}));
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
@@ -294,9 +548,90 @@ fn main() -> eframe::Result {
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
fn damaged_ui_json_is_kept_and_reported() {
let d = dir("damaged");
let path = d.join("ui.json");
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
let (ui, warning, keep) = load_prefs_at(&path);
assert!(ui.is_none() && !keep);
let warning = warning.unwrap();
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
assert!(!path.exists());
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
// a missing file is just "no settings yet"
assert_eq!(load_prefs_at(&path).1, None);
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");
let path = d.join("ui.json");
std::fs::write(&path, b"old").unwrap();
write_atomic(&path, b"{\"new\": true}").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
assert!(!d.join("ui.json.tmp").exists());
// a failed write leaves the old file
let missing = d.join("gone").join("ui.json");
assert!(write_atomic(&missing, b"x").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
let ui = UiState::default();
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
let (loaded, warning, _) = load_prefs_at(&path);
assert!(loaded.is_some() && warning.is_none());
let _ = std::fs::remove_dir_all(&d);
}
}
+62 -17
View File
@@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"'" => Code::Quote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
"Enter" if !mods.is_empty() => Code::Enter,
"F1" => Code::F1,
"F2" => Code::F2,
"F3" => Code::F3,
@@ -146,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 {
@@ -169,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::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -208,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, "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);
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(),
]);
}
@@ -243,8 +245,18 @@ impl NativeMenu {
.collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
]);
}
if title == "Window" {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
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 == "Help" {
@@ -276,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)
}
@@ -319,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);
@@ -346,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();
}
@@ -395,6 +419,27 @@ impl NativeMenu {
mod tests {
use super::*;
/// The menu model's titles stay English, so only the language can tell the native menu to
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
/// languages — must change the structure key.
#[test]
fn switching_language_rebuilds_native_menu_structure() {
use lightcraft_ui_egui::i18n::{Locale, set_language};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
set_language(Locale::En);
let bar = menu_bar(&app);
let mut seen = std::collections::BTreeSet::new();
for language in Locale::ALL {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::ZhHant);
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Locale::En);
}
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
+7
View File
@@ -10,6 +10,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|---|---|---|---|---|---|---|
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
@@ -17,7 +18,13 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `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 |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only |
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.
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
+1
View File
@@ -0,0 +1 @@
{"version":1,"model":"ILCE-7M4","files":597,"samples":2182876,"matrix":[[1.06975,-0.0709886,-0.000770444],[-0.0169822,1.08012,-0.0651276],[-0.114835,-0.601525,1.74804]],"hue_sat":{"hue_divisions":72,"sat_divisions":5,"val_divisions":5,"data":[[0.0,1.0,1.0],[-0.819008,1.14375,1.0],[-6.07446,1.10974,1.0],[-4.0478,1.04285,1.0],[-0.546764,1.00329,1.0],[0.0,1.0,1.0],[-1.0412,1.10597,1.0],[-7.22645,1.05878,1.0],[-7.30635,0.989387,1.0],[-5.18589,0.974522,1.0],[0.0,1.0,1.0],[-1.02364,1.06303,1.0],[-6.94669,1.01579,1.0],[-7.47614,0.955435,1.0],[-7.02579,0.940854,1.0],[0.0,1.0,1.0],[-0.29852,1.04349,1.0],[-6.37202,1.00021,1.0],[-7.52098,0.935457,1.0],[-7.45702,0.922239,1.0],[0.0,1.0,1.0],[0.367464,1.03266,1.0],[-4.96951,0.996822,1.0],[-7.41706,0.948709,1.0],[-8.04735,0.91672,1.0],[0.0,1.0,1.0],[0.0388419,1.02587,1.0],[-4.47266,1.01282,1.0],[-7.26834,0.987279,1.0],[-9.02713,0.921453,1.0],[0.0,1.0,1.0],[-0.297476,1.02333,1.0],[-4.6323,1.00993,1.0],[-8.57248,0.962973,1.0],[-12.2375,0.872088,1.0],[0.0,1.0,1.0],[-0.785867,1.02786,1.0],[-4.73124,1.00876,1.0],[-9.09856,0.955343,1.0],[-12.4324,0.875472,1.0],[0.0,1.0,1.0],[-0.444072,1.03863,1.0],[-3.94773,1.01784,1.0],[-7.97996,0.975996,1.0],[-10.673,0.917401,1.0],[0.0,1.0,1.0],[1.22651,1.0559,1.0],[-1.89459,1.02934,1.0],[-5.54545,0.993046,1.0],[-8.48877,0.941628,1.0],[0.0,1.0,1.0],[3.24516,1.08292,1.0],[1.20522,1.0577,1.0],[-0.826224,1.01668,1.0],[-3.71884,0.946733,1.0],[0.0,1.0,1.0],[4.68802,1.12039,1.0],[3.23147,1.08619,1.0],[1.84476,1.04,1.0],[0.0970375,0.962298,1.0],[0.0,1.0,1.0],[5.36041,1.16313,1.0],[4.5937,1.10933,1.0],[3.83314,1.05056,1.0],[3.122,0.959876,1.0],[0.0,1.0,1.0],[5.22222,1.17824,1.0],[5.09129,1.1142,1.0],[4.99294,1.04813,1.0],[5.01485,0.957528,1.0],[0.0,1.0,1.0],[4.34382,1.18446,1.0],[4.9457,1.11351,1.0],[5.56715,1.04462,1.0],[6.27579,0.961892,1.0],[0.0,1.0,1.0],[3.5428,1.18985,1.0],[4.9116,1.11394,1.0],[6.11507,1.04098,1.0],[7.03638,0.964331,1.0],[0.0,1.0,1.0],[2.92641,1.20663,1.0],[4.57132,1.1281,1.0],[5.96964,1.04895,1.0],[6.98579,0.96261,1.0],[0.0,1.0,1.0],[2.07123,1.23257,1.0],[3.51736,1.15454,1.0],[4.85051,1.06995,1.0],[5.97805,0.971597,1.0],[0.0,1.0,1.0],[0.869122,1.26762,1.0],[2.22009,1.19021,1.0],[3.54297,1.10536,1.0],[4.31783,1.00254,1.0],[0.0,1.0,1.0],[-0.409355,1.31042,1.0],[0.778039,1.23845,1.0],[1.87845,1.15455,1.0],[1.53759,1.02961,1.0],[0.0,1.0,1.0],[-1.82067,1.35265,1.0],[-0.910751,1.28551,1.0],[-0.100378,1.18585,1.0],[0.0425375,1.01463,1.0],[0.0,1.0,1.0],[-3.26659,1.391,1.0],[-2.50117,1.33263,1.0],[-1.55789,1.18102,1.0],[-0.0853484,1.00594,1.0],[0.0,1.0,1.0],[-4.92446,1.43632,1.0],[-3.92066,1.35798,1.0],[-1.66274,1.11605,1.0],[-0.0358972,1.00178,1.0],[0.0,1.0,1.0],[-5.82474,1.46137,1.0],[-4.22466,1.34716,1.0],[-0.64525,1.04813,1.0],[-0.00357121,1.00023,1.0],[0.0,1.0,1.0],[-7.0672,1.47242,1.0],[-5.38667,1.32663,1.0],[-0.553542,1.02668,1.0],[-0.00188478,1.00008,1.0],[0.0,1.0,1.0],[-8.37522,1.43136,1.0],[-6.13631,1.28174,1.0],[-0.515332,1.01808,1.0],[-0.0015762,1.00005,1.0],[0.0,1.0,1.0],[-10.5196,1.39935,1.0],[-7.49316,1.24442,1.0],[-0.517621,1.01282,1.0],[-0.001445,1.00003,1.0],[0.0,1.0,1.0],[-10.5952,1.3534,1.0],[-7.81548,1.20414,1.0],[-0.63473,1.0111,1.0],[-0.00208267,1.00003,1.0],[0.0,1.0,1.0],[-10.879,1.32051,1.0],[-8.3374,1.18517,1.0],[-0.91817,1.01139,1.0],[-0.00474972,1.00003,1.0],[0.0,1.0,1.0],[-11.8094,1.29525,1.0],[-9.18818,1.17522,1.0],[-0.968552,1.01157,1.0],[-0.00411555,1.00003,1.0],[0.0,1.0,1.0],[-12.2038,1.27777,1.0],[-8.97374,1.15856,1.0],[-0.751018,1.00872,1.0],[-0.00250012,1.00002,1.0],[0.0,1.0,1.0],[-12.0564,1.2709,1.0],[-8.38288,1.15926,1.0],[-0.643806,1.00878,1.0],[-0.00202184,1.00002,1.0],[0.0,1.0,1.0],[-12.8403,1.25618,1.0],[-8.85664,1.14517,1.0],[-0.646111,1.00756,1.0],[-0.00195373,1.00001,1.0],[0.0,1.0,1.0],[-13.2604,1.25094,1.0],[-8.74013,1.11989,1.0],[-0.556009,1.00457,1.0],[-0.00173404,1.00001,1.0],[0.0,1.0,1.0],[-13.3939,1.24388,1.0],[-8.23916,1.11659,1.0],[-0.376536,1.00355,1.0],[-0.000916476,1.00001,1.0],[0.0,1.0,1.0],[-13.6578,1.24212,1.0],[-7.97883,1.11035,1.0],[-0.409859,1.00287,1.0],[-0.0018203,1.0,1.0],[0.0,1.0,1.0],[-15.7225,1.22414,1.0],[-10.2345,1.10217,1.0],[-0.968177,1.00427,1.0],[-0.00650675,1.00001,1.0],[0.0,1.0,1.0],[-16.1557,1.16423,1.0],[-12.721,1.06603,1.0],[-2.02337,1.00334,1.0],[-0.0134173,1.00001,1.0],[0.0,1.0,1.0],[-17.253,1.09714,1.0],[-15.785,1.02184,1.0],[-4.02264,0.996649,1.0],[-0.0279292,0.999956,1.0],[0.0,1.0,1.0],[-17.6206,1.04331,1.0],[-16.2039,0.99401,1.0],[-4.09728,0.990246,1.0],[-0.0269801,0.999896,1.0],[0.0,1.0,1.0],[-17.2302,0.987569,1.0],[-15.1656,0.964114,1.0],[-3.21398,0.983725,1.0],[-0.0202065,0.999807,1.0],[0.0,1.0,1.0],[-16.2184,0.943678,1.0],[-13.4633,0.938141,1.0],[-3.40639,0.971571,1.0],[-0.00923182,0.999563,1.0],[0.0,1.0,1.0],[-14.5653,0.909699,1.0],[-11.6873,0.915733,1.0],[-5.4654,0.942337,1.0],[-0.124331,0.998194,1.0],[0.0,1.0,1.0],[-11.5122,0.906891,1.0],[-9.31547,0.923874,1.0],[-6.12222,0.939832,1.0],[-0.337538,0.995963,1.0],[0.0,1.0,1.0],[-7.77619,0.92294,1.0]Line truncated
+4
View File
@@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
+2
View File
@@ -10,11 +10,13 @@ workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
crc32fast = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+216
View File
@@ -0,0 +1,216 @@
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
//! and a catalog clone.
//!
//! ```text
//! cargo run --release -p lightcraft-catalog --example persist_bench -- <dir> [photos…]
//! ```
//!
//! `<dir>` is a scratch directory (wiped per size). Default sizes: 1000 10000 85000. 90 % of the
//! photos are Local browse records, the rest library photos with a few edits and history steps.
//! Peak memory: run under `/usr/bin/time -l` (macOS) / `-v` (Linux) with one size;
//! `PERSIST_BENCH_NO_LOAD=1` skips the final load, so the peak is building + compacting.
use std::sync::Arc;
use std::time::Instant;
use lightcraft_catalog::*;
use lightcraft_develop::DevelopSettings;
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1e3
}
fn photo(c: &mut Catalog, i: u64) -> Photo {
let id = c.alloc_photo_id();
let mut p = Photo::new(
id,
Source::File { path: format!("/Users/someone/Pictures/2026/09/{:05}/IMG_{i:05}.CR3", i / 500) },
&format!("IMG_{i:05}.CR3"),
"CR3",
6000,
4000,
"2026-09-30T12:00:00",
);
p.kind = MediaKind::Raw;
p.file_size = 25_000_000 + i;
p.captured = Some(format!("2026-09-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
p.meta.camera = "Camera Model X".into();
p.meta.lens = "24-70mm f/2.8".into();
p.meta.focal_mm = Some(35.0);
p.meta.aperture = Some(4.0);
p.meta.shutter = "1/250".into();
p.meta.iso = Some(400);
p.as_shot_wb = Some((5200.0, 3.0));
p.local = !i.is_multiple_of(10);
if p.local {
// as a browse catalogues it: import defaults, a baseline fingerprint
p.develop = Arc::new(p.import_defaults());
p.set_local_baseline();
}
if !p.local {
let mut d = DevelopSettings::default();
d.light.exposure = (i % 7) as f64 * 0.1;
let s = Arc::new(d);
p.develop = s.clone();
p.edited = Some("2026-09-30T12:00:00".into());
p.history = vec![HistoryStep { label: "Exposure".into(), settings: s }];
p.rating = (i % 6) as u8;
}
p
}
fn stats(mut v: Vec<f64>) -> String {
v.sort_by(f64::total_cmp);
let pick = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
format!("median {:.2} ms, p95 {:.2} ms, max {:.2} ms", pick(0.5), pick(0.95), pick(1.0))
}
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let dir = std::path::PathBuf::from(args.next().ok_or("usage: persist_bench <scratch dir> [photos…]")?);
let mut sizes: Vec<u64> = args.map(|a| a.parse()).collect::<std::result::Result<_, _>>()?;
if sizes.is_empty() {
sizes = vec![1000, 10_000, 85_000];
}
for n in sizes {
let _ = std::fs::remove_dir_all(&dir);
let (mut j, _, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
let mut cat = Catalog::new();
// bulk-add (as one import would) and log it, so the first compaction is an import's
for chunk in (0..n).collect::<Vec<_>>().chunks(500) {
let ops: Vec<Op> = chunk.iter().map(|i| Op::AddPhoto { photo: Box::new(photo(&mut cat, *i)) }).collect();
for op in &ops {
cat.apply(op.clone())?;
}
j.append(&ops)?;
}
j.snapshot(&cat)?;
let first = j.stats().last_snapshot;
println!("== {n} photos ({} local): snapshot {:.1} MB", cat.photos().filter(|p| p.local).count(), first.bytes as f64 / 1e6);
let t = Instant::now();
let copy = cat.clone();
println!(" catalog clone: {:.2} ms", ms(t));
drop(copy);
// single-op appends (a rating change each), as commands do
let ids: Vec<PhotoId> = cat.photos().map(|p| p.id).take(1000).collect();
let mut appends = Vec::new();
for k in 0..300usize {
let id = ids.get(k % ids.len().max(1)).copied().unwrap_or(PhotoId(1));
let op = Op::SetRating { id, rating: (k % 6) as u8 };
cat.apply(op.clone())?;
j.append(std::slice::from_ref(&op))?;
appends.push(j.stats().last_append_ms);
}
println!(" append (1 op, fsync): {}", stats(appends));
// compactions at the default threshold crossing
let mut totals = Vec::new();
for _ in 0..3 {
while !j.wants_snapshot() {
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
cat.apply(op.clone())?;
j.append(&[op])?;
}
j.snapshot(&cat)?;
let s = j.stats().last_snapshot;
println!(
" compaction ({} records): serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
s.records, s.serialize_ms, s.write_sync_ms, s.reset_ms, s.total_ms
);
totals.push(s.total_ms);
}
println!(" compaction total: {}", stats(totals));
// the same at the threshold, written by the worker while single-op appends go on
let mut blocking = Vec::new();
for _ in 0..3 {
while !j.wants_snapshot() {
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
cat.apply(op.clone())?;
j.append(&[op])?;
}
let t = Instant::now();
j.snapshot_in_background(&cat)?;
let start_ms = ms(t);
let mut during = Vec::new();
while !j.snapshot_written() {
let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
cat.apply(op.clone())?;
j.append(&[op])?;
during.push(j.stats().last_append_ms);
}
let t = Instant::now();
j.poll()?;
let finish_ms = ms(t);
let s = j.stats().last_snapshot;
println!(
" background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
s.records,
s.total_ms,
during.len(),
stats(during)
);
blocking.push(s.blocking_ms);
}
println!(" background compaction blocking: {}", stats(blocking));
// forget the untouched Local records (folders last browsed long ago; every 7th folder
// recently; every 50th Local record rated), then compact
let folders: std::collections::BTreeSet<String> = cat.photos().filter(|p| p.local).filter_map(|p| folder_of(p)).collect();
let mut ops: Vec<Op> = folders
.iter()
.enumerate()
.map(|(k, f)| Op::SetBrowsed {
folder: f.clone(),
at: Some(if k % 7 == 0 { "2026-09-30T00:00:00" } else { "2026-06-01T00:00:00" }.into()),
})
.collect();
ops.extend(cat.photos().filter(|p| p.local && p.id.0.is_multiple_of(50)).map(|p| Op::SetRating { id: p.id, rating: 2 }).collect::<Vec<_>>());
for op in &ops {
cat.apply(op.clone())?;
}
j.append(&ops)?;
j.snapshot(&cat)?;
let before = j.stats().last_snapshot.bytes;
let t = Instant::now();
let plan = cat.forget_local_plan("2026-10-05T00:00:00", DEFAULT_FORGET_DAYS, &|_| false);
let plan_ms = ms(t);
let t = Instant::now();
let ops = plan.ops();
for op in &ops {
cat.apply(op.clone())?;
}
j.append(&ops)?;
let apply_ms = ms(t);
j.snapshot(&cat)?;
let after = j.stats().last_snapshot.bytes;
println!(
" forget Local: {} of {} local records forgotten (kept: {} recent, {} touched, {} in use); plan {plan_ms:.1} ms, apply+append {apply_ms:.1} ms; snapshot {:.1} MB -> {:.1} MB",
plan.evict.len(),
plan.local,
plan.kept_recent,
plan.kept_touched,
plan.kept_in_use,
before as f64 / 1e6,
after as f64 / 1e6
);
drop(j);
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
// peak memory of building + compacting only
continue;
}
let t = Instant::now();
let (_, loaded, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
println!(" open (load snapshot + replay): {:.1} ms", ms(t));
if loaded.len() != cat.len() {
return Err("reloaded catalog differs".into());
}
}
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
+9 -4
View File
@@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
/// Header text for a group key.
pub fn group_label(key: &str) -> String {
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
// `get` rather than `[..]`: a non-ASCII key must not split a character
let year = key.get(..4).unwrap_or(key);
match key.len() {
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
_ => key.to_string(),
},
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
4 => key.to_string(),
_ => "Unknown Date".into(),
}
@@ -196,6 +198,9 @@ mod tests {
assert_eq!(group_label("2026-02"), "February 2026");
assert_eq!(group_label("2026"), "2026");
assert_eq!(group_label(""), "Unknown Date");
// non-ASCII keys of a date's length used to be sliced mid-character
assert_eq!(group_label("2024-01é0"), "2024-01é0");
assert_eq!(group_label("202é01"), "January 202é01");
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
+485 -30
View File
@@ -1,8 +1,9 @@
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
//!
//! Files (in a [`Store`]):
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
//! state after op `N`; replaced atomically (temp + fsync + rename).
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
//! rename).
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
//! fsynced before [`Journal::append`] returns.
@@ -10,10 +11,55 @@
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
//! and the file is cut back to the last good record, so later appends start on a clean line;
//! - a **failed append** (disk full, a dropped network share) may leave part of its batch in the
//! file: [`Journal::append`] cuts the log back to the last whole record before returning the
//! error (or, if that fails too, before the next append), so the retry — the same ops with the
//! same `seq`s — starts on a clean line. Logs written by builds without that cut-back hold the
//! fragment glued to the first retried record on one line; the loader recognises the whole,
//! CRC-valid record at the end of such a line and skips only the fragment;
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
//! the log) are skipped;
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
//!
//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
//! `<= N` are skipped as stale;
//! - after the log rewrite: the new snapshot + exactly the records `> N`.
//!
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
//! first wait for it, so an older snapshot can never replace a newer one.
//!
//! # Format versions
//!
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
//!
//! | Version | Written by | Adds |
//! |---|---|---|
//! | (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` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
//! build would misread or silently drop. New fields must deserialize from older data
//! (`#[serde(default)]`), so newer builds keep reading every older format.
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
//! snapshot in this build's format (also for a new library), before anything is appended — so a
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
//! [`LoadReport::upgraded_from`] says when that happened.
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
//! "unsupported format".)
use crate::store::Store;
use crate::{Catalog, CatalogError, Op, Result};
@@ -21,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
pub const SNAPSHOT: &str = "catalog.snap";
pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
const VERSION: u32 = 1;
/// 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;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
@@ -49,10 +97,75 @@ pub struct LoadReport {
pub failed: usize,
/// Bytes of a torn final record that were dropped.
pub torn_bytes: u64,
/// Partial records left mid-log by a failed append that was retried (written by builds that
/// didn't cut the log back after a failed write): skipped, the retried records were replayed.
pub torn_fragments: usize,
/// The damaged log was preserved under this name.
pub damaged: Option<String>,
/// No catalog files existed (a new library).
pub created: bool,
/// The library was in an older catalog format (`0` = before format versions: a log without a
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
pub upgraded_from: Option<u32>,
}
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistStats {
/// [`Journal::append`] calls that wrote something.
pub appends: u64,
/// Encode + write + `sync_data` of the last / slowest append.
pub last_append_ms: f64,
pub max_append_ms: f64,
/// Snapshots written (compactions, plus the ones on close / repair).
pub snapshots: u64,
/// The last snapshot, by stage.
pub last_snapshot: SnapshotTiming,
/// Total time of the slowest snapshot.
pub max_snapshot_ms: f64,
/// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
/// and starting the worker, plus rewriting the log when it is done).
pub max_blocking_ms: f64,
/// A background snapshot is being written.
pub snapshot_running: bool,
/// Background snapshots that failed (the log was kept whole; retried later).
pub failed_snapshots: u64,
}
/// One snapshot (compaction), by stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotTiming {
/// Serialising the catalog to JSON, streamed into the temp file (buffered writes included).
pub serialize_ms: f64,
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
pub write_sync_ms: f64,
/// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
/// background snapshot ran; fsynced).
pub reset_ms: f64,
/// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
pub total_ms: f64,
/// How long the caller was blocked (equal to `total_ms` unless `background`).
pub blocking_ms: f64,
/// Written by a worker thread.
pub background: bool,
/// Size of `catalog.snap`.
pub bytes: u64,
/// Log records the snapshot compacted.
pub records: u64,
}
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
fn profiling() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
}
fn ms_since(t: web_time::Instant) -> f64 {
t.elapsed().as_secs_f64() * 1e3
}
pub struct Journal {
@@ -63,6 +176,29 @@ pub struct Journal {
log_records: u64,
log_bytes: u64,
pub policy: SnapshotPolicy,
stats: PersistStats,
/// The background snapshot in flight.
pending: Option<Pending>,
/// After a failed background snapshot: don't retry before the log has this many records.
retry_at_records: u64,
/// A failed append may have left part of its records after `log_bytes`, and cutting them off
/// failed too: the next append cuts the log back to `log_bytes` first.
tail_dirty: bool,
}
/// A background snapshot in flight.
struct Pending {
/// The snapshot holds the state after this op.
seq: u64,
/// Log bytes / records appended since it started (all `> seq`): the log once it's done.
tail: Vec<u8>,
tail_records: u64,
/// Log records it compacts.
records: u64,
started: web_time::Instant,
/// Time the caller spent starting it.
start_ms: f64,
worker: std::thread::JoinHandle<std::io::Result<SnapshotTiming>>,
}
fn io(e: std::io::Error) -> CatalogError {
@@ -76,24 +212,93 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
}
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
/// the op is one this version doesn't know (see [`Journal::open`]).
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
let rest = line.strip_prefix("{\"seq\":")?;
let (seq, rest) = rest.split_once(",\"crc\":")?;
let (crc, rest) = rest.split_once(",\"op\":")?;
let body = rest.strip_suffix('}')?;
let seq: u64 = seq.parse().ok()?;
let crc: u32 = crc.parse().ok()?;
if crc32fast::hash(body.as_bytes()) != crc {
return None;
match decode_line(line) {
Line::Record(seq, op) => Some((seq, op)),
Line::Newer(_) | Line::Bad => None,
}
serde_json::from_str(body).ok().map(|op| (seq, op))
}
/// One log line, decoded.
enum Line {
Record(u64, Op),
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
Newer(u64),
/// Malformed or a CRC mismatch: torn or damaged.
Bad,
}
fn decode_line(line: &str) -> Line {
let parts = || {
let rest = line.strip_prefix("{\"seq\":")?;
let (seq, rest) = rest.split_once(",\"crc\":")?;
let (crc, rest) = rest.split_once(",\"op\":")?;
let body = rest.strip_suffix('}')?;
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
};
let Some((seq, crc, body)) = parts() else { return Line::Bad };
if crc32fast::hash(body.as_bytes()) != crc {
return Line::Bad;
}
match serde_json::from_str(body) {
Ok(op) => Line::Record(seq, op),
Err(_) => Line::Newer(seq),
}
}
/// The error for a library written by a newer LightCraft.
fn newer(what: String) -> CatalogError {
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
}
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
/// The record's CRC must match, so damage is never mistaken for it.
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
(rec.0 <= last + 1).then_some((i, rec))
})
}
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
/// whole-file string). The bytes are exactly
/// `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":<serde_json of the catalog>}\n`,
/// as before streaming. Fills in the serialise / write+sync times and the size.
fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io::Result<SnapshotTiming> {
let t0 = web_time::Instant::now();
let mut serialize_ms = 0.0;
let bytes = store.write_atomic_with(SNAPSHOT, &mut |w| {
use std::io::Write;
let t = web_time::Instant::now();
// serde_json emits many tiny writes: buffer them in a concrete writer (inlined), so only
// 1 MiB chunks go through the store's `dyn Write`
let mut b = std::io::BufWriter::with_capacity(1 << 20, w);
write!(b, "{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{seq},\"catalog\":")?;
serde_json::to_writer(&mut b, catalog).map_err(std::io::Error::other)?;
b.write_all(b"}\n")?;
b.flush()?;
serialize_ms = ms_since(t);
Ok(())
})?;
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapFile {
struct SnapHeader {
format: String,
version: u32,
}
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
#[derive(serde::Deserialize)]
struct SnapFile {
seq: u64,
catalog: Catalog,
}
@@ -106,18 +311,34 @@ impl Journal {
let snap = store.read(SNAPSHOT).map_err(io)?;
let log = store.read(LOG).map_err(io)?;
report.created = snap.is_none() && log.is_none();
let (mut catalog, snapshot_seq) = match snap {
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
Some(bytes) => {
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
if s.format != FORMAT || s.version > VERSION {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
// the header first: a newer snapshot may not parse as this version's catalog
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
if h.format != FORMAT {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
}
(s.catalog, s.seq)
if h.version > VERSION {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
(s.catalog, s.seq, Some(h.version))
}
None => (Catalog::new(), 0),
None => (Catalog::new(), 0, None),
};
report.snapshot_seq = snapshot_seq;
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
let mut j = Journal {
store,
seq: snapshot_seq,
snapshot_seq,
log_records: 0,
log_bytes: 0,
policy: SnapshotPolicy::default(),
stats: PersistStats::default(),
pending: None,
retry_at_records: 0,
tail_dirty: false,
};
let log = log.unwrap_or_default();
// Scan records; `good_end` is the byte offset just past the last good record.
@@ -136,7 +357,22 @@ impl Journal {
good_end = next;
continue;
}
match line.and_then(decode_record) {
let mut record = line.and_then(decode_record);
if record.is_none()
&& let Some((at, r)) = resync(&log[pos..line_end], j.seq)
{
// the torn start of a failed append, then the retried records (older builds
// appended them right after the fragment)
log::warn!("catalog log: skipped a partial record ({at} bytes) left by a failed write");
report.torn_fragments += 1;
record = Some(r);
}
if record.is_none()
&& let Some(Line::Newer(seq)) = line.map(decode_line)
{
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
}
match record {
Some((seq, op)) if seq <= j.seq => {
// already in the snapshot
let _ = op;
@@ -152,7 +388,15 @@ impl Journal {
}
_ => {
// bad record (or a gap): torn tail if nothing good follows, else damage
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
let mut rest_has_good = false;
for l in log[next..].split(|b| *b == b'\n') {
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
Ok(Line::Record(..)) => rest_has_good = true,
// refused before anything is modified
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
Ok(Line::Bad) | Err(_) => {}
}
}
if rest_has_good {
damaged_at = Some(pos);
}
@@ -176,10 +420,34 @@ impl Journal {
report.torn_bytes = (log.len() - good_end) as u64;
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
j.store.truncate(LOG, good_end as u64).map_err(io)?;
j.log_bytes = good_end as u64;
} else if needs_newline {
j.store.append(LOG, b"\n").map_err(io)?;
j.log_bytes = good_end as u64 + 1;
} else {
j.log_bytes = good_end as u64;
}
}
// Bring an older (or new, or snapshot-less) library to this version's format before
// anything is appended: a snapshot in this format makes older builds refuse the library
// instead of misreading ops or dropping fields they don't know.
if report.created {
// tiny: written directly, not counted as a compaction
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
log::warn!("catalog: can't write the new library's snapshot: {e}");
}
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
match j.snapshot(&catalog) {
Ok(()) => {
if let Some(v) = report.upgraded_from {
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
}
}
// nothing lost: the old files are intact and still load; retried on next open
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
}
j.log_bytes = good_end as u64;
}
catalog.revision = 0;
Ok((j, catalog, report))
@@ -190,6 +458,7 @@ impl Journal {
if ops.is_empty() {
return Ok(());
}
let t0 = web_time::Instant::now();
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
@@ -197,29 +466,202 @@ impl Journal {
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
if self.tail_dirty {
self.store.truncate(LOG, self.log_bytes).map_err(io)?;
self.tail_dirty = false;
}
if let Err(e) = self.store.append(LOG, buf.as_bytes()) {
// Part of the batch may be in the file. Cut it back to the last whole record, so the
// retry (same seqs) starts on a clean line instead of after a fragment that would
// read back as damage.
if let Err(t) = self.store.truncate(LOG, self.log_bytes) {
log::error!("catalog: can't cut the log back after a failed append: {t}");
self.tail_dirty = true;
}
return Err(io(e));
}
if let Some(p) = self.pending.as_mut() {
p.tail.extend_from_slice(buf.as_bytes());
p.tail_records += ops.len() as u64;
}
self.seq = seq;
self.log_records += ops.len() as u64;
self.log_bytes += buf.len() as u64;
let ms = ms_since(t0);
self.stats.appends += 1;
self.stats.last_append_ms = ms;
self.stats.max_append_ms = self.stats.max_append_ms.max(ms);
if profiling() {
eprintln!("catalog: append {} op(s), {} B: {ms:.2} ms", ops.len(), buf.len());
}
Ok(())
}
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
self.snapshot_with_unlogged(catalog, 0)
}
/// [`Journal::snapshot`] of a catalog that also reflects `unlogged` ops applied after the
/// last appended one but never written to the log (their append failed). The snapshot counts
/// them (it holds the state after op `seq + unlogged`), so they are saved and must not be
/// appended afterwards. Once the snapshot is durable [`Journal::seq`] includes them, even if
/// resetting the log then fails (the error is still returned).
pub fn snapshot_with_unlogged(&mut self, catalog: &Catalog, unlogged: u64) -> Result<()> {
// never two snapshot writers; the one in flight is older, so it must land first
if let Err(e) = self.wait() {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
let seq = self.seq + unlogged;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
// nothing lost (the log is whole); don't retry on every append
self.stats.failed_snapshots += 1;
self.retry_at_records = self.log_records + self.policy.max_records;
return Err(io(e));
}
};
// the snapshot is durable: it holds every op up to `seq`
self.seq = seq;
self.snapshot_seq = seq;
let t2 = web_time::Instant::now();
// A crash here leaves old records in the log; they are skipped by seq on load.
self.store.write_atomic(LOG, b"").map_err(io)?;
self.snapshot_seq = self.seq;
timing.reset_ms = ms_since(t2);
timing.total_ms = ms_since(t0);
timing.blocking_ms = timing.total_ms;
timing.records = self.log_records;
self.record_snapshot(timing);
self.log_records = 0;
self.log_bytes = 0;
self.retry_at_records = 0;
self.tail_dirty = false;
Ok(())
}
/// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
/// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
/// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
/// finishes the compaction. No-op while a snapshot is already in flight.
pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
if self.pending.is_some() {
return Ok(());
}
let t0 = web_time::Instant::now();
let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
let copy = catalog.clone();
let seq = self.seq;
let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, &copy));
let worker = match spawned {
Ok(w) => w,
Err(e) => {
log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
return self.snapshot(catalog);
}
};
self.pending =
Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
Ok(())
}
/// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
/// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
/// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
pub fn poll(&mut self) -> Result<bool> {
if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
return Ok(false);
}
self.finish().map(|()| true)
}
/// Wait for the background snapshot in flight, if any, and finish it.
pub fn wait(&mut self) -> Result<()> {
if self.pending.is_none() {
return Ok(());
}
self.finish()
}
/// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
pub fn snapshot_running(&self) -> bool {
self.pending.is_some()
}
/// The background worker is done (its result not yet applied by [`Journal::poll`]).
pub fn snapshot_written(&self) -> bool {
self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
}
/// Join the worker; on success replace the log with the records appended meanwhile.
fn finish(&mut self) -> Result<()> {
let Some(p) = self.pending.take() else { return Ok(()) };
let t0 = web_time::Instant::now();
let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
let mut timing = match written {
Ok(t) => t,
Err(e) => {
// the log still holds every record: nothing lost, retry later
self.stats.failed_snapshots += 1;
self.retry_at_records = self.log_records + self.policy.max_records;
log::error!("catalog: background snapshot failed: {e}");
return Err(io(e));
}
};
// `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
// Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
let t1 = web_time::Instant::now();
self.snapshot_seq = p.seq;
if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
// the log stays whole (stale records are skipped on load); compact again later
self.retry_at_records = self.log_records + self.policy.max_records;
log::error!("catalog: can't trim the log after a snapshot: {e}");
return Err(io(e));
}
timing.reset_ms = ms_since(t1);
timing.total_ms = ms_since(p.started);
timing.blocking_ms = p.start_ms + ms_since(t0);
timing.background = true;
timing.records = p.records;
self.record_snapshot(timing);
self.log_records = p.tail_records;
self.log_bytes = p.tail.len() as u64;
self.retry_at_records = 0;
self.tail_dirty = false;
Ok(())
}
fn record_snapshot(&mut self, t: SnapshotTiming) {
self.stats.snapshots += 1;
self.stats.last_snapshot = t;
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
if profiling() {
eprintln!(
"catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
if t.background { "background " } else { "" },
t.records,
t.bytes,
t.serialize_ms,
t.write_sync_ms,
t.reset_ms,
t.total_ms,
t.blocking_ms
);
}
}
/// Where persistence time went so far.
pub fn stats(&self) -> PersistStats {
PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
}
/// The log is long enough to be worth compacting.
pub fn wants_snapshot(&self) -> bool {
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
self.pending.is_none()
&& self.log_records >= self.retry_at_records
&& (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
}
pub fn seq(&self) -> u64 {
@@ -238,3 +680,16 @@ impl Journal {
self.store.describe()
}
}
impl Drop for Journal {
/// Wait for a background snapshot (never leave a writer behind that could replace a newer
/// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
/// records in it are skipped as stale on the next load.
fn drop(&mut self) {
if let Some(p) = self.pending.take()
&& let Ok(Err(e)) = p.worker.join()
{
log::error!("catalog: background snapshot failed: {e}");
}
}
}
+49 -27
View File
@@ -51,38 +51,60 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
let Some((first, rest)) = parts.split_first() else { return };
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
Some(i) => i,
None => {
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
nodes.len() - 1
}
};
if seen.insert((path.to_lowercase(), photo)) {
nodes[i].count += 1;
}
let p = nodes[i].path.clone();
insert(&mut nodes[i].children, rest, &p, photo, seen);
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent)
struct Level {
name: String,
path: String,
count: usize,
parent: Option<String>,
}
fn sort(nodes: &mut [KeywordNode]) {
nodes.sort_by_key(|n| n.name.to_lowercase());
for n in nodes {
sort(&mut n.children);
}
}
let mut roots = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
for p in self.photos().filter(|p| p.in_library()) {
this_photo.clear();
for k in &p.meta.keywords {
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
insert(&mut roots, &parts, "", p.id.0, &mut seen);
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
}
}
}
sort(&mut roots);
roots
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
})
.collect()
}
build(None, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
+97 -5
View File
@@ -5,13 +5,23 @@
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
//!
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
//! instead of reading part of it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
pub mod journal;
pub mod keywords;
pub mod local;
pub mod lock;
pub mod model;
pub mod query;
pub mod rules;
pub mod safe_file;
pub mod stacks;
pub mod store;
@@ -19,11 +29,14 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
pub use journal::{Journal, LoadReport, SnapshotPolicy};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey};
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
@@ -41,6 +54,10 @@ pub enum CatalogError {
Corrupt(String),
#[error("catalog storage: {0}")]
Io(String),
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
/// version doesn't know). Nothing was read into the session and nothing was modified.
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
Newer(String),
}
pub type Result<T> = std::result::Result<T, CatalogError>;
@@ -147,6 +164,11 @@ pub enum Op {
id: PhotoId,
captured: Option<String>,
},
/// Assisted culling scores.
SetAnalysis {
id: PhotoId,
analysis: Option<crate::Analysis>,
},
/// Rename a photo: its file name and the source it points to. Applying the op never touches
/// the disk — the engine moves the file before it commits (and on undo/redo).
SetFile {
@@ -160,12 +182,34 @@ pub enum Op {
id: PhotoId,
file_name: String,
source: Source,
/// The file's format when it changes too (Convert to DNG).
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
/// What a photo's file is now (after it changed on disk: Reload).
SetContent {
id: PhotoId,
width: u32,
height: u32,
file_size: u64,
#[serde(default)]
content_hash: Option<String>,
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
name: Option<String>,
},
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
/// step: it drives forgetting untouched Local records (see [`local`]).
SetBrowsed {
folder: String,
at: Option<String>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -185,6 +229,9 @@ pub struct Catalog {
/// Custom colour label names.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
label_names: BTreeMap<ColorLabel, String>,
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -233,6 +280,11 @@ impl Catalog {
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
/// The Quick Collection, once something was added to it.
pub fn quick_collection(&self) -> Option<AlbumId> {
self.albums.values().find(|a| a.quick).map(|a| a.id)
}
/// Albums (regular and smart) that contain the photo.
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
let Some(p) = self.photos.get(&id) else { return Vec::new() };
@@ -261,7 +313,8 @@ impl Catalog {
/// Number of photos shown for an album in the sources list (excludes deleted photos).
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
// counted in place: no id list is built just for its length
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
@@ -288,6 +341,11 @@ impl Catalog {
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
}
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -466,6 +524,10 @@ impl Catalog {
s.collapsed = collapsed;
old
}
Op::SetAnalysis { id, analysis } => {
let p = self.photo_mut(id)?;
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
}
Op::SetCaptured { id, captured } => {
if let Some(c) = &captured
&& stacks::iso_seconds(c).is_none()
@@ -475,10 +537,22 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
}
Op::Relink { id, file_name, source } => {
Op::Relink { id, file_name, source, format } => {
let p = self.photo_mut(id)?;
let old_name = std::mem::replace(&mut p.file_name, file_name);
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
width: std::mem::replace(&mut p.width, width),
height: std::mem::replace(&mut p.height, height),
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
@@ -496,6 +570,14 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
Some(t) => self.browsed.insert(folder.clone(), t),
None => self.browsed.remove(&folder),
};
Op::SetBrowsed { folder, at: old }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@@ -571,4 +653,14 @@ impl Catalog {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
mod tests_torn_append;
+304
View File
@@ -0,0 +1,304 @@
//! Forgetting untouched Local browse records.
//!
//! Browsing a folder (Local) catalogues its photos as `local` records so they can be shown,
//! rated and edited in place. Most are only ever looked at, and on a large disk they can be most
//! of the catalog. A record is **forgotten** (removed from the catalog, never from the disk —
//! files and XMP sidecars are not touched) when all of these hold:
//!
//! - it is a Local record ([`Photo::local`]) of a file, not deleted, not a virtual copy;
//! - its folder was last browsed more than `days` ago ([`Catalog::last_browsed`]). A folder with
//! no recorded time (a catalog from before this existed) counts as browsed *now*: it is stamped
//! with the current time and nothing in it is forgotten this time;
//! - it is **untouched** ([`Photo::untouched_local`]): nothing about it changed since it was
//! browsed — see below;
//! - nothing refers to it: no album holds it or shows it as cover, no stack holds it, no virtual
//! copy was made of it; and the caller (the engine) says it isn't in use (visible, selected,
//! in the undo history, has a smart preview…).
//!
//! **Untouched.** A record catalogued by a browse carries a fingerprint of its whole state at
//! that moment ([`Photo::local_baseline`]). It is untouched while its state still hashes to that
//! fingerprint, i.e. *any* change since — develop settings or history, versions, rating, flag,
//! label, any metadata (title, caption, keywords, location, copyright…), capture time, crop,
//! rename or relink, culling scores, file content — keeps it. Records without a fingerprint
//! (browsed before fingerprints existed) are judged by their data alone and kept unless every
//! user-editable field is still empty / default: no rating, flag or label; develop settings equal
//! to the import defaults with no history, versions or edit time; no title, caption, alt text,
//! description, keywords, creator, copyright, rights, location or GPS; no culling scores.
//! When in doubt a record is kept.
//!
//! Browsing the folder again catalogues its files afresh (new records, new fingerprints).
use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::Serialize;
use crate::{Catalog, Op, Photo, PhotoId, Source};
/// Default for how long a Local folder may go unbrowsed before its untouched records are
/// forgotten (days).
pub const DEFAULT_FORGET_DAYS: u32 = 30;
/// Records removed per logged batch (keeps log lines a reasonable size).
const BATCH: usize = 2000;
/// 64-bit FNV-1a over everything written to it (stable across builds and platforms, unlike
/// `std`'s hasher), so a photo is hashed as it serialises, without a buffer.
struct Fnv(u64);
impl std::io::Write for Fnv {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
for b in buf {
self.0 ^= u64::from(*b);
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// The folder a file-backed photo is in (`None` for demo photos).
pub fn folder_of(p: &Photo) -> Option<String> {
match &p.source {
// `/` and `\` are both separators whatever the platform (a catalog can move between them)
Source::File { path } => {
let key = crate::query::folder_key(path);
key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() })
}
Source::Demo { .. } => None,
}
}
impl Photo {
/// A hash of everything about the photo except [`Photo::local_baseline`] itself.
pub fn fingerprint(&self) -> u64 {
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
let r = if self.local_baseline.is_some() {
let mut p = self.clone();
p.local_baseline = None;
serde_json::to_writer(&mut h, &p)
} else {
serde_json::to_writer(&mut h, self)
};
// (serialising a photo can't fail; if it ever did, no baseline would match: kept)
if r.is_err() { 0 } else { h.0 }
}
/// Record the current state as the browse baseline (done when a browse catalogues it).
pub fn set_local_baseline(&mut self) {
self.local_baseline = None;
self.local_baseline = Some(self.fingerprint());
}
/// A Local record nothing was changed on since it was browsed (see the module docs). Says
/// nothing about albums, stacks or virtual copies referring to it.
pub fn untouched_local(&self) -> bool {
if !self.local || self.deleted || self.copy_of.is_some() || self.copy_name.is_some() || folder_of(self).is_none() {
return false;
}
match self.local_baseline {
Some(b) => self.fingerprint() == b,
None => self.looks_unedited(),
}
}
/// Every user-editable field is still empty / default (records without a baseline).
fn looks_unedited(&self) -> bool {
let m = &self.meta;
self.rating == 0
&& self.flag == crate::Flag::None
&& self.label.is_none()
&& self.edited.is_none()
&& self.history.is_empty()
&& self.versions.is_empty()
&& *self.develop == self.import_defaults()
&& self.analysis.is_none()
&& m.title.is_empty()
&& m.caption.is_empty()
&& m.alt_text.is_empty()
&& m.extended_description.is_empty()
&& m.keywords.is_empty()
&& m.creator.is_empty()
&& m.copyright.is_empty()
&& m.copyright_status.is_unknown()
&& m.usage_terms.is_empty()
&& m.copyright_url.is_empty()
&& m.location.is_empty()
&& m.city.is_empty()
&& m.state.is_empty()
&& m.country.is_empty()
&& m.gps.is_none()
}
}
/// What forgetting Local records would do / did.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ForgetPlan {
/// Local records looked at.
pub local: usize,
/// Records to forget.
pub evict: Vec<PhotoId>,
/// Kept: their folder was browsed within the period (or has no time yet).
pub kept_recent: usize,
/// Kept: changed by the user since they were browsed.
pub kept_touched: usize,
/// Kept: in an album, a stack, the source of a virtual copy, or in use.
pub kept_in_use: usize,
/// Folders with Local records but no last-browsed time: stamped now.
pub stamp: Vec<String>,
/// Folders whose last Local record goes: their time is dropped.
pub unstamp: Vec<String>,
/// The time stamps are given.
#[serde(skip)]
pub now: String,
}
impl ForgetPlan {
/// The ops that carry the plan out (empty when there is nothing to do).
pub fn ops(&self) -> Vec<Op> {
let mut ops: Vec<Op> = self.stamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: Some(self.now.clone()) }).collect();
ops.extend(self.evict.chunks(BATCH).map(|c| Op::Batch { ops: c.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }));
ops.extend(self.unstamp.iter().map(|f| Op::SetBrowsed { folder: f.clone(), at: None }));
ops
}
}
impl Catalog {
/// When `folder` was last browsed (ISO 8601), if known.
pub fn last_browsed(&self, folder: &str) -> Option<&str> {
self.browsed.get(&crate::query::folder_key(folder)).map(String::as_str)
}
/// Folders with a last-browsed time.
pub fn browsed_folders(&self) -> &BTreeMap<String, String> {
&self.browsed
}
/// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of
/// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an
/// unreadable `now` forgets nothing. See the module docs.
pub fn forget_local_plan(&self, now: &str, days: u32, in_use: &dyn Fn(&Photo) -> bool) -> ForgetPlan {
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
let now_s = crate::stacks::iso_seconds(now);
let max_age = i64::from(days) * 86_400;
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
// the fingerprints are the costly part: work them out for the candidates in parallel
let candidates: Vec<&Photo> = self
.photos
.values()
.filter(|p| p.local && !referenced.contains(&p.id))
.filter(|p| folder_of(p).and_then(|f| self.browsed.get(&f)).is_some_and(|at| is_old(at, now_s, days, max_age)))
.map(|p| &**p)
.collect();
let untouched: HashSet<PhotoId> = untouched_of(&candidates);
let mut stamp = BTreeSet::new();
let mut remaining: BTreeSet<String> = BTreeSet::new();
for p in self.photos.values().filter(|p| p.local) {
plan.local += 1;
let Some(folder) = folder_of(p) else {
plan.kept_in_use += 1;
continue;
};
let recent = match (self.browsed.get(&folder), now_s) {
(None, _) => {
stamp.insert(folder.clone());
true
}
(Some(at), _) => !is_old(at, now_s, days, max_age),
};
let keep = if recent {
plan.kept_recent += 1;
true
} else if referenced.contains(&p.id) || in_use(p) {
plan.kept_in_use += 1;
true
} else if !untouched.contains(&p.id) {
plan.kept_touched += 1;
true
} else {
plan.evict.push(p.id);
false
};
if keep {
remaining.insert(folder);
}
}
plan.stamp = stamp.into_iter().collect();
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
plan
}
}
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
fn is_old(at: &str, now_s: Option<i64>, days: u32, max_age: i64) -> bool {
days > 0 && matches!((now_s, crate::stacks::iso_seconds(at)), (Some(n), Some(a)) if n - a >= max_age)
}
/// The untouched ones among `photos` ([`Photo::untouched_local`]), checked on several threads
/// where there are many.
fn untouched_of(photos: &[&Photo]) -> HashSet<PhotoId> {
let check = |chunk: &[&Photo]| chunk.iter().filter(|p| p.untouched_local()).map(|p| p.id).collect::<Vec<_>>();
#[cfg(not(target_arch = "wasm32"))]
{
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(8);
if threads > 1 && photos.len() >= 256 {
let size = photos.len().div_ceil(threads);
return std::thread::scope(|sc| {
let jobs: Vec<_> = photos.chunks(size).map(|c| sc.spawn(move || check(c))).collect();
// a job that panicked (it can't) leaves its photos out: they are kept
jobs.into_iter().filter_map(|j| j.join().ok()).flatten().collect()
});
}
}
check(photos).into_iter().collect()
}
impl Op {
/// Every photo the op refers to.
pub fn photo_ids(&self, out: &mut impl FnMut(PhotoId)) {
match self {
Op::AddPhoto { photo } => out(photo.id),
Op::RemovePhoto { id }
| Op::SetRating { id, .. }
| Op::SetFlag { id, .. }
| Op::SetLabel { id, .. }
| Op::SetDevelop { id, .. }
| Op::SetMeta { id, .. }
| Op::SetDeleted { id, .. }
| Op::SetLocal { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
}
Op::SetAlbumPhotos { photos, .. } | Op::SetStack { photos, .. } => photos.iter().for_each(|p| out(*p)),
Op::SetAlbumCover { cover, .. } => cover.iter().for_each(|p| out(*p)),
Op::AddStack { stack } => stack.photos.iter().for_each(|p| out(*p)),
Op::Batch { ops } => ops.iter().for_each(|o| o.photo_ids(out)),
Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. } => {}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
//! the library is open (issue #99).
//!
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
//!
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
//! that's actually held refuses the open.
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
//! the error message. It's a separate file because a Windows lock also blocks reading the
//! locked file. It's only a hint: it may be missing or left over from a crash.
//! - File systems without lock support (some network shares): the library opens unlocked, with a
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
//! advisory and depend on the server; the owner file is then the only hint.
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
use std::fs::{File, OpenOptions, TryLockError};
use std::io;
use std::path::{Path, PathBuf};
/// The lock file in the library directory.
pub const LOCK: &str = "catalog.lock";
/// Who holds the lock (JSON, see [`LockOwner`]).
pub const OWNER: &str = "catalog.lock.owner";
/// Who has the library open (as written in [`OWNER`]).
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct LockOwner {
pub pid: u32,
/// Computer name, if known.
pub host: String,
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
pub program: String,
pub version: String,
/// When it opened the library (seconds since 1970).
pub since: u64,
}
impl LockOwner {
/// This process, as `program`.
pub fn this_process(program: &str) -> LockOwner {
LockOwner {
pid: std::process::id(),
host: host_name(),
program: program.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
}
}
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
pub fn describe(&self) -> String {
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
let host = if self.host.is_empty() {
String::new()
} else if self.host == host_name() {
" on this computer".to_string()
} else {
format!(" on {}", self.host)
};
format!("{program}{version} (process {}{host})", self.pid)
}
}
/// The computer's name, best effort (empty if unknown).
fn host_name() -> String {
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
/// Another process has the library open.
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
InUse(Option<LockOwner>),
}
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
/// dies).
#[derive(Debug)]
pub struct LibraryLock {
dir: PathBuf,
/// `None` when the file system can't lock (the library is open unlocked).
_file: Option<File>,
held: bool,
}
impl LibraryLock {
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
let unlocked = |why: io::Error| {
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
};
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
Ok(f) => f,
Err(e) => return Ok(unlocked(e)),
};
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
return Err(LockError::InUse(owner));
}
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
}
let owner = LockOwner::this_process(program);
if let Ok(json) = serde_json::to_vec_pretty(&owner)
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
{
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
}
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
}
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
pub fn held(&self) -> bool {
self.held
}
pub fn dir(&self) -> &Path {
&self.dir
}
}
impl Drop for LibraryLock {
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
/// then the file handle closes and the OS releases the lock.
fn drop(&mut self) {
if self.held {
let _ = std::fs::remove_file(self.dir.join(OWNER));
}
}
}
+132 -2
View File
@@ -93,6 +93,65 @@ pub enum Source {
Demo { scene: u32 },
}
/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CopyrightStatus {
/// Not stated (no `xmpRights:Marked`).
#[default]
Unknown,
/// `xmpRights:Marked` = True.
Copyrighted,
/// `xmpRights:Marked` = False.
PublicDomain,
}
impl CopyrightStatus {
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
/// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`).
pub fn parse(s: &str) -> Option<CopyrightStatus> {
match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"unknown" | "" => Some(CopyrightStatus::Unknown),
"copyrighted" => Some(CopyrightStatus::Copyrighted),
"publicdomain" => Some(CopyrightStatus::PublicDomain),
_ => None,
}
}
/// The id used by commands (`photo.setMeta`'s `copyrightStatus`).
pub fn id(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "unknown",
CopyrightStatus::Copyrighted => "copyrighted",
CopyrightStatus::PublicDomain => "publicDomain",
}
}
pub fn label(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "Unknown",
CopyrightStatus::Copyrighted => "Copyrighted",
CopyrightStatus::PublicDomain => "Public Domain",
}
}
/// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
pub fn marked(self) -> Option<bool> {
match self {
CopyrightStatus::Unknown => None,
CopyrightStatus::Copyrighted => Some(true),
CopyrightStatus::PublicDomain => Some(false),
}
}
pub fn from_marked(m: Option<bool>) -> CopyrightStatus {
match m {
None => CopyrightStatus::Unknown,
Some(true) => CopyrightStatus::Copyrighted,
Some(false) => CopyrightStatus::PublicDomain,
}
}
pub fn is_unknown(&self) -> bool {
*self == CopyrightStatus::Unknown
}
}
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -115,8 +174,20 @@ pub struct Meta {
pub alt_text: String,
pub extended_description: String,
pub copyright: String,
/// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields).
#[serde(skip_serializing_if = "CopyrightStatus::is_unknown")]
pub copyright_status: CopyrightStatus,
#[serde(skip_serializing_if = "String::is_empty")]
pub usage_terms: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub copyright_url: String,
pub creator: String,
pub keywords: Vec<String>,
/// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo.
/// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP.
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec<lightcraft_meta::Region>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -197,6 +268,32 @@ pub struct Photo {
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
/// Assisted culling scores (`None` until analysed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Analysis>,
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
/// picture style baked in — so it is treated as a rendered (non-raw) source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_baseline: Option<u64>,
}
/// What assisted culling measured on a photo.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Analysis {
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
pub sharpness: f32,
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
pub clipped: f32,
/// Similar-shot group (burst), when the photo has neighbours that look alike.
pub group: Option<u32>,
/// The best photo of its group.
pub best: bool,
}
impl Photo {
@@ -229,8 +326,22 @@ impl Photo {
copy_of: None,
copy_name: None,
import_look: None,
analysis: None,
preview_only: None,
local_baseline: None,
}
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
/// its embedded preview ([`Photo::preview_only`]).
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW and NEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// 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 {
@@ -242,7 +353,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(),
};
@@ -283,12 +395,15 @@ pub struct Album {
/// live; `photos` stays empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart: Option<Box<crate::Filter>>,
/// The Quick Collection: a regular album that B adds to (one per library).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub quick: bool,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
@@ -312,4 +427,19 @@ mod edited_tests {
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
}
}
}
+180 -4
View File
@@ -24,6 +24,12 @@ pub struct Filter {
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
/// Only these photos (Find Similar results…); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub only: Vec<PhotoId>,
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
@@ -33,6 +39,8 @@ pub struct Filter {
pub date: Option<String>,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option<String>,
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
pub person: Option<String>,
pub camera: Option<String>,
/// Lens (case-insensitive substring).
pub lens: Option<String>,
@@ -40,11 +48,18 @@ pub struct Filter {
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
/// Import date prefix.
pub imported: Option<String>,
/// Imported at or after this time (ISO; Recently Added).
pub imported_from: Option<String>,
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
pub merged: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_set: Option<crate::RuleSet>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -100,6 +115,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
"person" | "who" => has_person(p, val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
@@ -112,6 +128,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
@@ -130,15 +152,23 @@ impl Filter {
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if !self.labels.is_empty() {
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
if let Some(e) = self.edited {
v.push(if e { "edited".into() } else { "unedited".into() });
}
for (name, val) in
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
{
for (name, val) in [
("keyword", &self.keyword),
("person", &self.person),
("camera", &self.camera),
("lens", &self.lens),
("date", &self.date),
("imported", &self.imported),
] {
if let Some(x) = val {
v.push(format!("{name} {x}"));
}
@@ -152,9 +182,17 @@ impl Filter {
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(rs.describe());
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
@@ -186,6 +224,23 @@ impl Filter {
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(rs) = &self.rule_set
&& !rs.matches(p, cat)
{
return false;
}
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !self.only.contains(&p.id) {
return false;
}
if let Some(want) = &self.merged {
match merged_kind(&p.file_name) {
Some(k) if want == "any" || want == k => {}
_ => return false,
}
}
if let Some(a) = self.album
&& !cat.album_contains(a, p)
{
@@ -216,11 +271,21 @@ impl Filter {
{
return false;
}
if let Some(d) = &self.imported_from
&& p.imported.as_str() < d.as_str()
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
return false;
}
if let Some(n) = &self.person
&& !has_person(p, n)
{
return false;
}
if let Some(c) = &self.camera
&& !p.meta.camera.eq_ignore_ascii_case(c)
{
@@ -284,6 +349,54 @@ impl Catalog {
}
m.into_iter().collect()
}
/// The people named on faces in the library (MWG regions read from XMP): how many photos each
/// appears in and the photo showing their largest face (for a card's picture). Most photos first,
/// then by name. Names that differ only in case are one person, shown as first seen; a person
/// twice in one photo counts once.
pub fn people(&self) -> Vec<Person> {
self.people_in(&Filter::default())
}
/// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the
/// People view offers while other filters (a date, a rating, an album…) are active, so picking
/// a person never ends in an empty grid.
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
for p in self.photos().filter(|p| filter.matches(p, self)) {
let mut seen: Vec<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = name.to_lowercase();
// the face's size in pixels of the photo
let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64;
let (person, best) =
m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area));
if !seen.contains(&key) {
person.count += 1;
seen.push(key);
}
if area > *best || (area == *best && p.id < person.photo) {
(person.photo, person.face, *best) = (p.id, r.rect, area);
}
}
}
let mut v: Vec<Person> = m.into_values().map(|(p, _)| p).collect();
v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
v
}
}
/// A person named on faces in the library ([`Catalog::people`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Person {
pub name: String,
/// Photos they appear in.
pub count: usize,
/// The photo with their largest face, and that face (normalized, in the photo's upright frame).
pub photo: PhotoId,
pub face: lightcraft_meta::Rect,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
@@ -304,3 +417,66 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
/// No file-system access: the paths compared should already be absolute.
pub fn folder_key(path: &str) -> String {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
Some(unc) => format!("//{unc}"),
None => rest.to_string(),
};
}
// the part `..` can't climb above: `//server/share`, `c:` or `/`
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
let mut it = unc.splitn(3, '/');
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
(format!("//{server}/{share}"), rest.to_string())
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
(s[..2].to_ascii_lowercase(), s[2..].to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
let mut parts: Vec<&str> = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
".." if absolute => {}
c => parts.push(c),
}
}
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
let (p, r) = (folder_key(path), folder_key(root));
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
if stem.ends_with("-hdr-pano") {
Some("hdrPanorama")
} else if stem.ends_with("-hdr") {
Some("hdr")
} else if stem.ends_with("-pano") {
Some("panorama")
} else {
None
}
}
+491
View File
@@ -0,0 +1,491 @@
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
//!
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Catalog, Photo};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Match {
#[default]
All,
Any,
None,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RuleSet {
#[serde(default, rename = "match")]
pub mode: Match,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Rule {
/// A nested group with its own match mode.
Group { group: RuleSet },
Field {
field: String,
op: String,
#[serde(default)]
value: Value,
},
}
/// How a field's value is compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Text,
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
Keywords,
Number,
Date,
/// One of a fixed set (`choices`).
Choice(&'static [&'static str]),
Bool,
}
/// The fields rules can test: (id, label, kind).
pub const FIELDS: &[(&str, &str, Kind)] = &[
("rating", "Rating", Kind::Number),
("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
("edited", "Has Edits", Kind::Bool),
("keywords", "Keywords", Kind::Keywords),
("text", "Any Searchable Text", Kind::Text),
("fileName", "Filename", Kind::Text),
("format", "File Format", Kind::Text),
("title", "Title", Kind::Text),
("caption", "Caption", Kind::Text),
("camera", "Camera", Kind::Text),
("lens", "Lens", Kind::Text),
("location", "Location", Kind::Text),
("creator", "Creator", Kind::Text),
("copyright", "Copyright", Kind::Text),
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
("captureDate", "Capture Date", Kind::Date),
("importDate", "Import Date", Kind::Date),
("editDate", "Edit Date", Kind::Date),
("iso", "ISO Speed", Kind::Number),
("aperture", "Aperture", Kind::Number),
("focalLength", "Focal Length", Kind::Number),
("megapixels", "Megapixels", Kind::Number),
("hasGps", "Has GPS", Kind::Bool),
("virtualCopy", "Virtual Copy", Kind::Bool),
("album", "Album", Kind::Number),
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
/// The operators for a field kind: (id, label).
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
match kind {
Kind::Text => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("isNot", "isn't"),
("startsWith", "starts with"),
("endsWith", "ends with"),
("isEmpty", "is empty"),
("isNotEmpty", "isn't empty"),
],
Kind::Keywords => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("startsWith", "starts with"),
("isEmpty", "are empty"),
("isNotEmpty", "aren't empty"),
],
Kind::Number => {
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
}
Kind::Date => &[
("is", "is"),
("after", "is after"),
("before", "is before"),
("between", "is between"),
("inLast", "is in the last"),
("notInLast", "isn't in the last"),
("isEmpty", "is unknown"),
],
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
Kind::Bool => &[("is", "is")],
}
}
pub fn field_kind(field: &str) -> Option<Kind> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
}
thread_local! {
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
pub fn set_now(iso: Option<String>) {
NOW.with(|n| *n.borrow_mut() = iso);
}
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
pub fn now() -> String {
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
return n;
}
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
crate::dates::civil(secs)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00".to_string()
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
Value::Null => String::new(),
other => other.to_string().to_lowercase(),
}
}
fn text_op(op: &str, have: &str, want: &str) -> bool {
let have = have.trim().to_lowercase();
match op {
"contains" => want.split_whitespace().all(|w| have.contains(w)),
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
"is" => have == want,
"isNot" => have != want,
"startsWith" => have.starts_with(want),
"endsWith" => have.ends_with(want),
"isEmpty" => have.is_empty(),
"isNotEmpty" => !have.is_empty(),
_ => false,
}
}
fn number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
_ => None,
}
}
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
if op == "between" {
let (a, b) = match value {
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
_ => (None, None),
};
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
}
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
match op {
"is" => (h - w).abs() < 1e-6,
"isNot" => (h - w).abs() >= 1e-6,
"gte" => h >= w - 1e-9,
"lte" => h <= w + 1e-9,
"gt" => h > w,
"lt" => h < w,
_ => false,
}
}
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
fn last_secs(value: &Value) -> Option<i64> {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
v => (number(v)?, "days"),
};
let day = 86_400.0;
let per = match unit {
"hours" => 3600.0,
"weeks" => 7.0 * day,
"months" => 30.4375 * day,
"years" => 365.25 * day,
_ => day,
};
Some((n * per) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
if op == "isEmpty" {
return have.is_none_or(str::is_empty);
}
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
match op {
// a prefix: 2026, 2026-04, 2026-04-12
"is" => {
let w = s(value);
!w.is_empty() && h.starts_with(&w)
}
"after" => {
let w = s(value);
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
}
"before" => {
let w = s(value);
!w.is_empty() && h < w.as_str()
}
"between" => match value {
Value::Array(v) if v.len() == 2 => {
let (a, b) = (s(&v[0]), s(&v[1]));
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
}
_ => false,
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
}
}
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = format!("{:?}", p.flag).to_lowercase();
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
// custom label names count too
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
"edited" => p.is_edited() == value.as_bool().unwrap_or(true),
"hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
"virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
"keywords" => match op {
"isEmpty" => m.keywords.is_empty(),
"isNotEmpty" => !m.keywords.is_empty(),
"notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
_ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
},
"text" => {
let all = [
p.file_name.as_str(),
&m.title,
&m.caption,
&m.camera,
&m.lens,
&m.location,
&m.city,
&m.country,
&p.format,
&m.keywords.join(" "),
]
.join(" ");
text(&all)
}
"fileName" => text(&p.file_name),
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
"camera" => text(&m.camera),
"lens" => text(&m.lens),
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
"creator" => text(&m.creator),
"copyright" => text(&m.copyright),
"copyrightStatus" => {
let want = crate::CopyrightStatus::parse(&want);
(want == Some(m.copyright_status)) == (op == "is")
}
"captureDate" => date_op(op, p.captured.as_deref(), value),
"importDate" => date_op(op, Some(&p.imported), value),
"editDate" => date_op(op, p.edited.as_deref(), value),
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
"album" => {
let id = number(value).map(|v| crate::AlbumId(v as u64));
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
}
_ => false,
}
}
}
impl RuleSet {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if self.rules.is_empty() {
return self.mode != Match::Any;
}
match self.mode {
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
}
}
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
/// same photos can enter or leave the set without any catalog change.
pub fn depends_on_now(&self) -> bool {
self.rules.iter().any(|r| match r {
Rule::Group { group } => group.depends_on_now(),
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
})
}
/// Unknown fields or operators (for command validation), as readable messages.
pub fn problems(&self) -> Vec<String> {
let mut out = Vec::new();
for r in &self.rules {
match r {
Rule::Group { group } => out.extend(group.problems()),
Rule::Field { field, op, .. } => match field_kind(field) {
None => out.push(format!("unknown field `{field}`")),
Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
_ => {}
},
}
}
out
}
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
pub fn describe(&self) -> String {
let join = match self.mode {
Match::All => " and ",
Match::Any => " or ",
Match::None => " nor ",
};
let parts: Vec<String> = self
.rules
.iter()
.map(|r| match r {
Rule::Group { group } => format!("({})", group.describe()),
Rule::Field { field, op, value } => {
let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
let v = match value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
o.get("unit").and_then(Value::as_str).unwrap_or("days")
),
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
other => other.to_string(),
};
format!("{label} {op} {v}").trim().to_string()
}
})
.collect();
let s = parts.join(join);
if self.mode == Match::None { format!("none of: {s}") } else { s }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
);
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
// copyright status (unknown until set)
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
let mut pd = photo(2);
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
// nested: rating ≥ 4 and (label is blue or keywords contain food)
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
}
+388
View File
@@ -0,0 +1,388 @@
//! 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
//! volume has no hard links).
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
//! resolved paths elsewhere), for "is this target an original?" checks.
use std::cell::Cell;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
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
/// this module **on the calling thread** fails once `bytes` bytes of a file were written — as a
/// full disk or an unplugged drive would.
#[doc(hidden)]
pub fn fail_writes_after(bytes: u64) -> FaultGuard {
FAIL_AFTER.with(|c| c.set(Some(bytes)));
FaultGuard(())
}
/// Ends a [`fail_writes_after`] when dropped.
#[doc(hidden)]
pub struct FaultGuard(());
impl Drop for FaultGuard {
fn drop(&mut self) {
FAIL_AFTER.with(|c| c.set(None));
}
}
/// Counts what is written; fails past an injected limit.
struct Limited<W> {
inner: W,
n: u64,
limit: Option<u64>,
}
impl<W: Write> Write for Limited<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let buf = match self.limit {
Some(l) => {
let room = usize::try_from(l.saturating_sub(self.n)).unwrap_or(usize::MAX);
if room == 0 && !buf.is_empty() {
return Err(io::Error::other("write failed (injected fault)"));
}
buf.get(..room.min(buf.len())).unwrap_or(buf)
}
None => buf,
};
let k = self.inner.write(buf)?;
self.n += k as u64;
Ok(k)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn parent_of(path: &Path) -> &Path {
path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."))
}
/// Make a rename or a new name in `dir` durable (POSIX). Opening a directory fails on Windows,
/// where NTFS journals the rename itself, so errors are ignored.
pub fn sync_dir(dir: &Path) {
if let Ok(d) = File::open(dir) {
let _ = sync_file(&d);
}
}
/// A new, empty temp file next to `path` (hidden, unique in this process and folder).
fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
static N: AtomicU64 = AtomicU64::new(0);
if path.file_name().is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is not a file path", path.display())));
}
let dir = parent_of(path);
for _ in 0..1000 {
let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => return Ok((tmp, f)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
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 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);
let mut w = Limited { inner: io::BufWriter::with_capacity(1 << 20, file), n: 0, limit };
fill(&mut w)?;
w.flush()?;
let n = w.n;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
if sync {
sync_file(&f)?;
}
Ok(n)
})();
match r {
Ok(n) => Ok((tmp, n)),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e)
}
}
}
/// Replace (or create) `path` with `data` atomically and durably; see the module docs. The
/// folder must exist.
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
write_atomic_with(path, &mut |w| w.write_all(data)).map(|_| ())
}
/// [`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> {
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);
}
if sync {
sync_dir(parent_of(path));
}
Ok(n)
}
/// Write `data` as the new file `path`: never replaces an existing file (`AlreadyExists`), and
/// the file only appears once it is complete, synced and read back identical.
pub fn write_new(path: &Path, data: &[u8]) -> io::Result<()> {
write_new_unique(&mut std::iter::once(path.to_path_buf()), data).map(|_| ())
}
/// [`write_new`] under the first free name of `candidates` (all in the same folder, e.g.
/// `IMG_1.dng`, `IMG_1-2.dng`, …): the file is written once and then given the first name that
/// is not taken. Returns that name; `AlreadyExists` when every candidate is taken.
pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u8]) -> io::Result<PathBuf> {
let mut candidates = candidates.peekable();
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, 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?)"));
}
for path in candidates.by_ref().take(100_000) {
match publish_new(&tmp, &path) {
Ok(()) => {
sync_dir(parent_of(&path));
return Ok(path);
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: no free file name", first.display())))
})();
// after a hard link the temp name is a second name of the new file; after a rename it's gone
let _ = fs::remove_file(&tmp);
r
}
/// Give the complete temp file `tmp` the name `path`, unless that is taken.
fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
match fs::hard_link(tmp, path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
Err(_) => {
if fs::symlink_metadata(path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
}
fs::rename(tmp, path)
}
}
}
/// Does the file at `path` hold exactly `data`?
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
use std::io::Read;
let mut f = File::open(path)?;
if f.metadata()?.len() != data.len() as u64 {
return Ok(false);
}
let mut buf = vec![0u8; 1 << 20];
let mut at = 0usize;
loop {
let k = match f.read(&mut buf) {
Ok(0) => return Ok(at == data.len()),
Ok(k) => k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if data.get(at..at + k) != buf.get(..k) {
return Ok(false);
}
at += k;
}
}
/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)?
pub fn same_file(a: &Path, b: &Path) -> bool {
let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false };
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
ma.dev() == mb.dev() && ma.ino() == mb.ino()
}
#[cfg(not(unix))]
{
if ma.len() != mb.len() {
return false;
}
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-safe-file-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn names(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
v.sort();
v
}
#[test]
fn atomic_write_replaces_and_leaves_no_temp() {
let d = temp("replace");
let p = d.join("out.jpg");
write_atomic(&p, b"one").unwrap();
write_atomic(&p, b"two two").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["out.jpg"]);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_failure_mid_write_keeps_the_old_file() {
let d = temp("midwrite");
let p = d.join("photo.jpg");
fs::write(&p, b"the original bytes").unwrap();
{
let _f = fail_writes_after(4);
let e = write_atomic(&p, &[7u8; 10_000]).unwrap_err();
assert!(e.to_string().contains("injected"), "{e}");
assert!(write_new(&d.join("new.dng"), &[1u8; 100]).is_err());
}
assert_eq!(fs::read(&p).unwrap(), b"the original bytes", "the target is untouched");
assert_eq!(names(&d), vec!["photo.jpg"], "no temp file and no partial new file");
// a fill error aborts the same way
let r = write_atomic_with(&p, &mut |w| {
w.write_all(b"half")?;
Err(io::Error::other("encoder failed"))
});
assert!(r.is_err());
assert_eq!(fs::read(&p).unwrap(), b"the original bytes");
assert_eq!(names(&d), vec!["photo.jpg"]);
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");
let p = d.join("a.dng");
fs::write(&p, b"keep").unwrap();
assert_eq!(write_new(&p, b"other").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&p).unwrap(), b"keep");
let mut c = (1..).map(|k| if k == 1 { d.join("a.dng") } else { d.join(format!("a-{k}.dng")) });
let got = write_new_unique(&mut c, b"fresh").unwrap();
assert_eq!(got, d.join("a-2.dng"));
assert_eq!(fs::read(&got).unwrap(), b"fresh");
assert_eq!(names(&d), vec!["a-2.dng", "a.dng"], "the temp file is gone");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn same_file_sees_through_other_spellings() {
let d = temp("same");
let p = d.join("x.jpg");
fs::write(&p, b"x").unwrap();
fs::write(d.join("y.jpg"), b"x").unwrap();
fs::create_dir_all(d.join("sub")).unwrap();
assert!(same_file(&p, &d.join("sub/../x.jpg")));
assert!(!same_file(&p, &d.join("y.jpg")));
assert!(!same_file(&p, &d.join("missing.jpg")));
let _ = fs::remove_dir_all(&d);
}
}
+37 -1
View File
@@ -93,6 +93,42 @@ impl Catalog {
self.group_ops(result, sources, true)
}
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
/// top). `None` when it isn't stacked or can't move.
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
let st = self.stack_of(photo)?;
let i = st.photos.iter().position(|p| *p == photo)?;
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
if to == i {
return None;
}
let mut photos = st.photos.clone();
let p = photos.remove(i);
photos.insert(to, p);
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
}
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
/// new stack (topped by it). A part with a single photo is no longer stacked.
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
let st = self.stack_of(photo)?.clone();
let i = st.photos.iter().position(|p| *p == photo)?;
if i == 0 {
return None;
}
let (head, tail) = st.photos.split_at(i);
let mut ops = vec![if head.len() >= 2 {
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
} else {
Op::RemoveStack { id: st.id }
}];
if tail.len() >= 2 {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
}
Some(Op::Batch { ops })
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
@@ -241,7 +277,7 @@ mod tests {
#[test]
fn group_merge_set_top_remove_and_arrange() {
let (mut c, ids) = cat(6, &[]);
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
let op = c.group_ops(b, &[a, b, x], false).unwrap();
let inv = c.apply(op).unwrap();
let s = c.stack_of(a).unwrap().clone();
+91 -6
View File
@@ -16,12 +16,33 @@ pub trait Store: Send {
/// Replace a file atomically: after a crash either the old or the new content is visible,
/// never a mix (native: write temp + fsync + rename + fsync directory).
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
/// Append and make durable (fsync) before returning.
/// [`Store::write_atomic`] with streamed content: `fill` writes the new content into the
/// given writer (an error aborts and leaves the old file). Same durability: the file is
/// replaced only once all of it is written (native: and fsynced). Returns its length.
///
/// The default buffers the content in memory; [`FsStore`] streams it into the temp file, so
/// a large snapshot is never held in memory as a whole.
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
let mut buf = Vec::new();
fill(&mut buf)?;
self.write_atomic(name, &buf)?;
Ok(buf.len() as u64)
}
/// Append and make durable (fsync) before returning. On an error, part of `data` may have
/// been written (a full disk, a dropped network share): the journal cuts the file back to its
/// previous length with [`Store::truncate`] before appending again.
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
/// Cut a file to `len` bytes (drop a torn tail before appending again).
/// Cut a file to `len` bytes (drop a torn tail before appending again). A missing file is
/// left missing.
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
/// Human-readable location (diagnostics).
fn describe(&self) -> String;
/// Another handle on the same files that a worker thread can write `catalog.snap` through
/// while this one keeps appending to the log (background compaction). `None` (the default):
/// snapshots are written synchronously.
fn background_writer(&self) -> Option<Box<dyn Store>> {
None
}
}
/// Files in a directory.
@@ -77,6 +98,32 @@ impl Store for FsStore {
Ok(())
}
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
use std::io::Write;
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
let tmp = self.path(&format!("{name}.tmp"));
let written = (|| {
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
fill(&mut w)?;
w.flush()?;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
f.sync_all()?;
Ok(w.n)
})();
let n = match written {
Ok(n) => n,
Err(e) => {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
};
std::fs::rename(&tmp, self.path(name))?;
self.sync_dir();
Ok(n)
}
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
use std::io::Write;
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
@@ -87,16 +134,27 @@ impl Store for FsStore {
}
self.appender = Some((name.to_string(), f));
}
let (_, f) = self.appender.as_mut().expect("appender just opened");
f.write_all(data)?;
f.sync_data()
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
let written = f.write_all(data).and_then(|()| f.sync_data());
if written.is_err() {
// The handle may be dead (a network share that reconnected, a removed volume): the next
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
// ([`Store::truncate`]) before appending again.
self.appender = None;
}
written
}
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
Ok(f) => f,
// nothing to cut (an append that failed before creating the file)
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
f.set_len(len)?;
f.sync_all()
}
@@ -104,12 +162,36 @@ impl Store for FsStore {
fn describe(&self) -> String {
self.dir.display().to_string()
}
#[cfg(not(target_arch = "wasm32"))]
fn background_writer(&self) -> Option<Box<dyn Store>> {
Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
}
}
/// Counts the bytes written through it.
struct Counter<W> {
inner: W,
n: u64,
}
impl<W: io::Write> io::Write for Counter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let k = self.inner.write(buf)?;
self.n += k as u64;
Ok(k)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
#[derive(Clone, Default)]
pub struct MemStore {
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
/// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
pub background: bool,
}
impl MemStore {
@@ -145,4 +227,7 @@ impl Store for MemStore {
fn describe(&self) -> String {
"memory".into()
}
fn background_writer(&self) -> Option<Box<dyn Store>> {
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
}
}
+168 -4
View File
@@ -57,11 +57,15 @@ fn albums_and_folders() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let f = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
})
.unwrap();
let al = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
})
.unwrap();
assert_eq!(c.albums_of(a), vec![al]);
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
@@ -216,3 +220,163 @@ fn folder_paths() {
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
}
#[test]
fn merge_results_are_recognised() {
use crate::query::merged_kind;
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
assert_eq!(merged_kind("hdr.jpg"), None);
assert_eq!(merged_kind("panorama.jpg"), None);
}
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
/// with an exact inverse that survives serialisation (the journal).
#[test]
fn preview_only_survives_serde_and_set_content_undo() {
let mut c = Catalog::new();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
assert!(!p.develops_raw());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
// the catalog round-trips it
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
// an older catalog (no field) loads as decodable
let mut v = serde_json::to_value(&c).unwrap();
let photos = v["photos"].as_object_mut().unwrap();
let ph = photos.values_mut().next().unwrap();
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
let old: Catalog = serde_json::from_value(v).unwrap();
assert_eq!(old.photo(a).unwrap().preview_only, None);
assert!(old.photo(a).unwrap().develops_raw());
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
let inv = c.apply(op).unwrap();
assert!(c.photo(a).unwrap().develops_raw());
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().width, 6000);
}
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
#[test]
fn folder_identity_ignores_spelling() {
use crate::query::{folder_key, folder_within};
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
for (a, b) in [
("D:/Example/Photos", "D:\\Example\\Photos"),
("D:/Example/Photos", "d:\\Example\\Photos\\"),
("D:\\Example/Photos", "D:/Example//Photos/."),
("D:/Example/Photos", "D:/Example/Other/../Photos"),
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
("\\\\server\\share\\Photos", "//server/share/Photos/"),
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
("/home/me/Photos", "/home/me/Photos/"),
("/home/me/Photos", "/home//me/./Photos"),
("/", "//"),
("C:\\", "c:/"),
] {
same(a, b);
}
differ("D:/Example/Photos", "D:/Example/Photos2");
differ("D:/Example/Photos", "E:/Example/Photos");
differ("/home/me/Photos", "/home/me");
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
assert!(folder_within("/a/b", "/"));
assert!(folder_within("C:\\x", "c:\\"));
}
/// People come from named *face* regions: counted once per photo, case-insensitive, most photos
/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match.
#[test]
fn people_from_named_face_regions() {
use lightcraft_meta::{Rect, Region, RegionKind};
let region = |name: Option<&str>, kind: RegionKind| Region {
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind,
name: name.map(str::to_string),
description: None,
};
let mut c = Catalog::new();
let mut add = |name: &str, regions: Vec<Region>| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.regions = regions;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let a = add(
"a.jpg",
vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)],
);
let b =
add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]);
let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 };
let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]);
let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]);
add("f.jpg", vec![]);
let people = c.people();
let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect();
assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out");
// the picture is the person's largest face; ties go to the lower photo id
assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }));
assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one");
let q = |f: Filter| c.query(&f, &Sort::default());
assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2);
let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() });
got.sort();
assert_eq!(got, vec![b, d]);
assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person");
assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case");
assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a));
// other filters narrow who is offered (so picking a person never ends in an empty grid); the
// `person` filter itself does not
c.apply(Op::SetRating { id: b, rating: 3 }).unwrap();
let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::<Vec<_>>();
let rated = Filter { rating: 1, ..Default::default() };
assert_eq!(
names(rated.clone()),
vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)],
"only the rated photo's people, counted within it, spelled as first seen there"
);
assert_eq!(
names(Filter { person: Some("Sam".into()), ..rated }),
names(Filter { rating: 1, ..Default::default() }),
"the person filter is ignored"
);
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
}
/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
/// before regions existed (no `regions` key) reads with none.
#[test]
fn empty_regions_are_not_serialized_and_default_when_missing() {
let m = Meta { keywords: vec!["k".into()], ..Default::default() };
let v = serde_json::to_value(&m).unwrap();
assert!(v.get("regions").is_none(), "{v}");
let back: Meta = serde_json::from_value(v).unwrap();
assert!(back.regions.is_empty());
let mut with = m.clone();
with.regions.push(lightcraft_meta::Region {
rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
kind: lightcraft_meta::RegionKind::Face,
name: Some("A".into()),
description: None,
});
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
assert_eq!(back, with);
}
+339
View File
@@ -0,0 +1,339 @@
//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use proptest::prelude::*;
use crate::journal::{LOG, SNAPSHOT};
use crate::*;
/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
/// and can be made to fail.
#[derive(Clone)]
struct Gated {
files: MemStore,
gate: Arc<(Mutex<bool>, Condvar)>,
fail: Arc<AtomicBool>,
}
impl Gated {
fn new() -> Gated {
Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
}
fn set_gate(&self, open: bool) {
*self.gate.0.lock().unwrap() = open;
self.gate.1.notify_all();
}
/// The files as a crash at this moment would leave them.
fn crash(&self) -> MemStore {
let m = MemStore::new();
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
m
}
fn snapshot_seq_on_disk(&self) -> Option<u64> {
let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
v["seq"].as_u64()
}
}
impl Store for Gated {
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
self.files.read(name)
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
self.files.write_atomic(name, data)
}
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> std::io::Result<u64> {
if name == SNAPSHOT {
let (lock, cv) = &*self.gate;
let (open, timeout) = cv.wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(20), |open| !*open).unwrap();
if !*open || timeout.timed_out() {
return Err(std::io::Error::other("gate never opened"));
}
}
let mut buf = Vec::new();
fill(&mut buf)?;
if self.fail.load(Ordering::SeqCst) {
return Err(std::io::Error::other("disk full"));
}
self.files.write_atomic(name, &buf)?;
Ok(buf.len() as u64)
}
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
self.files.append(name, data)
}
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
self.files.truncate(name, len)
}
fn describe(&self) -> String {
"gated".into()
}
fn background_writer(&self) -> Option<Box<dyn Store>> {
Some(Box::new(self.clone()))
}
}
/// Apply and durably append one edit (two ops in one append every fourth time).
fn edit(j: &mut Journal, live: &mut Catalog, k: u64) {
let one = |live: &mut Catalog, k: u64| {
let op = if live.len() < 4 || k.is_multiple_of(3) {
let id = live.alloc_photo_id();
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "t")) }
} else {
Op::SetRating { id: PhotoId(1 + k % live.len() as u64), rating: (k % 6) as u8 }
};
live.apply(op.clone()).unwrap();
op
};
let mut ops = vec![one(live, k)];
if k.is_multiple_of(4) {
ops.push(one(live, k / 4 + 1));
}
j.append(&ops).unwrap();
}
/// Load the files a crash now would leave: they must hold exactly the live state.
fn assert_recovers(g: &Gated, live: &Catalog) -> LoadReport {
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
assert_eq!(c.to_snapshot(), live.to_snapshot(), "{r:?}");
assert_eq!((r.failed, r.torn_bytes, r.damaged.clone()), (0, 0, None));
r
}
fn wait_written(j: &Journal) {
for _ in 0..20_000 {
if j.snapshot_written() {
return;
}
std::thread::sleep(Duration::from_millis(1));
}
panic!("background snapshot never finished");
}
#[test]
fn background_snapshot_keeps_edits_made_while_it_runs() {
let g = Gated::new();
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
for k in 0..30 {
edit(&mut j, &mut live, k);
}
let n = j.seq();
j.snapshot_in_background(&live).unwrap();
assert!(j.snapshot_running() && !j.wants_snapshot() && j.stats().snapshot_running);
// edits while the snapshot is being written are appended (durably) as usual
let mut during = 0;
for k in 30..50 {
edit(&mut j, &mut live, k);
during = j.seq() - n;
}
// crash before the snapshot landed: no snapshot yet, the whole log replays
let r = assert_recovers(&g, &live);
assert_eq!((r.snapshot_seq, r.replayed as u64), (0, j.seq()));
// the snapshot lands (op n); crash before the log was trimmed: its records are stale
g.set_gate(true);
wait_written(&j);
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
for k in 50..55 {
edit(&mut j, &mut live, k);
during = j.seq() - n;
}
let r = assert_recovers(&g, &live);
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, during));
// finishing trims the log to exactly the records after the snapshot, in order
assert!(j.poll().unwrap());
assert!(!j.snapshot_running());
assert_eq!((j.snapshot_seq(), j.log_records()), (n, during));
let log = String::from_utf8(g.files.get(LOG).unwrap()).unwrap();
let seqs: Vec<u64> = log.lines().map(|l| journal::decode_record(l).unwrap().0).collect();
assert_eq!(seqs, ((n + 1)..=j.seq()).collect::<Vec<_>>());
assert_eq!(j.log_bytes(), log.len() as u64);
let r = assert_recovers(&g, &live);
assert_eq!((r.snapshot_seq, r.stale, r.replayed as u64), (n, 0, during));
let s = j.stats();
assert!(s.last_snapshot.background && s.last_snapshot.records == n && !s.snapshot_running, "{s:?}");
// and appending goes on after it
edit(&mut j, &mut live, 99);
assert_recovers(&g, &live);
}
#[test]
fn failed_background_snapshot_loses_nothing_and_retries_later() {
let g = Gated::new();
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
j.policy = SnapshotPolicy { max_records: 10, max_bytes: u64::MAX };
for k in 0..12 {
edit(&mut j, &mut live, k);
}
assert!(j.wants_snapshot());
g.fail.store(true, Ordering::SeqCst);
g.set_gate(true);
j.snapshot_in_background(&live).unwrap();
edit(&mut j, &mut live, 12);
wait_written(&j);
assert!(j.poll().is_err());
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
// still the (empty) snapshot written when the library was created
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
assert_recovers(&g, &live);
// no retry storm: the next attempt waits for another `max_records` records
assert!(!j.wants_snapshot());
g.fail.store(false, Ordering::SeqCst);
while !j.wants_snapshot() {
let k = j.seq();
edit(&mut j, &mut live, k);
}
j.snapshot_in_background(&live).unwrap();
j.wait().unwrap();
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
let r = assert_recovers(&g, &live);
assert_eq!((r.snapshot_seq, r.replayed), (j.seq(), 0));
}
/// A synchronous snapshot (close, "Optimize Library") waits for the background one, so the older
/// snapshot can't land after (and replace) the newer one.
#[test]
fn sync_snapshot_waits_for_the_background_one() {
let g = Gated::new();
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
for k in 0..10 {
edit(&mut j, &mut live, k);
}
j.snapshot_in_background(&live).unwrap();
for k in 10..20 {
edit(&mut j, &mut live, k);
}
let opener = {
let g = g.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
g.set_gate(true);
})
};
j.snapshot(&live).unwrap();
opener.join().unwrap();
assert!(!j.snapshot_running());
assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
assert!(g.files.get(LOG).unwrap().is_empty());
assert_eq!(j.stats().snapshots, 2);
assert_recovers(&g, &live);
}
/// Dropping the journal (e.g. reopening the library) waits for the snapshot in flight and leaves
/// the log whole: reopening finds every op.
#[test]
fn dropping_the_journal_waits_for_its_snapshot() {
let g = Gated::new();
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
for k in 0..10 {
edit(&mut j, &mut live, k);
}
let n = j.seq();
j.snapshot_in_background(&live).unwrap();
for k in 10..15 {
edit(&mut j, &mut live, k);
}
let opener = {
let g = g.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
g.set_gate(true);
})
};
let total = j.seq();
drop(j);
opener.join().unwrap();
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
let (_, c, r) = Journal::open(Box::new(g.files.clone())).unwrap();
assert_eq!(c.to_snapshot(), live.to_snapshot());
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, total - n));
}
/// On disk, with a real worker thread: a crash mid-write leaves only a temp file, which loading
/// ignores; edits during the snapshot survive; reopening resumes appends.
#[test]
fn fs_store_background_snapshot() {
let dir = std::env::temp_dir().join(format!("lc-journal-bg-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (mut j, mut live, _) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
for k in 0..200 {
edit(&mut j, &mut live, k);
}
// a previous crash mid-snapshot left a partial temp file
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
drop(j);
let n = j2.seq();
j2.snapshot_in_background(&live).unwrap();
for k in 200..260 {
edit(&mut j2, &mut live, k);
}
j2.wait().unwrap();
assert_eq!((j2.snapshot_seq(), j2.log_records()), (n, j2.seq() - n));
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
edit(&mut j2, &mut live, 999);
drop(j2);
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!(c3.to_snapshot(), live.to_snapshot());
assert_eq!((r.snapshot_seq, r.stale), (n, 0));
let _ = std::fs::remove_dir_all(&dir);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
/// Any interleaving of edits, background snapshots (started, landed, finished), synchronous
/// snapshots and crashes: a crash at any point recovers exactly the acknowledged ops.
#[test]
fn background_compaction_never_loses_acknowledged_ops(actions in proptest::collection::vec((0u8..8, 0u64..1000), 1..60)) {
let g = Gated::new();
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
for (ctl, k) in actions {
match ctl {
0..=2 => edit(&mut j, &mut live, k),
3 => j.snapshot_in_background(&live).unwrap(),
4 | 5 => {
// the worker writes its snapshot; finish it now or later
if j.snapshot_running() {
g.set_gate(true);
wait_written(&j);
g.set_gate(false);
if ctl == 4 {
prop_assert!(j.poll().unwrap());
}
}
}
6 => {
g.set_gate(true);
j.snapshot(&live).unwrap();
g.set_gate(false);
prop_assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
}
_ => {
// a crash now
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
prop_assert_eq!(r.failed, 0);
prop_assert!(r.damaged.is_none());
}
}
// the snapshot on disk never covers more than what was appended
prop_assert!(g.snapshot_seq_on_disk().unwrap_or(0) <= j.seq());
}
g.set_gate(true);
let (_, c, _) = Journal::open(Box::new(g.crash())).unwrap();
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
drop(j);
let (_, c, _) = Journal::open(Box::new(g.files.clone())).unwrap();
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
use crate::*;
fn photo(c: &mut Catalog) -> Op {
let id = c.alloc_photo_id();
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
}
/// A catalog with two photos and the log line for a third op on top of it.
fn legacy_parts() -> (Catalog, String, Catalog) {
let mut c = Catalog::new();
for _ in 0..2 {
let op = photo(&mut c);
c.apply(op).unwrap();
}
let base = c.clone();
let id = c.photos().next().unwrap().id;
let op = Op::SetRating { id, rating: 4 };
c.apply(op.clone()).unwrap();
(base, format!("{}\n", encode_record(3, &op)), c)
}
fn snapshot_version(m: &MemStore) -> u64 {
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
v["version"].as_u64().unwrap()
}
#[test]
fn v1_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
m.set(LOG, log.into_bytes());
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
// rewritten in this format before anything is appended
assert_eq!(snapshot_version(&m), u64::from(VERSION));
assert!(m.get(LOG).unwrap().is_empty());
assert_eq!(j.seq(), 3);
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
drop(j);
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
let op = photo(&mut c);
c.apply(op.clone()).unwrap();
let m = MemStore::new();
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
assert_eq!(r.upgraded_from, Some(0));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
/// it would read in part.
#[test]
fn new_library_is_written_in_this_format() {
let m = MemStore::new();
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
assert!(r.created);
assert_eq!(r.upgraded_from, None);
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
fn assert_refused_untouched(m: &MemStore) {
let before = m.files.lock().unwrap().clone();
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
let msg = e.to_string();
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
}
#[test]
fn newer_snapshot_is_refused_untouched() {
let (base, log, _) = legacy_parts();
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
);
m.set(LOG, log.into_bytes());
assert_refused_untouched(&m);
// even when its catalog doesn't parse as this version's
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
);
assert_refused_untouched(&m);
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
}
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
#[test]
fn unknown_op_in_the_log_is_refused_untouched() {
let (base, log, _) = legacy_parts();
let future = |seq: u64| {
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
};
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
let id = base.photos().next().unwrap().id;
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
let m = MemStore::new();
m.set(SNAPSHOT, snap.clone().into_bytes());
m.set(LOG, log.into_bytes());
assert_refused_untouched(&m);
}
// a known op with a field of an unknown shape is "newer" too
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
let m = MemStore::new();
m.set(SNAPSHOT, snap.into_bytes());
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
assert_refused_untouched(&m);
}
/// A CRC mismatch is still damage / a torn tail (not "newer").
#[test]
fn bad_crc_is_still_a_torn_tail() {
let (base, log, _) = legacy_parts();
let m = MemStore::new();
m.set(
SNAPSHOT,
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
);
let body = r#"{"op":"fromTheFuture"}"#;
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
}
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
/// variant here under the new version.
#[test]
fn op_variants_are_versioned() {
fn since(op: &Op) -> u32 {
match op {
Op::AddPhoto { .. }
| Op::RemovePhoto { .. }
| Op::SetRating { .. }
| Op::SetFlag { .. }
| Op::SetLabel { .. }
| Op::SetDevelop { .. }
| Op::SetMeta { .. }
| Op::SetDeleted { .. }
| Op::SetLocal { .. }
| Op::SetVersions { .. }
| Op::SetHistory { .. }
| Op::PushHistory { .. }
| Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetCaptured { .. }
| Op::SetAnalysis { .. }
| Op::SetFile { .. }
| Op::Relink { .. }
| Op::SetContent { .. }
| Op::SetLabelName { .. }
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
}
}
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");
}
+60 -1
View File
@@ -50,7 +50,16 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
5 => {
let id = c.alloc_album_id();
Op::AddAlbum {
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
album: Album {
id,
name: format!("A{b}"),
parent: None,
folder: b.is_multiple_of(4),
photos: vec![],
cover: None,
smart: None,
quick: false,
},
}
}
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
@@ -265,3 +274,53 @@ fn fs_store_roundtrip() {
assert_eq!(c3.to_snapshot(), c.to_snapshot());
let _ = std::fs::remove_dir_all(&dir);
}
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
#[test]
fn streamed_snapshot_keeps_the_on_disk_format() {
let (m, _, full) = sample_catalog_with_log();
let (mut j, c, _) = open(&m);
assert_eq!(c.to_snapshot(), full.to_snapshot());
j.snapshot(&c).unwrap();
let legacy = format!(
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n",
crate::journal::VERSION,
j.seq(),
c.to_snapshot()
);
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
let dir = std::env::temp_dir().join(format!("lc-journal-stream-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// an old-style snapshot (written by a build before streaming) loads, and is rewritten the same
std::fs::write(dir.join(SNAPSHOT), legacy.as_bytes()).unwrap();
let (mut fj, fc, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!((fc.to_snapshot(), r.snapshot_seq), (c.to_snapshot(), j.seq()));
fj.snapshot(&fc).unwrap();
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), legacy.as_bytes());
drop(fj);
let _ = std::fs::remove_dir_all(&dir);
}
/// A snapshot that fails while streaming (serialisation or I/O error) leaves the old file in
/// place and removes its temp file.
#[test]
fn failed_streamed_write_keeps_the_old_file() {
let dir = std::env::temp_dir().join(format!("lc-journal-streamfail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = FsStore::open(&dir).unwrap();
s.write_atomic(SNAPSHOT, b"old").unwrap();
let r = s.write_atomic_with(SNAPSHOT, &mut |w| {
w.write_all(b"partial new content")?;
Err(std::io::Error::other("serialisation failed"))
});
assert!(r.is_err());
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"old");
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
assert_eq!(s.write_atomic_with(SNAPSHOT, &mut |w| w.write_all(b"new")).unwrap(), 3);
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
+267
View File
@@ -0,0 +1,267 @@
//! Forgetting untouched Local browse records (see [`crate::local`]).
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use crate::*;
const OLD: &str = "2026-08-01T10:00:00";
const NOW: &str = "2026-10-05T10:00:00";
/// A Local record of `/pics/<folder>/IMG_<i>.JPG` as a browse catalogues it (with its baseline).
fn local(c: &mut Catalog, folder: &str, i: u32) -> PhotoId {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder}/IMG_{i}.JPG") }, &format!("IMG_{i}.JPG"), "JPEG", 60, 40, OLD);
p.meta.camera = "Cam".into();
p.meta.creator = "From EXIF".into(); // file metadata is part of the baseline, not a user change
p.focal_and_wb_for_test();
p.local = true;
p.set_local_baseline();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
}
impl Photo {
fn focal_and_wb_for_test(&mut self) {
self.meta.focal_mm = Some(35.3);
self.as_shot_wb = Some((5123.4, 3.1));
}
}
fn browsed(c: &mut Catalog, folder: &str, at: &str) {
c.apply(Op::SetBrowsed { folder: format!("/pics/{folder}"), at: Some(at.into()) }).unwrap();
}
fn plan(c: &Catalog, days: u32) -> ForgetPlan {
c.forget_local_plan(NOW, days, &|_| false)
}
fn run(c: &mut Catalog, days: u32) -> ForgetPlan {
let p = plan(c, days);
for op in p.ops() {
c.apply(op).unwrap();
}
p
}
#[test]
fn untouched_records_of_old_folders_are_forgotten() {
let mut c = Catalog::new();
let old: Vec<_> = (0..5).map(|i| local(&mut c, "old", i)).collect();
let recent: Vec<_> = (0..3).map(|i| local(&mut c, "recent", i)).collect();
browsed(&mut c, "old", OLD);
browsed(&mut c, "recent", "2026-10-01T09:00:00");
// a library photo in the old folder is never a candidate
let lib = c.alloc_photo_id();
let mut p = Photo::new(lib, Source::File { path: "/pics/old/LIB.JPG".into() }, "LIB.JPG", "JPEG", 6, 4, OLD);
p.set_local_baseline();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let dry = plan(&c, 30);
assert_eq!((dry.local, dry.evict.len(), dry.kept_recent), (8, 5, 3), "{dry:?}");
assert_eq!(dry.evict, old);
assert_eq!(c.len(), 9, "a plan changes nothing");
let done = run(&mut c, 30);
assert_eq!(done.evict.len(), 5);
assert!(old.iter().all(|id| c.photo(*id).is_none()));
assert!(recent.iter().all(|id| c.photo(*id).is_some()));
assert!(c.photo(lib).is_some());
// the folder has no Local records left: its time goes
assert_eq!(done.unstamp, vec!["/pics/old".to_string()]);
assert_eq!(c.last_browsed("/pics/old"), None);
assert_eq!(c.last_browsed("/pics/recent/"), Some("2026-10-01T09:00:00"));
// 0 days = never
browsed(&mut c, "recent", OLD);
assert!(plan(&c, 0).evict.is_empty());
assert_eq!(plan(&c, 30).evict.len(), 3);
}
#[test]
fn any_user_change_keeps_a_record() {
type Change = Box<dyn Fn(&mut Catalog, PhotoId)>;
let set = |f: fn(&mut Photo)| -> Change {
Box::new(move |c: &mut Catalog, id: PhotoId| {
let mut p = (**c.photo(id).unwrap()).clone();
f(&mut p);
c.apply(Op::RemovePhoto { id }).unwrap();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
})
};
let op = |f: fn(PhotoId) -> Op| -> Change { Box::new(move |c: &mut Catalog, id: PhotoId| drop(c.apply(f(id)).unwrap())) };
let edited = || {
let mut d = DevelopSettings::default();
d.light.exposure = 0.3;
Arc::new(d)
};
let changes: Vec<(&str, Change)> = vec![
("rating", op(|id| Op::SetRating { id, rating: 3 })),
("flag", op(|id| Op::SetFlag { id, flag: Flag::Reject })),
("label", op(|id| Op::SetLabel { id, label: Some(ColorLabel::Red) })),
("develop", Box::new(move |c, id| drop(c.apply(Op::SetDevelop { id, settings: edited(), label: "Exposure".into(), edited: None }).unwrap()))),
("crop", set(|p| Arc::make_mut(&mut p.develop).crop.flip_h = true)),
(
"history only",
Box::new(|c, id| {
let step = HistoryStep { label: "Reset".into(), settings: Arc::new(DevelopSettings::default()) };
drop(c.apply(Op::PushHistory { id, step }).unwrap())
}),
),
("versions", set(|p| p.versions.push(Version { name: "v".into(), created: NOW.into(), settings: p.develop.clone(), auto: false }))),
("title", set(|p| p.meta.title = "T".into())),
("caption", set(|p| p.meta.caption = "C".into())),
("keywords", set(|p| p.meta.keywords.push("k".into()))),
("location", set(|p| p.meta.city = "Oslo".into())),
("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))),
("copyright", set(|p| p.meta.copyright = "© me".into())),
("creator edit", set(|p| p.meta.creator = "Me".into())),
("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),
("rename", set(|p| p.file_name = "renamed.JPG".into())),
("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })),
("deleted", op(|id| Op::SetDeleted { id, deleted: true })),
("pick", op(|id| Op::SetFlag { id, flag: Flag::Pick })),
(
"album",
Box::new(|c, id| {
let a = c.alloc_album_id();
let mut al = Album::new(a, "A");
al.photos.push(id);
drop(c.apply(Op::AddAlbum { album: al }).unwrap())
}),
),
(
"stack",
Box::new(|c, id| {
let other = local(c, "elsewhere", 99);
let s = c.alloc_stack_id();
drop(c.apply(Op::AddStack { stack: Stack { id: s, photos: vec![id, other], collapsed: false } }).unwrap())
}),
),
(
"virtual copy",
Box::new(|c, id| {
let mut v = (**c.photo(id).unwrap()).clone();
v.id = c.alloc_photo_id();
v.copy_of = Some(id);
v.copy_name = Some("Copy 1".into());
v.local = false;
drop(c.apply(Op::AddPhoto { photo: Box::new(v) }).unwrap())
}),
),
];
for (what, change) in changes {
let mut c = Catalog::new();
let id = local(&mut c, "old", 1);
let control = local(&mut c, "old", 2);
browsed(&mut c, "old", OLD);
change(&mut c, id);
let p = plan(&c, 30);
assert_eq!(p.evict, vec![control], "{what}: {p:?}");
assert_eq!(p.kept_touched + p.kept_in_use, 1, "{what}: {p:?}");
// the stamp stays: a record in the folder remains
assert!(p.unstamp.is_empty(), "{what}");
}
// a change undone (back to the browse state) is untouched again
let mut c = Catalog::new();
let id = local(&mut c, "old", 1);
browsed(&mut c, "old", OLD);
let inv = c.apply(Op::SetRating { id, rating: 2 }).unwrap();
assert!(plan(&c, 30).evict.is_empty());
c.apply(inv).unwrap();
assert_eq!(plan(&c, 30).evict, vec![id]);
}
/// Catalogs from before last-browsed times (and baselines): nothing is forgotten on the first
/// run — the folders are stamped now — and baseline-less records are judged by their data.
#[test]
fn upgrade_without_times_forgets_nothing_then_counts_from_then() {
let mut c = Catalog::new();
let a = local(&mut c, "a", 1);
// legacy records: no baseline
let legacy = |c: &mut Catalog, i: u32, f: fn(&mut Photo)| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/pics/a/L{i}.JPG") }, "L.JPG", "JPEG", 6, 4, OLD);
p.meta.camera = "Cam".into();
p.local = true;
f(&mut p);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let clean = legacy(&mut c, 1, |_| {});
let rated = legacy(&mut c, 2, |p| p.rating = 1);
let with_creator = legacy(&mut c, 3, |p| p.meta.creator = "maybe the user".into());
let p = run(&mut c, 30);
assert!(p.evict.is_empty(), "{p:?}");
assert_eq!(p.stamp, vec!["/pics/a".to_string()]);
assert_eq!(c.last_browsed("/pics/a"), Some(NOW));
// 30+ days later
let later = c.forget_local_plan("2026-11-06T10:00:00", 30, &|_| false);
let mut evict = later.evict.clone();
evict.sort();
assert_eq!(evict, vec![a, clean]);
assert_eq!(later.kept_touched, 2, "{rated:?} {with_creator:?}");
}
#[test]
fn in_use_records_are_kept() {
let mut c = Catalog::new();
let a = local(&mut c, "old", 1);
let b = local(&mut c, "old", 2);
browsed(&mut c, "old", OLD);
let p = c.forget_local_plan(NOW, 30, &|p| p.id == a);
assert_eq!((p.evict.clone(), p.kept_in_use), (vec![b], 1));
}
/// The ops are journaled: a crash right after forgetting reloads the same catalog, and a
/// fingerprint survives the snapshot round trip (a reloaded record is still untouched).
#[test]
fn forgetting_is_journaled_and_replays() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let mut ops = Vec::new();
for i in 0..10 {
local(&mut c, if i % 2 == 0 { "old" } else { "new" }, i);
}
// log what was built so far: the ops are the records as added
for p in c.photos() {
ops.push(Op::AddPhoto { photo: Box::new((**p).clone()) });
}
ops.push(Op::SetBrowsed { folder: "/pics/old".into(), at: Some(OLD.into()) });
ops.push(Op::SetBrowsed { folder: "/pics/new".into(), at: Some(NOW.into()) });
browsed(&mut c, "old", OLD);
browsed(&mut c, "new", NOW);
j.append(&ops).unwrap();
j.snapshot(&c).unwrap();
drop(j);
// reload from the snapshot: baselines still match
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let p = c.forget_local_plan(NOW, 30, &|_| false);
assert_eq!(p.evict.len(), 5, "{p:?}");
let ops = p.ops();
for op in &ops {
c.apply(op.clone()).unwrap();
}
j.append(&ops).unwrap();
let expect = c.to_snapshot();
drop(j); // crash: replay the log
let (_, c2, r) = Journal::open(Box::new(m)).unwrap();
assert_eq!(r.failed, 0);
assert_eq!(c2.to_snapshot(), expect);
assert_eq!(c2.len(), 5);
// nothing left to forget
assert!(c2.forget_local_plan(NOW, 30, &|_| false).evict.is_empty());
}
#[test]
fn op_photo_ids_cover_nested_ops() {
let op = Op::Batch {
ops: vec![
Op::SetRating { id: PhotoId(1), rating: 1 },
Op::SetAlbumPhotos { id: AlbumId(1), photos: vec![PhotoId(2), PhotoId(3)] },
Op::Batch { ops: vec![Op::RemovePhoto { id: PhotoId(4) }] },
],
};
let mut ids = Vec::new();
op.photo_ids(&mut |id| ids.push(id.0));
assert_eq!(ids, vec![1, 2, 3, 4]);
}
+93
View File
@@ -0,0 +1,93 @@
//! Issue #99: one process per library.
use std::path::PathBuf;
use crate::lock::{LOCK, OWNER};
use crate::*;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn second_opener_is_refused_until_the_first_lets_go() {
let dir = temp_dir("twice");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(first.held());
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
assert_eq!(who.pid, std::process::id());
let msg = e.to_string();
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
drop(first);
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
assert!(again.held());
drop(again);
let _ = std::fs::remove_dir_all(&dir);
}
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
#[test]
fn leftover_lock_files_are_not_a_lock() {
let dir = temp_dir("stale");
std::fs::write(dir.join(LOCK), b"").unwrap();
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(l.held());
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
drop(l);
let _ = std::fs::remove_dir_all(&dir);
}
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
#[test]
fn lock_held_by_another_process_is_released_when_it_dies() {
let dir = temp_dir("child");
let exe = std::env::current_exe().unwrap();
let mut child = std::process::Command::new(exe)
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
.env("LC_LOCK_CHILD_DIR", &dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !dir.join("child-locked").exists() {
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
assert!(std::time::Instant::now() < deadline, "child never locked");
std::thread::sleep(std::time::Duration::from_millis(20));
}
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
assert_eq!(who.pid, child.id());
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
child.wait().unwrap();
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
assert!(l.held());
drop(l);
let _ = std::fs::remove_dir_all(&dir);
}
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
/// lock, say so, and wait to be killed.
#[test]
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
fn lock_holder_child() {
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
}
+223
View File
@@ -0,0 +1,223 @@
//! Issue #101: an append that fails part-way (disk full, a dropped network share) and is then
//! retried must not read back as log damage, and must lose nothing.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::journal::LOG;
use crate::*;
/// A store whose next append writes only a prefix of its data, then fails.
#[derive(Clone, Default)]
struct Torn {
files: MemStore,
/// The next append writes this many bytes, then returns an error.
torn_at: Arc<Mutex<Option<usize>>>,
/// `truncate` fails (the cut-back after a failed append can't be done right away).
truncate_fails: Arc<AtomicBool>,
}
impl Torn {
fn tear_next_append(&self, bytes: usize) {
*self.torn_at.lock().unwrap() = Some(bytes);
}
}
impl Store for Torn {
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
self.files.read(name)
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
self.files.write_atomic(name, data)
}
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
if let Some(n) = self.torn_at.lock().unwrap().take() {
self.files.append(name, &data[..n.min(data.len())])?;
return Err(std::io::Error::other("disk full"));
}
self.files.append(name, data)
}
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
if self.truncate_fails.load(Ordering::SeqCst) {
return Err(std::io::Error::other("share unavailable"));
}
self.files.truncate(name, len)
}
fn describe(&self) -> String {
"torn".into()
}
}
fn add_photo(c: &mut Catalog) -> Op {
let id = c.alloc_photo_id();
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
}
/// Apply `ops` to the live catalog (like a command) and return them for the journal.
fn apply(c: &mut Catalog, ops: Vec<Op>) -> Vec<Op> {
for op in &ops {
c.apply(op.clone()).unwrap();
}
ops
}
/// Every tear position of a batch, then the retry (same ops, same seqs, like the engine's queue)
/// and more appends: reloading replays everything, reports no damage and no torn bytes.
#[test]
fn partial_append_then_retry_loads_cleanly() {
let probe = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(probe.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let id = c.photos().next().unwrap().id;
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
let before = probe.get(LOG).unwrap().len();
j.append(&batch).unwrap();
let batch_len = probe.get(LOG).unwrap().len() - before;
for tear in 1..batch_len {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good_len = t.files.get(LOG).unwrap().len();
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
t.tear_next_append(tear);
assert!(j.append(&batch).is_err(), "tear at {tear}");
// cut back to the last whole record
assert_eq!(t.files.get(LOG).unwrap().len(), good_len, "tear at {tear}");
j.append(&batch).unwrap();
let more = apply(&mut c, vec![Op::SetLabel { id, label: Some(ColorLabel::ALL[0]) }]);
j.append(&more).unwrap();
drop(j);
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
assert_eq!((r.torn_bytes, r.torn_fragments, r.replayed), (0, 0, 5), "tear at {tear}");
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
}
}
/// When cutting back fails too (the share is gone), the next append cuts back first; until that
/// works nothing more is appended after the fragment.
#[test]
fn failed_cut_back_is_done_before_the_next_append() {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good_len = t.files.get(LOG).unwrap().len();
let id = c.photos().next().unwrap().id;
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetRating { id, rating: 5 }]);
t.truncate_fails.store(true, Ordering::SeqCst);
t.tear_next_append(30);
assert!(j.append(&batch).is_err());
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30, "the fragment is still there");
// still unreachable: the retry fails without writing anything after the fragment
assert!(j.append(&batch).is_err());
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30);
t.truncate_fails.store(false, Ordering::SeqCst);
j.append(&batch).unwrap();
drop(j);
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert!(r.damaged.is_none(), "{r:?}");
assert_eq!(r.replayed, 3);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// Logs written before the fix: the fragment of the failed append is glued to the first retried
/// record on one line, followed by the rest of the retry. They load in full (the fragment is
/// skipped), instead of stopping at the fragment and setting the later records aside as damage.
#[test]
fn legacy_fragment_glued_to_the_retry_is_skipped() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let good = m.get(LOG).unwrap();
let id = c.photos().next().unwrap().id;
let batch = apply(
&mut c,
vec![
Op::SetRating { id, rating: 3 },
Op::SetFlag { id, flag: Flag::Pick },
Op::SetLabelName { label: ColorLabel::ALL[1], name: Some("Größe ✓".into()) },
],
);
j.append(&batch).unwrap();
let full = m.get(LOG).unwrap();
let batch_bytes = &full[good.len()..];
let later = apply(&mut c, vec![Op::SetRating { id, rating: 1 }]);
j.append(&later).unwrap();
let later_bytes = m.get(LOG).unwrap()[full.len()..].to_vec();
drop(j);
// every tear (inside the first record, after it, inside a multi-byte character…)
for tear in 1..batch_bytes.len() {
if batch_bytes[tear - 1] == b'\n' {
continue; // a tear on a record boundary leaves no fragment
}
let mut log = good.clone();
log.extend_from_slice(&batch_bytes[..tear]);
log.extend_from_slice(batch_bytes);
log.extend_from_slice(&later_bytes);
let t = MemStore::new();
t.set(LOG, log);
let (_, loaded, r) = Journal::open(Box::new(t)).unwrap();
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
assert_eq!(r.torn_fragments, 1, "tear at {tear}");
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
}
// a damaged record whose tail happens to be a whole record from the future is still damage
let mut log = good.clone();
log.extend_from_slice(b"garbage");
log.extend_from_slice(&later_bytes);
log.extend_from_slice(&later_bytes);
let t = MemStore::new();
t.set(LOG, log);
let (_, _, r) = Journal::open(Box::new(t)).unwrap();
assert!(r.damaged.is_some(), "{r:?}");
}
/// A snapshot can absorb ops whose append failed: it records them as saved (`seq` moves past
/// them), and appending after it continues from there.
#[test]
fn snapshot_with_unlogged_ops_saves_them() {
let t = Torn::default();
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
let add = add_photo(&mut c);
let first = apply(&mut c, vec![add]);
j.append(&first).unwrap();
let id = c.photos().next().unwrap().id;
let queued = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetFlag { id, flag: Flag::Reject }]);
t.tear_next_append(10);
assert!(j.append(&queued).is_err());
j.snapshot_with_unlogged(&c, queued.len() as u64).unwrap();
assert_eq!(j.seq(), 3);
let after = apply(&mut c, vec![Op::SetRating { id, rating: 5 }]);
j.append(&after).unwrap();
drop(j);
let (j, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
assert_eq!((r.snapshot_seq, r.replayed), (3, 1));
assert_eq!(j.seq(), 4);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// `FsStore::truncate` on a file that was never created (an append that failed first) is a no-op.
#[test]
fn fs_store_truncates_a_missing_file_quietly() {
let dir = std::env::temp_dir().join(format!("lc-torn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = FsStore::open(&dir).unwrap();
s.truncate(LOG, 0).unwrap();
assert!(!dir.join(LOG).exists());
s.append(LOG, b"abc\n").unwrap();
s.truncate(LOG, 0).unwrap();
assert_eq!(std::fs::read(dir.join(LOG)).unwrap(), b"");
let _ = std::fs::remove_dir_all(&dir);
}
+3 -1
View File
@@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
}
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
let px = samples
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let k = 1.0 - c[3];
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
+2 -2
View File
@@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
let n = img.width as usize * img.height as usize;
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
};
@@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
}};
}
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
}
match img.samples {
Samples::U8(s) => match ch {
+1 -1
View File
@@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
for (s, d) in src_chunks.zip(dst_chunks) {
t.transform(s, d).ok()?;
}
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
};
std::panic::catch_unwind(run).ok().flatten()
}
+2 -2
View File
@@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
@@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
let Some(list) = t.bytes(e) else { return vec![] };
let mut out = Vec::new();
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
if i == 0 {
continue;
}
+1 -1
View File
@@ -274,7 +274,7 @@ pub fn encode(
{
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
};
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
let src = &data[y * width * channels..(y + 1) * width * channels];
+1
View File
@@ -12,6 +12,7 @@
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod encode;
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
};
buf.truncate(out.buffer_size());
let (samples, depth) = match out.bit_depth {
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
_ => (Buf::U8(buf), info.bit_depth as u8),
};
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
+8 -3
View File
@@ -9,6 +9,7 @@
//!
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cct;
pub mod perceptual;
@@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
impl RgbSpace {
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
/// Degenerate (collinear) primaries, which readers reject before building a space from a
/// file, leave the primaries unscaled instead of panicking.
pub fn to_xyz(&self) -> Mat3 {
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
m.mul(&Mat3::diag(s[0], s[1], s[2]))
}
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
pub fn from_xyz(&self) -> Mat3 {
self.to_xyz().inverse().expect("invertible")
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
}
/// Luminance weights (the Y row of `to_xyz`).
pub fn luma(&self) -> [f64; 3] {
@@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
}
let s = BRADFORD.apply(src.to_xyz());
let d = BRADFORD.apply(dst.to_xyz());
let inv = BRADFORD.inverse().expect("invertible");
// a constant, invertible matrix: the fallback (no adaptation) can't happen
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
}
+3 -2
View File
@@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
OkMats {
to_lms: to_lms.to_f32(),
from_lms: to_lms.inverse().expect("invertible").to_f32(),
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
to_lab: LMS_TO_LAB.to_f32(),
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
}
})
}
+1
View File
@@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
miniz_oxide = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+4
View File
@@ -4,13 +4,16 @@
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod controls;
pub mod presets;
pub mod segmask;
pub mod settings;
pub use controls::{CONTROLS, ControlSpec, Section, Track};
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
pub use segmask::SegMask;
pub use settings::*;
use serde_json::Value;
@@ -105,6 +108,7 @@ mod tests {
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial {
+22 -1
View File
@@ -208,8 +208,29 @@ impl Preset {
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
}
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
/// already has are skipped), their effect scaled by the amount.
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
apply_partial(s, &self.settings, amount)
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
return apply_partial(s, &self.settings, amount);
};
let mut rest = self.settings.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
out
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Model segmentations stored with a mask ([`SegMask`]), so rendering never needs the model.
use serde::{Deserialize, Serialize};
/// Logits are stored in steps of 1/`SCALE`, clamped to ±127/`SCALE` (≈ ±15.9: a sigmoid of
/// 1 − 1.2e-7, i.e. fully in or out).
const SCALE: f32 = 8.0;
/// Largest grid a stored segmentation may claim (hostile input caps the allocation).
pub const MAX_SIDE: usize = 1024;
/// A segmentation computed by a model (SAM 3 in `lightcraft-segment`): `side × side` logits
/// over the uncropped, oriented image stretched to a square (row `y`, column `x` covers
/// normalized `((x + 0.5) / side, (y + 0.5) / side)`), quantized to `i8`, deflated and base64
/// encoded. Coverage at a point is the sigmoid of the bilinearly sampled logit, so edges stay
/// smooth at any output size.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SegMask {
pub side: u32,
pub data: String,
/// The part of the image the grid covers, normalized `[x0, y0, x1, y1]` (`None` = all of
/// it). A zoomed-in pass over one object covers just its surroundings, at a higher
/// resolution than a pass over the whole photo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rect: Option<[f64; 4]>,
}
impl SegMask {
/// From `side × side` logits over the whole image.
pub fn from_logits(side: usize, logits: &[f32]) -> SegMask {
let q: Vec<u8> = logits.iter().take(side * side).map(|l| ((l * SCALE).round().clamp(-127.0, 127.0) as i8) as u8).collect();
let z = miniz_oxide::deflate::compress_to_vec(&q, 8);
SegMask { side: side as u32, data: base64_encode(&z), rect: None }
}
/// From `side × side` logits over the part `rect` of the image.
pub fn from_logits_in(side: usize, logits: &[f32], rect: [f64; 4]) -> SegMask {
SegMask { rect: Some(rect), ..SegMask::from_logits(side, logits) }
}
/// The covered part, normalized (the whole image without a `rect`); `None` when damaged.
pub fn bounds(&self) -> Option<[f64; 4]> {
let r = self.rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
}
/// The logits, or `None` when the data is damaged or claims an absurd size.
pub fn logits(&self) -> Option<Vec<f32>> {
let side = self.side as usize;
if side == 0 || side > MAX_SIDE {
return None;
}
let z = base64_decode(&self.data)?;
let q = miniz_oxide::inflate::decompress_to_vec_with_limit(&z, side * side).ok()?;
(q.len() == side * side).then(|| q.iter().map(|b| f32::from(*b as i8) / SCALE).collect())
}
}
/// Most zoomed-in detail patches one AI mask component keeps (the app makes one; each costs a
/// decode per render, so a hostile document can't make rendering explode).
pub const MAX_DETAIL: usize = 4;
/// Deserialize a component's detail patches, keeping at most [`MAX_DETAIL`] (the rest are
/// skipped without being decoded).
pub(crate) fn de_detail<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<SegMask>, D::Error> {
struct Capped;
impl<'de> serde::de::Visitor<'de> for Capped {
type Value = Vec<SegMask>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of segmentations")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<SegMask>, A::Error> {
let mut out = Vec::new();
while out.len() < MAX_DETAIL {
match seq.next_element::<SegMask>()? {
Some(m) => out.push(m),
None => return Ok(out),
}
}
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(out)
}
}
d.deserialize_seq(Capped)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for i in 0..4 {
if i <= chunk.len() {
out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let (mut acc, mut bits) = (0u32, 0u32);
for c in s.bytes().filter(|c| !c.is_ascii_whitespace()) {
if c == b'=' {
break;
}
let v = B64.iter().position(|b| *b == c)? as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_round_trips() {
for n in 0..10 {
let v: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
assert_eq!(base64_decode(&base64_encode(&v)).unwrap(), v);
}
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(base64_encode(b"Ma"), "TWE=");
assert!(base64_decode("TW!u").is_none());
}
#[test]
fn logits_round_trip_quantized() {
let side = 16;
let l: Vec<f32> = (0..side * side).map(|i| (i as f32 - 128.0) / 4.0).collect();
let m = SegMask::from_logits(side, &l);
let back = m.logits().unwrap();
for (a, b) in l.iter().zip(&back) {
assert!((a.clamp(-127.0 / SCALE, 127.0 / SCALE) - b).abs() <= 0.5 / SCALE + 1e-6);
}
}
#[test]
fn a_hostile_document_keeps_at_most_max_detail_patches() {
let patch = serde_json::to_value(SegMask::from_logits(4, &[1.0; 16])).unwrap();
let shape = serde_json::json!({"kind": "object", "hint": [{"x": 0.5, "y": 0.5}], "detail": vec![patch; 10_000]});
let s: crate::MaskShape = serde_json::from_value(shape).unwrap();
let crate::MaskShape::Object { detail, .. } = s else { panic!("an Object") };
assert_eq!(detail.len(), MAX_DETAIL);
// and a whole develop document with them still loads
let mut d = crate::DevelopSettings::default();
d.masks.push(crate::Mask {
id: 1,
components: vec![crate::MaskComponent {
name: None,
op: crate::MaskOp::Add,
invert: false,
shape: crate::MaskShape::Prompt { text: "x".into(), seg: None, detail: vec![SegMask::from_logits(4, &[1.0; 16]); 50], edge: 0.0 },
}],
..Default::default()
});
let back: crate::DevelopSettings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
let crate::MaskShape::Prompt { detail, .. } = &back.masks[0].components[0].shape else { panic!("a Prompt") };
assert_eq!(detail.len(), MAX_DETAIL);
}
#[test]
fn damaged_data_is_none() {
let mut m = SegMask::from_logits(8, &[1.0; 64]);
m.side = 9;
assert!(m.logits().is_none(), "size mismatch");
m.side = 100_000;
assert!(m.logits().is_none(), "absurd size");
assert!(SegMask { side: 8, data: "not base64 !".into(), rect: None }.logits().is_none());
assert!(SegMask { rect: Some([0.5, 0.0, 0.2, 1.0]), ..SegMask::from_logits(2, &[0.0; 4]) }.bounds().is_none());
}
}
+36 -1
View File
@@ -772,8 +772,33 @@ pub enum MaskShape {
Subject,
Sky,
Background,
/// One object picked by clicks (SAM 3 point prompts): `hint` holds the clicks that include,
/// `exclude` the ones that exclude; `seg` the segmentation computed from them.
Object {
hint: Vec<Point>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
exclude: Vec<Point>,
#[serde(default, skip_serializing_if = "Option::is_none")]
seg: Option<crate::SegMask>,
/// Zoomed-in passes over parts of the image (higher resolution than `seg`), used
/// inside their rectangles.
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
detail: Vec<crate::SegMask>,
/// Edge −100..100: below 0 harder (a steeper transition), above 0 softer (feathered).
#[serde(default, skip_serializing_if = "is_zero")]
edge: f64,
},
/// Everything a description names ("sky", "the red car": SAM 3 concept prompts); `seg` is
/// the segmentation computed for it.
Prompt {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
seg: Option<crate::SegMask>,
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
detail: Vec<crate::SegMask>,
/// Edge −100..100, as for `Object`.
#[serde(default, skip_serializing_if = "is_zero")]
edge: f64,
},
People {
person: u32,
@@ -786,6 +811,9 @@ pub enum MaskShape {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskComponent {
/// A name given in the Masking panel (Rename); `None` shows the shape's kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub op: MaskOp,
#[serde(default)]
@@ -802,11 +830,18 @@ pub struct Mask {
pub invert: bool,
pub components: Vec<MaskComponent>,
pub adjust: LocalAdjustments,
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
#[serde(skip_serializing_if = "is_zero")]
pub refine: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl Default for Mask {
fn default() -> Self {
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
}
}
+7
View File
@@ -17,10 +17,12 @@ lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-gpu = { workspace = true }
lightcraft-catalog = { workspace = true }
web-time = { workspace = true }
lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
@@ -28,6 +30,11 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }
lightcraft-segment = { workspace = true, optional = true }
[features]
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
sam = ["dep:lightcraft-segment"]
[dev-dependencies]
libc = { workspace = true }
+59
View File
@@ -0,0 +1,59 @@
//! Optional build input: with `CRAFT_FONTS_DIR=<storytold/craft-fonts checkout>`, embed every font
//! in its `fonts/manifest.txt` as `CRAFT_FONTS` (see src/fonts.rs). Unset, `CRAFT_FONTS` is empty.
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
//! script only reads a local directory (no network).
//!
//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
//! watermark falls back to it there. Native builds embed every font.
use std::fmt::Write as _;
use std::path::PathBuf;
fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
}
src.push_str("];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
let _ = writeln!(
out,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
scripts.join(", "),
path.display().to_string(),
);
}
Ok(out)
}
+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);
}
}
+777
View File
@@ -0,0 +1,777 @@
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its
//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
//! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels.
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_2020};
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit},
};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
pub matrix: Mat3,
pub tone: CameraTone,
/// Hue/saturation correction after `matrix`, in linear ProPhoto RGB (DNG `ProfileHueSatMap`).
pub hue_sat: Option<HsvTable>,
}
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
/// below were set at this size).
const PROXY: usize = 96;
/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples.
pub(crate) const PROFILE_PROXY: usize = 192;
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return None;
}
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
// A camera profile pooled from many photos knows colours this photo shows too little of;
// only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = fit_pairs_with(&sensor, &reference, colour)?;
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
raw.format,
look.matrix.0,
look.tone,
look.hue_sat.is_some(),
profile.is_some()
);
}
Some(look)
}
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let edge = (2 * size).max(384) as u32;
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
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 None;
}
let aspect = crop.width as f64 / crop.height as f64;
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 {
return None;
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
let sensor = raw.develop_binned(k, 0.99).ok()??;
let mut sensor = fit(&sensor, size, size, Filter::Box);
let reference = fit(&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)));
Some((sensor, reference))
}
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
/// colour matrices, other files or unusable previews.
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
let to_camera = transform.matrix.inverse()?;
let (pairs, _) = collect_pairs(&sensor, &reference)?;
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
}
/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table)
/// fitted to pairs pooled from many photos.
pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option<HsvTable>)> {
let matrix = fit_matrix(pairs)?;
Some((matrix, fit_hue_sat(pairs, &matrix)))
}
/// 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
}
/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`).
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];
}
let o = f64::from(tone.apply(y as f32));
let k = f64::from(tone.chroma_scale(o as f32));
scene.map(|v| o + (v * o / y - o) * k)
}
#[cfg(test)]
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
fit_pairs_with(sensor, reference, None)
}
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
/// (for the chroma curve); `None` when too few, or the photo has too little colour.
type Pairs = Vec<([f64; 3], [f64; 3])>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
let mut pairs = Vec::new();
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
let mut bright = 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.0) {
continue;
}
if (0.015..=1.0).contains(&y) {
bright.push((input.map(f64::from), output.map(f64::from)));
}
if !output.iter().all(|v| *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)));
}
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
}
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
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));
if lx <= 0.0 || ly <= 0.0 {
continue;
}
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 None;
}
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()?);
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
}
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
/// and chroma curve fitted to this photo.
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
let (pairs, bright) = collect_pairs(sensor, reference)?;
let candidates = match colour {
Some(given) => vec![given],
None => {
let matrix = fit_matrix(&pairs)?;
vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)]
}
};
// Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by
// error in gamma-encoded display values (closer to what is seen: in linear values a slightly
// missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear.
let mut best: Option<(f64, f64, usize, CameraLook)> = None;
for (matrix, hue_sat) in candidates {
let correction = hue_sat.as_ref().and_then(HueSat::new);
let colour = |x: [f64; 3]| {
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let Some(curve) = fit_tone(tone_pairs) else { continue };
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
if let Some(tone) = fit_chroma(&bright, &look) {
look.tone = tone;
}
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
let corrected = displayed(colour(*x), &tone);
for c in 0..3 {
linear += (corrected[c] - target[c]).powi(2);
perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2);
samples += 1;
}
}
if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) {
best = Some((perceptual, linear, samples, look));
}
}
let (_, after, samples, look) = best?;
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
let before: f64 = pairs
.iter()
.step_by(3)
.map(|(x, target)| {
let original = displayed(*x, &original_tone);
(0..3).map(|c| (original[c] - target[c]).powi(2)).sum::<f64>()
})
.sum();
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 samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
return None;
}
Some(look)
}
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
fn to_prophoto() -> Mat3 {
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
}
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
pub(crate) struct HueSat<'a> {
table: &'a HsvTable,
to: [[f32; 3]; 3],
from: [[f32; 3]; 3],
}
impl<'a> HueSat<'a> {
pub fn new(table: &'a HsvTable) -> Option<HueSat<'a>> {
let to = to_prophoto();
Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() })
}
#[inline]
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
}
}
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> {
let max = p[0].max(p[1]).max(p[2]);
let min = p[0].min(p[1]).min(p[2]);
if !(max.is_finite() && min.is_finite()) || max <= 0.0 {
return None;
}
let d = max - min;
if d <= 0.0 {
return Some((0.0, 0.0));
}
let h = if max == p[0] {
((p[1] - p[2]) / d).rem_euclid(6.0)
} else if max == p[1] {
(p[2] - p[0]) / d + 2.0
} else {
(p[0] - p[1]) / d + 4.0
};
Some((h * 60.0, d / max))
}
/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns
/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately.
const TABLE_HUES: usize = 72;
const TABLE_SATS: usize = 5;
/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward
/// orange but bright yellow-greens toward green, which one shift per hue averages away.
const TABLE_VALS: usize = 5;
/// Kernel widths around each table node (hue in degrees, saturation, encoded value).
const KERNEL_HUE: f64 = 2.5;
const KERNEL_SAT: f64 = 0.15;
const KERNEL_VAL: f64 = 0.12;
/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity.
const SHRINK_WEIGHT: f64 = 5.0;
/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue,
/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples.
/// Saturation 0 stays identity, so neutrals are never tinted.
fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable> {
let to = to_prophoto();
let samples: Vec<(f64, f64, f64, f64, f64)> = pairs
.iter()
.enumerate()
.filter(|(i, _)| i % 3 != 0)
.filter_map(|(_, (x, y))| {
let p = to.apply(matrix.apply(*x));
let (hp, sp) = hue_saturation(p)?;
let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32));
let (ht, st) = hue_saturation(to.apply(*y))?;
// hue is meaningless near neutral
if sp < 0.08 || st < 0.02 {
return None;
}
let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0);
let log_scale = (st / sp).ln().clamp(-0.7, 0.7);
Some((hp, sp.min(1.0), value, shift, log_scale))
})
.collect();
if samples.len() < 64 {
return None;
}
// Samples by table hue step: a node only looks at hues within its kernel's reach.
let step = 360.0 / TABLE_HUES as f64;
let mut by_hue: Vec<Vec<(f64, f64, f64, f64, f64)>> = vec![Vec::new(); TABLE_HUES];
for sample in samples {
if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) {
bin.push(sample);
}
}
let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1;
use rayon::prelude::*;
let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS)
.into_par_iter()
.map(|index| {
let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS);
if s == 0 {
return [0.0, 1.0, 1.0];
}
let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64);
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
for offset in 0..=2 * reach {
let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue };
for &(hp, sp, vp, dh, ls) in bin {
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
if dhue.abs() > 4.0 * KERNEL_HUE {
continue;
}
let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2);
let k = (-0.5 * d2).exp() * sp;
weight += k;
shift += k * dh;
log_scale += k * ls;
}
}
if weight <= 0.0 {
return [0.0, 1.0, 1.0];
}
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
[(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0]
})
.collect();
data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable {
hue_divisions: TABLE_HUES,
sat_divisions: TABLE_SATS,
val_divisions: TABLE_VALS,
data,
srgb_value: true,
})
}
/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node
/// keeps half of its estimate.
const CHROMA_KERNEL: f64 = 0.08;
const CHROMA_SHRINK: f64 = 2.0;
/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds
/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as
/// the distance from neutral of luminance-normalised RGB.
fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<CameraTone> {
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let scene = |x: &[f64; 3]| {
let p = look.matrix.apply(*x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let predict = |x: &[f64; 3]| displayed(scene(x), &tone);
let chroma = |p: [f64; 3]| {
let y = luma(p);
(y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt())
};
let samples: Vec<(f64, f64, f64)> = pairs
.iter()
.enumerate()
.filter(|(i, _)| i % 3 != 0)
.filter_map(|(_, (x, y))| {
let p = predict(x);
let (cp, cy) = (chroma(p)?, chroma(*y)?);
(cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln())))
})
.collect();
if samples.len() < 64 {
return None;
}
let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N];
for (j, node) in curve.iter_mut().enumerate() {
let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64;
let (mut weight, mut sum) = (0.0, 0.0);
for &(o, cp, log_ratio) in &samples {
let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp;
weight += k;
sum += k * log_ratio;
}
*node = (sum / (weight + CHROMA_SHRINK)).exp() as f32;
}
let fitted = look.tone.with_chroma(curve)?;
let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0);
let error = |map: &ToneMap| -> f64 {
pairs
.iter()
.step_by(3)
.map(|(x, y)| {
let p = displayed(scene(x), map);
(0..3).map(|c| (p[c] - y[c]).powi(2)).sum::<f64>()
})
.sum()
};
let (before, after) = (error(&tone), error(&with));
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}");
}
(after.is_finite() && after < before).then_some(fitted)
}
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());
}
/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime
/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most
/// of the gap, keep neutrals neutral and keep luminance.
#[test]
fn hue_table_follows_a_hue_dependent_camera_rendering() {
let to = to_prophoto();
let from = to.inverse().unwrap();
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
let mut lime = Vec::new();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 29) as f32 * 0.02;
// mostly greys and mild colours, plus a patch of saturated yellow-green
*src = if i % 7 == 0 {
[ev * 0.75, ev, ev * 0.2]
} else {
[ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)]
};
let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap();
// the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation
let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0);
let mut p = to.apply(src.map(f64::from)).map(|v| v as f32);
let table = HsvTable {
hue_divisions: 1,
sat_divisions: 2,
val_divisions: 1,
data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]],
srgb_value: false,
};
p = table.apply(p);
let p = from.apply(p.map(f64::from)).map(|v| v as f32);
let scale = luminance_2020(*src) / luminance_2020(p);
let p = p.map(|v| v * scale);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
if i % 7 == 0 {
lime.push(i);
}
}
let fit = fit_pairs(&sensor, &reference).unwrap();
let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted");
let hue_sat = HueSat::new(table).unwrap();
let hue_error = |with_table: bool| {
lime.iter()
.map(|&i| {
let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32);
let p = if with_table { hue_sat.apply(p) } else { p };
let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap();
let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap();
((h - t + 180.0).rem_euclid(360.0) - 180.0).abs()
})
.sum::<f64>()
/ lime.len() as f64
};
let (before, after) = (hue_error(false), hue_error(true));
assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°");
// neutrals pass through and luminance is kept
let grey = [0.3, 0.3, 0.3];
assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey));
let green = [0.2, 0.4, 0.05];
assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5);
}
/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve),
/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright
/// warm-tinted rock renders white instead of cream.
#[test]
fn chroma_curve_follows_highlight_bleaching() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let camera_chroma = |o: f32| 1.3 - 1.1 * o;
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.02 + (i % 37) as f32 * 0.03;
*src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
let o = 1.0 - (-2.5 * y).exp();
let k = camera_chroma(o);
*dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999));
}
let fit = fit_pairs(&sensor, &reference).unwrap();
// relative to the matrix, which already carries the average colourfulness
let chroma = fit.tone.chroma();
assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}");
// and the rendered highlights land on the camera's, far closer than without the curve
let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0);
let highlight_error = |map: &ToneMap| -> f64 {
sensor
.data
.iter()
.zip(&reference.data)
.filter(|(_, y)| luminance_2020(**y) > 0.7)
.map(|(x, y)| {
let p = displayed(fit.matrix.apply(x.map(f64::from)), map);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>()
})
.sum()
};
let (a, b) = (highlight_error(&without), highlight_error(&with));
assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}");
}
/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that
/// colour model (a camera profile) keeps it, fitting only its own tone.
#[test]
fn profile_colour_is_pooled_and_then_used_as_given() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let photo = |seed: usize, strength: f32| {
let mut sensor = Rgb32f::new(64, 48);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let i = i + seed * 7;
let ev = 0.04 + (i % 23) as f32 * 0.02;
*src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
// each photo has its own tone (DRO, picture style)
*dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y);
}
(sensor, reference)
};
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
pool.extend(collect_pairs(&sensor, &reference).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] {
let (a, b) = (matrix.apply(x), known.apply(x));
let (la, lb) = (luma(a), luma(b));
assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}");
}
let (sensor, reference) = photo(9, 3.5);
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
assert_eq!(look.matrix, matrix);
assert!(look.hue_sat.is_none());
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(look.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.02, "this photo's own tone is followed: RMS {}", error.sqrt());
}
#[test]
fn hue_sat_passes_black_and_non_finite_pixels_through() {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]);
let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]);
assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1);
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn sony_and_nikon_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
/// balance is relative to the as-shot look.
#[test]
fn corpus_nef_gets_a_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d7500-lossless14.nef");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
#[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());
}
}
+310
View File
@@ -0,0 +1,310 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
//! table; photos of that model then only fit their tone and chroma curves.
//!
//! Profiles are JSON files (`<model>.json`) in [`dir`]: `LIGHTCRAFT_CAMERA_PROFILES`, else
//! `<config>/camera-profiles`; a local profile replaces the one built in ([`BUNDLED`], from
//! `assets/camera-profiles/`). They are read once per process; a damaged or hostile file is
//! ignored with a warning. They hold aggregate colour statistics only, never image content.
use lightcraft_color::Mat3;
use lightcraft_raw::profile::HsvTable;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
/// Format version of the profile files.
const VERSION: u32 = 1;
/// Largest profile file read (a 5 × 72 × 5 table is ~60 KB of JSON).
const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CameraProfile {
pub version: u32,
/// The camera model as the files report it (Exif `Model`), e.g. `ILCE-7M4`.
pub model: String,
/// Photos and colour pairs the profile was fitted on.
pub files: usize,
pub samples: usize,
/// White-balanced camera RGB (with the baseline exposure) → linear Rec.2020, rows.
matrix: [[f64; 3]; 3],
/// Hue/saturation/value correction after `matrix` (linear ProPhoto RGB).
pub hue_sat: Option<HsvTable>,
}
impl CameraProfile {
pub fn new(model: &str, files: usize, samples: usize, matrix: Mat3, hue_sat: Option<HsvTable>) -> CameraProfile {
CameraProfile { version: VERSION, model: model.to_owned(), files, samples, matrix: matrix.0, hue_sat }
}
pub fn matrix(&self) -> Mat3 {
Mat3(self.matrix)
}
/// Whether the data is usable (bounded matrix, table shape matching its data).
fn valid(&self) -> bool {
let matrix = self.matrix.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) && Mat3(self.matrix).inverse().is_some();
let table = self.hue_sat.as_ref().is_none_or(|t| {
let dims = (1..=4096).contains(&t.hue_divisions) && (2..=4096).contains(&t.sat_divisions) && (1..=4096).contains(&t.val_divisions);
let len = t.hue_divisions.checked_mul(t.sat_divisions).and_then(|n| n.checked_mul(t.val_divisions));
dims && len == Some(t.data.len()) && t.data.iter().flatten().all(|v| v.is_finite()) && t.data.iter().all(|e| e[1] >= 0.0 && e[2] >= 0.0)
});
self.version == VERSION && !self.model.is_empty() && matrix && table
}
}
/// LightCraft's configuration folder (settings, GPU marker, camera profiles).
pub fn config_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
}
}
/// Where camera profiles are read from and written to.
pub fn dir() -> Option<PathBuf> {
std::env::var_os("LIGHTCRAFT_CAMERA_PROFILES").map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("camera-profiles")))
}
/// File name of `model`'s profile: letters, digits, `-` and `_` kept, anything else `_`.
pub fn file_name(model: &str) -> String {
let name: String = model.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
format!("{name}.json")
}
/// Read and validate a profile file.
pub fn load(path: &Path) -> Result<CameraProfile, String> {
let size = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.len();
if size > MAX_FILE {
return Err(format!("{}: {size} bytes is too large for a camera profile", path.display()));
}
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&bytes, &path.display().to_string())
}
/// Parse and validate a profile's JSON (`origin` names it in errors).
fn parse(json: &[u8], origin: &str) -> Result<CameraProfile, String> {
let profile: CameraProfile = serde_json::from_slice(json).map_err(|e| format!("{origin}: {e}"))?;
if !profile.valid() {
return Err(format!("{origin}: not a usable camera profile (version {}, model {:?})", profile.version, profile.model));
}
Ok(profile)
}
/// The built-in profile for `model`, if any.
fn bundled(model: &str) -> Option<CameraProfile> {
let (_, json) = BUNDLED.iter().find(|(m, _)| *m == model)?;
parse(json.as_bytes(), &format!("built-in profile {model}")).inspect_err(|e| eprintln!("lightcraft: ignoring camera profile {e}")).ok()
}
/// Write `profile` to `dir` (created if needed) as `<model>.json`; returns the path.
pub fn save(profile: &CameraProfile, dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(file_name(&profile.model));
let json = serde_json::to_vec_pretty(profile).map_err(|e| e.to_string())?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
fn cache() -> &'static Mutex<HashMap<String, Option<Arc<CameraProfile>>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<Arc<CameraProfile>>>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// The profile for camera `model`: from [`dir`], else built in; read once per process.
pub fn get(model: &str) -> Option<Arc<CameraProfile>> {
let model = model.trim();
if model.is_empty() {
return None;
}
let mut cache = cache().lock().unwrap_or_else(PoisonError::into_inner);
if let Some(hit) = cache.get(model) {
return hit.clone();
}
let path = dir().map(|d| d.join(file_name(model)));
let profile = match path {
Some(path) if path.is_file() => match load(&path) {
Ok(p) if p.model.trim() == model => Some(Arc::new(p)),
Ok(p) => {
eprintln!("lightcraft: ignoring camera profile {}: it is for {:?}, not {model:?}", path.display(), p.model);
None
}
Err(e) => {
eprintln!("lightcraft: ignoring camera profile {e}");
None
}
},
_ => None,
};
let profile = profile.or_else(|| bundled(model).map(Arc::new));
cache.insert(model.to_owned(), profile.clone());
profile
}
/// Changes when the profiles folder's contents change (per process): part of the render cache
/// keys, so thumbnails rendered before a profile existed are not reused after.
pub fn cache_key() -> u64 {
static KEY: OnceLock<u64> = OnceLock::new();
*KEY.get_or_init(|| {
let entries = dir().and_then(|d| std::fs::read_dir(d).ok());
let mut files: Vec<(String, u64, u64)> = entries
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let meta = e.metadata().ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
})
.collect();
files.sort();
let mut h = lightcraft_preview::Hasher128::new();
for (name, len, modified) in &files {
h.str(name).u64(*len).u64(*modified);
}
for (model, json) in BUNDLED {
h.str(model).str(json);
}
h.finish().0 as u64
})
}
/// Per camera model: the photos read and their pooled colour pairs.
#[derive(Default)]
pub struct Pool {
models: HashMap<String, (usize, Vec<([f64; 3], [f64; 3])>)>,
}
/// Most colour pairs kept per photo, so a few busy photos can't dominate a profile.
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
/// NEF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
let entry = self.models.entry(model.to_owned()).or_default();
entry.0 += 1;
entry.1.extend(pairs.into_iter().step_by(step));
Ok(Some(model.to_owned()))
}
/// Fit a profile per model with at least `min_files` photos.
pub fn fit(&self, min_files: usize) -> Vec<Result<CameraProfile, String>> {
let mut models: Vec<_> = self.models.iter().collect();
models.sort_by(|a, b| a.0.cmp(b.0));
models
.into_iter()
.filter(|(_, (files, _))| *files >= min_files)
.map(|(model, (files, pairs))| {
let (matrix, hue_sat) = crate::camera_preview::fit_profile(pairs).ok_or_else(|| format!("{model}: no usable colour fit"))?;
Ok(CameraProfile::new(model, *files, pairs.len(), matrix, hue_sat))
})
.collect()
}
/// Add another pool's photos and pairs (pools filled on separate threads).
pub fn merge(&mut self, other: Pool) {
for (model, (files, pairs)) in other.models {
let entry = self.models.entry(model).or_default();
entry.0 += files;
entry.1.extend(pairs);
}
}
/// Photos read per model.
pub fn files(&self) -> Vec<(String, usize)> {
let mut v: Vec<_> = self.models.iter().map(|(m, (n, _))| (m.clone(), *n)).collect();
v.sort();
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profile() -> CameraProfile {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.2, 1.0]; 8], srgb_value: false };
CameraProfile::new("ILCE-7M4", 12, 3456, Mat3([[1.6, -0.5, -0.1], [-0.2, 1.3, -0.1], [-0.1, -0.2, 1.3]]), Some(table))
}
#[test]
fn profiles_round_trip_and_reject_bad_files() {
let dir = std::env::temp_dir().join(format!("lc-camera-profiles-{}", std::process::id()));
let path = save(&profile(), &dir).unwrap();
assert_eq!(path.file_name().unwrap(), "ILCE-7M4.json");
assert_eq!(load(&path).unwrap(), profile());
let bad = |p: CameraProfile| {
let path = dir.join("bad.json");
std::fs::write(&path, serde_json::to_vec(&p).unwrap()).unwrap();
load(&path).is_err()
};
let mut p = profile();
p.matrix[0][0] = f64::NAN;
assert!(bad(p), "non-finite matrix");
let mut p = profile();
p.matrix = [[0.0; 3]; 3];
assert!(bad(p), "singular matrix");
let mut p = profile();
if let Some(t) = p.hue_sat.as_mut() {
t.data.truncate(3);
}
assert!(bad(p), "table data doesn't match its shape");
let mut p = profile();
p.version = 99;
assert!(bad(p), "unknown version");
std::fs::write(dir.join("junk.json"), b"{not json").unwrap();
assert!(load(&dir.join("junk.json")).is_err());
assert!(load(&dir.join("missing.json")).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bundled_profiles_are_valid_and_named_after_their_model() {
assert!(!BUNDLED.is_empty());
for (model, json) in BUNDLED {
let p = parse(json.as_bytes(), model).unwrap();
assert_eq!(p.model, *model);
assert!(p.files >= 5 && p.hue_sat.is_some(), "{model}: {} photos", p.files);
assert_eq!(bundled(model), Some(p));
}
assert!(bundled("No Such Camera").is_none());
}
#[test]
fn file_names_are_safe() {
assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");
assert_eq!(file_name(" DSC-RX100M3 "), "DSC-RX100M3.json");
assert_eq!(file_name("../../etc/passwd"), "______etc_passwd.json");
assert_eq!(file_name("Ω 1/2"), "__1_2.json");
}
#[test]
fn pool_skips_files_that_cannot_contribute() {
let mut pool = Pool::default();
assert!(pool.add(b"not a raw file").is_err());
assert!(pool.fit(1).is_empty());
assert!(pool.files().is_empty());
}
}
+232
View File
@@ -2,6 +2,11 @@
//! The folder's photos are probed in place as `local` photos — only folder views list them —
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
//! makes them ordinary library photos.
//!
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
use std::path::Path;
@@ -35,12 +40,113 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
v
};
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
/// growing the log; far finer than the days the eviction counts in).
const RESTAMP_SECS: i64 = 3600;
/// Record that `root` and the folders of `files` were browsed now (journaled, not an undo step).
fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
let now = (s.clock)();
let now_s = lightcraft_catalog::stacks::iso_seconds(&now);
let mut folders: std::collections::BTreeSet<String> =
files.iter().filter_map(|f| Path::new(f).parent().map(|d| d.to_string_lossy().trim_end_matches(['/', '\\']).to_string())).collect();
folders.insert(root.to_string());
let ops: Vec<Op> = folders
.into_iter()
.filter(|f| {
let at = s.catalog.last_browsed(f).and_then(lightcraft_catalog::stacks::iso_seconds);
match (at, now_s) {
(Some(at), Some(n)) => n - at >= RESTAMP_SECS || n < at,
_ => true,
}
})
.map(|folder| Op::SetBrowsed { folder, at: Some(now.clone()) })
.collect();
if !ops.is_empty() {
let op = Op::Batch { ops };
if s.catalog.apply(op.clone()).is_ok() {
s.pending_log.push(op);
}
}
}
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
keep.extend(s.interaction.as_ref().map(|i| i.photo));
keep.extend(s.before.keys().copied());
for e in s.undo.iter().chain(s.redo.iter()) {
e.op.photo_ids(&mut |id| {
keep.insert(id);
});
}
keep
}
impl Session {
/// Forget untouched Local records whose folder wasn't browsed for `days` (default
/// [`Session::forget_local_days`]; 0 = never). With `dry_run`, only report what would go.
/// Journaled (durable, replayed after a crash) but not an undo step; nothing on disk changes.
pub fn forget_local(&mut self, dry_run: bool, days: Option<u32>) -> lightcraft_catalog::ForgetPlan {
let days = days.unwrap_or(self.forget_local_days);
let now = (self.clock)();
let keep = in_use(self);
let smart = self.media.smart_dir.clone();
let plan = self
.catalog
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
if dry_run {
return plan;
}
for op in plan.ops() {
match self.catalog.apply(op.clone()) {
Ok(_) => self.pending_log.push(op),
Err(e) => log::warn!("forget Local records: {e}"),
}
}
if !plan.evict.is_empty() {
log::info!("forgot {} untouched Local record(s) of folders not browsed for {days} days", plan.evict.len());
}
plan
}
}
fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
let dry_run = bool_or(p, "dryRun", false);
let days = match p.get("days") {
None | Some(Value::Null) => None,
Some(v) => {
Some(v.as_u64().ok_or_else(|| bad("library.forgetLocal", "days must be a whole number (0 = never)"))?.min(u64::from(u32::MAX)) as u32)
}
};
let used = days.unwrap_or(s.forget_local_days);
let plan = s.forget_local(dry_run, days);
if !dry_run && !plan.evict.is_empty() {
s.compact_soon();
}
Ok(json!({
"dryRun": dry_run,
"days": used,
"local": plan.local,
"forgotten": plan.evict.len(),
"keptRecent": plan.kept_recent,
"keptTouched": plan.kept_touched,
"keptInUse": plan.kept_in_use,
"foldersStamped": plan.stamp.len(),
}))
}
/// The browsed photos among `ids` (default: the selection) become library photos.
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
@@ -55,6 +161,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"folder.rename",
"Rename Folder",
[],
None,
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?;
let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.move",
"Move Folder",
[],
None,
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
let to = Path::new(into).join(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"library.browse",
"Browse Folder",
@@ -64,6 +204,98 @@ pub fn specs() -> Vec<CommandSpec> {
always,
browse
),
cmd!(
"library.forgetLocal",
"Forget Unchanged Local Photos",
[],
None,
"{dryRun?: bool, days?: n (default: the library preference, 0 = never)} — remove Local (browsed, not added) photos nobody changed, of folders not browsed for `days`, from the catalog; files and sidecars stay, browsing the folder again brings them back; not an undo step → {local, forgotten, keptRecent, keptTouched, keptInUse, foldersStamped, days, dryRun}",
always,
forget_local
),
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
]
}
/// Rename `from` to `to` on disk (a folder; everything in it goes along). Never overwrites:
/// refused when `to` exists (a case-only rename on a case-insensitive file system goes through
/// a temporary name). Missing parents of `to` are created.
pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
let (src, dst) = (Path::new(from), Path::new(to));
if !src.is_dir() {
return Err(format!("{from} is not a folder (moved or deleted?)"));
}
let case_only = from != to && from.to_lowercase() == to.to_lowercase();
if dst.exists() && !case_only {
return Err(format!("{to} already exists"));
}
if dst.starts_with(src) {
return Err("a folder can't move into itself".into());
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
if case_only {
let tmp = src.with_file_name(format!(".lc-rename-{}", std::process::id()));
if tmp.exists() {
return Err(format!("{} already exists", tmp.display()));
}
std::fs::rename(src, &tmp).map_err(|e| format!("{from} → {to}: {e}"))?;
return std::fs::rename(&tmp, dst).map_err(|e| {
let _ = std::fs::rename(&tmp, src);
format!("{from} → {to}: {e}")
});
}
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
}
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
if let Some(b) = &mut s.browse
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
{
b.path = Path::new(to).join(rel).to_string_lossy().to_string();
let f = b.path.clone();
s.filter.folder = Some(f);
}
}
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result::Result<usize, String> {
let (src, dst) = (Path::new(from), Path::new(to));
if !src.is_dir() {
return Err(format!("{from} is not a folder"));
}
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
return Err("the library's own folders can't be moved here".into());
}
rename_folder_on_disk(from, to)?;
let ops: Vec<Op> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
_ => None,
})
.map(|(id, file_name, np)| Op::Relink {
id,
file_name,
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
format: None,
})
.collect();
let n = ops.len();
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let renamed = src.parent() == dst.parent();
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
let folder = crate::FolderMove { from: to.to_string(), to: from.to_string() };
if let Err(e) = s.commit_with_folder(&label, Op::Batch { ops }, folder) {
// the catalog didn't take it: put the folder back where the photos still point
let back = rename_folder_on_disk(to, from).err().map(|b| format!("; moving it back failed too: {b}")).unwrap_or_default();
return Err(format!("{e}{back}"));
}
follow_folder(s, from, to);
Ok(n)
}
+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);
+362
View File
@@ -0,0 +1,362 @@
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
//! disk; undo relinks the photo to it.
use std::path::Path;
use lightcraft_catalog::{MediaKind, Op, Source};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_active, has_selection};
use crate::{Result, Session};
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
let p = Path::new(path);
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
}
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
/// returns the new path. An empty `packet` writes no XMP.
///
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
/// 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> {
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}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
let same = match (&back.data, &raw.data) {
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
}
_ => false,
};
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
}
drop((raw, back));
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
Ok(out.to_string_lossy().to_string())
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.convertToDng";
let ids = s.targets(p);
let mut ops = Vec::new();
let mut converted = Vec::new();
let mut skipped = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
let Source::File { path } = &ph.source else {
skipped.push(json!([id.0, "not a file"]));
continue;
};
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
skipped.push(json!([
id.0,
if ph.kind != MediaKind::Raw {
"not a raw photo"
} else if ph.copy_of.is_some() {
"a virtual copy"
} else {
"already a DNG"
}
]));
continue;
}
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
match write_dng_for(s, path, packet) {
Ok(out) => {
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
// virtual copies of this photo follow it to the DNG
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.push(Op::Relink {
id: c.id,
file_name: file_name.clone(),
source: Source::File { path: out.clone() },
format: Some("DNG".into()),
});
}
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
converted.push(json!({"id": id.0, "path": out, "original": path}));
}
Err(e) => skipped.push(json!([id.0, e])),
}
}
if !ops.is_empty() {
let n = converted.len();
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
}
Ok(json!({"converted": converted, "skipped": skipped}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"photo.convertToDng",
"Convert to DNG",
["Photo"],
None,
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
has_selection,
convert
)]
}
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
/// stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
// 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(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
Ok(json!({"path": out, "id": new, "original": id.0}))
}
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
(!same).then_some(Op::SetContent {
id,
width: info.width,
height: info.height,
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
})
}
/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe.
fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
let (mut m, src) = (ph.meta.clone(), &info.meta);
let before = m.clone();
if m.camera.is_empty() {
m.camera = src.camera.clone();
}
if m.lens.is_empty() {
m.lens = src.lens.clone();
}
if m.shutter.is_empty() {
m.shutter = src.shutter.clone();
}
m.focal_mm = m.focal_mm.or(src.focal_mm);
m.aperture = m.aperture.or(src.aperture);
m.iso = m.iso.or(src.iso);
m.gps = m.gps.or(src.gps);
let mut ops = Vec::new();
if m != before {
ops.push(Op::SetMeta { id, meta: Box::new(m) });
}
if ph.captured.is_none() && info.captured.is_some() {
ops.push(Op::SetCaptured { id, captured: info.captured.clone() });
}
ops
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p);
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
Some(Source::File { path }) => Some((*id, path)),
_ => None,
})
.collect();
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let Some(op) = content_op(id, ph, info.clone()) else {
if !meta_ops.is_empty() {
ops.extend(meta_ops);
reloaded.push(id);
}
continue;
};
ops.push(op);
ops.extend(meta_ops);
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.extend(content_op(c.id, c, info.clone()));
reloaded.push(c.id);
}
reloaded.push(id);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
/// same settings, metadata, rating, flag, label and albums. → {ids}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.duplicate";
let mut made = Vec::new();
for id in s.targets(p) {
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
let src = Path::new(path);
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
let dir = src.parent().unwrap_or(Path::new(""));
let mut dst = dir.join(format!("{stem}-copy{ext}"));
let mut n = 2;
while dst.exists() {
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
n += 1;
}
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
let new = s.catalog.alloc_photo_id();
let mut q = ph.clone();
q.id = new;
q.source = Source::File { path: dst.to_string_lossy().to_string() };
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
// the same bytes, but its own photo: no duplicate-content clash, no shared history
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
q.copy_of = None;
q.copy_name = None;
q.history.clear();
q.versions.retain(|v| !v.auto);
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
let mut photos = a.photos.clone();
photos.push(new);
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
}
s.commit("Duplicate", Op::Batch { ops })?;
made.push(new);
}
s.merge_undo(made.len(), "Duplicate");
if let Some(last) = made.last() {
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
}
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.duplicate",
"Duplicate",
["Photo"],
None,
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
has_selection,
duplicate
),
cmd!(
"photo.reload",
"Reload from Disk",
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
has_selection,
reload
),
cmd!(
"photo.editExternal",
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
has_active,
edit_external
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_catalog::{Photo, PhotoId, Source};
/// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps
/// everything already set.
#[test]
fn reload_fills_only_missing_camera_fields() {
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00");
ph.meta.title = "Mine".into();
ph.meta.iso = Some(800);
let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() };
info.meta.camera = "Canon EOS R6 Mark III".into();
info.meta.lens = "RF24-105mm".into();
info.meta.iso = Some(400);
info.meta.aperture = Some(6.3);
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3)));
assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept");
assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. })));
// nothing missing → nothing to do
ph.meta = (**meta).clone();
ph.captured = Some("2026-10-04T08:16:11".into());
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
//! `bestOfGroup`).
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, cmd};
use crate::{Result, Session};
/// Seconds between shots of the same burst at most.
const BURST_GAP: i64 = 10;
/// Signature similarity for "the same scene".
const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
let mut failed = Vec::new();
for id in ids {
match s.source_now(id, crate::media::SourceLevel::Thumb) {
Ok(src) => {
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
use lightcraft_pipeline::cull;
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
}
Err(e) => failed.push(json!([id.0, e])),
}
}
// bursts: consecutive (by capture time) look-alike shots
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
let mut next = 1u32;
for i in 1..rows.len() {
let (a, b) = (&rows[i - 1], &rows[i]);
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
let g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
});
group_of[i] = Some(g);
}
}
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
for (i, g) in group_of.iter().enumerate() {
if let Some(g) = g {
let e = best.entry(*g).or_insert((i, -1.0));
if rows[i].2 > e.1 {
*e = (i, rows[i].2);
}
}
}
let mut ops = Vec::new();
let (mut rejected, mut picked) = (0, 0);
let mut out = Vec::new();
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
let g = group_of[i];
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
rejected += 1;
} else if pick_best && is_best && flag == Flag::None {
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
picked += 1;
}
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
}
if !ops.is_empty() {
s.commit("Assisted Culling", Op::Batch { ops })?;
}
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findSimilar";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
if let Some(v) = s.signatures.get(&key) {
return Some(*v);
}
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let v = lightcraft_pipeline::cull::signature(&src);
s.signatures.insert(key, v);
Some(v)
};
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
for q in all {
if let Some(v) = sig(s, q) {
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
if sim >= min {
hits.push((q, sim));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
s.filter.only = hits.iter().map(|h| h.0).collect();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"library.findSimilar",
"Find Similar Photos",
["Photo"],
None,
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
always,
find_similar
),
cmd!(
"photo.analyze",
"Assisted Culling",
[],
None,
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
always,
analyze
),
]
}
+357
View File
@@ -0,0 +1,357 @@
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
//! exported as small JSON files).
use lightcraft_develop::ToneCurve;
use lightcraft_geom::Point;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
use crate::{Result, Session};
/// Channels `curve.reset` accepts.
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
let d = ToneCurve::default();
match channel {
"all" => *c = d,
"point" => {
c.master.clear();
c.red.clear();
c.green.clear();
c.blue.clear();
}
"parametric" => {
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
}
"master" | "rgb" | "luma" => c.master.clear(),
"red" => c.red.clear(),
"green" => c.green.clear(),
"blue" => c.blue.clear(),
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
}
Ok(())
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.reset";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let channel = str_param(p, "channel").unwrap_or("all");
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
s.set_develop(id, d, &label)?;
Ok(json!({"channel": channel}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"curve.reset",
"Reset Tone Curve",
[],
None,
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
has_active,
reset
),
cmd!(
query "curve.presets",
"Point Curve Presets",
[],
None,
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
always,
|s, _| Ok(list(s))
),
cmd!(
"curve.applyPreset",
"Apply Point Curve Preset",
[],
None,
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
has_active,
apply_preset
),
cmd!(
"curve.savePreset",
"Save Point Curve Preset",
[],
None,
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
has_active,
save_preset
),
cmd!(
"curve.deletePreset",
"Delete Point Curve Preset",
[],
None,
"{name} — a user preset (built-ins can't be deleted)",
always,
delete_preset
),
cmd!(
"curve.importPresets",
"Import Point Curve Presets",
[],
None,
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
always,
import_presets
),
cmd!(
"curve.exportPresets",
"Export Point Curve Presets",
[],
None,
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
always,
export_presets
),
]
}
// ---------------------------------------------------------------- point-curve presets
/// File extension of exported curve presets (JSON).
pub const CURVE_PRESET_EXT: &str = "lccurve";
/// `format` tag of an exported curve-preset file.
const FILE_FORMAT: &str = "lightcraft.curvePresets";
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
/// Built-ins ship with the app; the user's are saved with the library preferences.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CurvePreset {
pub name: String,
pub master: Vec<[f64; 2]>,
pub red: Vec<[f64; 2]>,
pub green: Vec<[f64; 2]>,
pub blue: Vec<[f64; 2]>,
/// Built-in presets can't be deleted or overwritten (never stored).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub builtin: bool,
}
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut out: Vec<[f64; 2]> =
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
out.clear();
}
out
}
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
}
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
}
impl CurvePreset {
/// The point curves of `c` under `name`.
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
CurvePreset {
name: name.into(),
master: from_points(&c.master),
red: from_points(&c.red),
green: from_points(&c.green),
blue: from_points(&c.blue),
builtin: false,
}
}
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
pub fn apply(&self, c: &mut ToneCurve) {
c.master = to_points(&self.master);
c.red = to_points(&self.red);
c.green = to_points(&self.green);
c.blue = to_points(&self.blue);
}
/// Does `c` carry exactly this preset's point curves?
pub fn matches(&self, c: &ToneCurve) -> bool {
let same = |a: &[[f64; 2]], b: &[Point]| {
let (a, b) = (normalize(a), from_points(b));
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
};
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
}
fn is_linear(&self) -> bool {
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
}
}
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
pub fn builtin_presets() -> Vec<CurvePreset> {
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
vec![
s("Linear", Vec::new()),
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
]
}
/// Built-in presets, then the user's.
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
let mut v = builtin_presets();
v.extend(s.curve_presets.iter().cloned());
v
}
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
}
fn is_builtin_name(name: &str) -> bool {
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
}
/// Add or replace (same name, ignoring case) a user preset.
fn upsert(s: &mut Session, p: CurvePreset) {
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
Some(x) => *x = p,
None => s.curve_presets.push(p),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
}
fn list(s: &Session) -> Value {
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
json!({"presets": all_presets(s), "current": current})
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.applyPreset";
let name = name_param(p, C)?;
let preset =
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
preset.apply(&mut d.curve);
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
Ok(json!({"name": preset.name}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.savePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
}
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
if preset.is_linear() {
return Err(bad(C, "the point curve is linear: shape it first"));
}
upsert(s, preset);
s.save_prefs()?;
Ok(list(s))
}
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.deletePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, "built-in presets can't be deleted"));
}
let n = s.curve_presets.len();
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if n == s.curve_presets.len() {
return Err(bad(C, format!("no curve preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
let items = match &v {
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
Value::Array(_) => v.clone(),
Value::Object(_) => Value::Array(vec![v.clone()]),
_ => return Err("not a curve preset file".into()),
};
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
if list.is_empty() {
return Err("no curve presets in the file".into());
}
Ok(list)
}
/// The JSON of a curve-preset file holding `presets`.
pub fn to_file(presets: &[CurvePreset]) -> String {
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
}
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.importPresets";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for f in &paths {
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
Ok(list) => {
for mut preset in list {
preset.name = preset.name.trim().to_string();
preset.builtin = false;
if is_builtin_name(&preset.name) {
preset.name = format!("{} (imported)", preset.name);
}
imported.push(preset.name.clone());
upsert(s, preset);
}
}
Err(e) => failed.push(json!([f, e])),
}
}
if !imported.is_empty() {
s.save_prefs()?;
}
Ok(json!({"imported": imported, "failed": failed}))
}
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.exportPresets";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(CURVE_PRESET_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let chosen: Vec<CurvePreset> = match &names {
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
None => s.curve_presets.clone(),
};
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+147 -13
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")
})
@@ -193,10 +214,32 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"develop.autoBwMix",
"Auto B&W Mix",
[],
None,
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
has_active,
|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.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| {
d.treatment = Treatment::Bw;
let b = &mut d.bw_mix;
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
Ok(())
})?;
Ok(json!({"mix": m}))
}
),
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| {
@@ -223,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 => {
@@ -234,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)),
};
@@ -255,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);
@@ -288,7 +331,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
}
edit(s, "develop.profile", "Profile", |d| {
@@ -301,7 +344,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
if crate::presets::profile(&id).is_none() {
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
}
let on = s.profile_favorites.contains(&id);
@@ -383,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"}));
@@ -479,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))
}
};
@@ -616,6 +659,97 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.quickAdjust",
"Quick Develop",
[],
None,
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
has_selection,
|s, p| {
let c = "develop.quickAdjust";
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
let delta = f64_req(p, "delta", c)?;
let label = format!("Quick Develop: {}", spec.label);
let ops: Vec<Op> = s
.targets(p)
.into_iter()
.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" {
nd.wb.mode = WbMode::Custom;
}
s.develop_op(id, nd, &label)
})
.collect();
let n = ops.len();
if n > 0 {
s.commit(&label, Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.matchExposure",
"Match Total Exposures",
["Photo"],
None,
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
has_selection,
|s, p| {
let c = "develop.matchExposure";
let reference = active(s, c)?;
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
let m = &s.catalog.photo(id)?.meta;
let t = match m.shutter.split_once('/') {
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
};
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
};
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
for id in ids {
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
skipped.push(id.0);
continue;
};
let mut nd = (*d).clone();
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
}
let n = ops.len();
if n > 0 {
s.commit("Match Total Exposures", Op::Batch { ops })?;
}
Ok(json!({"changed": n, "skipped": skipped}))
}
),
cmd!(
"develop.autoSync",
"Auto Sync",
["Edit"],
Some("Cmd+Alt+Shift+A"),
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
always,
|s, p| {
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
Ok(json!({"on": s.auto_sync}))
}
),
// ---- presets
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
+13
View File
@@ -96,5 +96,18 @@ pub fn specs() -> Vec<CommandSpec> {
save
),
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
cmd!(
query "export.checkTarget",
"Check Output Path",
[],
None,
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
always,
|s, p| {
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
Ok(json!({"path": path}))
}
),
]
}
+71
View File
@@ -0,0 +1,71 @@
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
//! and applied in one step. Saved with the library.
use lightcraft_catalog::Filter;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilterPreset {
pub name: String,
pub filter: Filter,
}
fn list(s: &Session) -> Value {
json!(s.filter_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
if s.filter == Filter::default() {
return Err(bad(C, "the filter is empty: set some filters first"));
}
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
Some(f) => *f = preset,
None => s.filter_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
s.filter = f.filter.clone();
Ok(json!({"photos": s.visible().len()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.filter_presets.len();
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
if n == s.filter_presets.len() {
return Err(bad(C, format!("no filter preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
cmd!(
"filter.savePreset",
"Save Filter Preset",
[],
None,
"{name} — the current filter (library.filter), saved with the library",
always,
save
),
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
]
}
+133 -1
View File
@@ -1,4 +1,5 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under};
use serde_json::{Value, json};
@@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
Ok(json!({"changed": n}))
}
/// A named set of up to nine keywords.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct KeywordSet {
pub name: String,
pub keywords: Vec<String>,
}
/// The set name meaning "the nine most recently added keywords".
pub const RECENT: &str = "Recent Keywords";
/// Remember `added` as the most recent keywords (newest first, nine kept).
pub fn note_recent(s: &mut Session, added: &[String]) {
for k in added.iter().rev() {
let k = clean(k);
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
pub fn current_keywords(s: &Session) -> Vec<String> {
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
v.truncate(9);
v
}
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
pub fn keyword_sets_json(s: &Session) -> Value {
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
)
};
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.saveSet",
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
always,
|s, p| {
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
} else {
current_keywords(s)
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
}
s.keyword_set = Some(name);
s.save_prefs()?;
Ok(keyword_sets_json(s))
}
),
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
let before = s.keyword_sets.len();
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.keyword_sets.len() == before {
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
}
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
s.keyword_set = None;
}
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.toggleFromSet",
"Toggle Keyword from Set",
[],
None,
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
super::has_selection,
|s, p| {
let i = p
.get("index")
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let all = !ids.is_empty()
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
let key = if all { "removeKeywords" } else { "addKeywords" };
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
let recent = s.recent_keywords.clone();
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
if s.keyword_set.is_none() {
s.recent_keywords = recent;
}
r["keyword"] = json!(k);
r["added"] = json!(!all);
Ok(r)
}
),
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
}),
+465 -69
View File
@@ -1,12 +1,111 @@
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
//! metadata, albums, import.
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
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}`")))
}
@@ -128,34 +227,43 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---- view source / filter / sort
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
cmd!(
"library.source",
"Show Source",
[],
None,
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
always,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"missing" => LibrarySource::Missing,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
}
LibrarySource::Album(a)
}
LibrarySource::Album(a)
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(json!({"count": vis.len()}))
}
Ok(json!({"count": vis.len()}))
}),
),
cmd!(
"library.filter",
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
@@ -241,13 +349,14 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
}),
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| {
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
"copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
"keywords": m.keywords});
s.meta_clipboard = Some(v.clone());
Ok(v)
@@ -257,7 +366,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste Metadata",
["Photo"],
None,
"{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}",
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
has_selection,
|s, p| {
let c = "photo.pasteMetadata";
@@ -337,6 +446,66 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)),
cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)),
cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)),
// ---- face / pet regions
cmd!(
"photo.removeRegion",
"Remove Face Box",
[],
None,
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
always,
|s, p| {
let id =
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?;
let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone();
if index >= meta.regions.len() {
return Err(bad("photo.removeRegion", "no such region"));
}
let gone = meta.regions.remove(index);
s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
s.skip_auto_write = true;
Ok(json!({"removed": gone.name}))
}
),
cmd!(
"photo.setRegion",
"Resize Face Box",
[],
None,
"{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar",
always,
|s, p| {
const C: &str = "photo.setRegion";
// a drag is one undo step however many frames it took: the caller commits once, on release
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad(C, "missing or invalid `index`"))?;
let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?;
let num = |k: &str| {
r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number")))
};
let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?);
let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0));
let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0));
if x1 - x0 < 0.005 || y1 - y0 < 0.005 {
return Err(bad(C, "the box would be too small"));
}
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?;
region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 };
s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
s.skip_auto_write = true;
Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}}))
}
),
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
@@ -344,11 +513,11 @@ pub fn specs() -> Vec<CommandSpec> {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(v)
}),
cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
deleted: false
}))),
cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| {
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let t = s.targets(p);
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
s.commit("Delete Permanently", Op::Batch { ops })?;
@@ -361,20 +530,49 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Info",
[],
None,
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
has_selection,
|s, p| {
let strs =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
Some(Value::String(t)) => Some(Some(
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
)),
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
},
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
};
let status = match str_param(p, "copyrightStatus") {
Some(t) => Some(
CopyrightStatus::parse(t)
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
),
None => None,
};
let mut ops = Vec::new();
for id in targets {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut m = ph.meta.clone();
if let Some(g) = gps {
m.gps = g;
}
if let Some(st) = status {
m.copyright_status = st;
}
for (k, field) in [
("title", &mut m.title),
("caption", &mut m.caption),
("copyright", &mut m.copyright),
("usageTerms", &mut m.usage_terms),
("copyrightUrl", &mut m.copyright_url),
("creator", &mut m.creator),
("location", &mut m.location),
("city", &mut m.city),
@@ -402,6 +600,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
let n = ops.len();
s.commit("Edit Info", Op::Batch { ops })?;
// keywords just added become the Recent Keywords set
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
if n > 0 && !added.is_empty() {
crate::cmd::keywords::note_recent(s, &added);
}
Ok(json!({"changed": n}))
}
),
@@ -418,7 +621,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cover = photos.first().copied();
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
)?;
Ok(json!({"id": id.0}))
}),
@@ -427,7 +630,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Smart Album…",
[],
None,
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
@@ -445,6 +648,27 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
Ok(json!(FIELDS
.iter()
.map(|(id, label, kind)| {
let k = match kind {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Date => "date",
Kind::Choice(_) => "choice",
Kind::Bool => "bool",
};
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
}
v
})
.collect::<Vec<_>>()))
}),
cmd!(
"album.setRules",
"Edit Smart Album",
@@ -504,6 +728,151 @@ pub fn specs() -> Vec<CommandSpec> {
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added}))
}),
cmd!(
"album.toggleTarget",
"Add to Target Album",
[],
None,
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
has_selection,
|s, p| {
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
Some(id) => id,
None => {
let id = s.catalog.alloc_album_id();
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
s.commit("New Quick Collection", Op::AddAlbum { album })?;
id
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
let photos: Vec<PhotoId> = if all_in {
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
s.commit(
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
)?;
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
}
),
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
s.target_album = match p.get("id").and_then(Value::as_u64) {
Some(a) => {
let id = AlbumId(a);
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
}
Some(id)
}
None => None,
};
Ok(json!({"target": s.target_album.map(|a| a.0)}))
}),
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
let Some(id) = s.catalog.quick_collection() else { return ok() };
s.commit(
"Clear Quick Collection",
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
)?;
ok()
}),
cmd!(
"library.autoImport",
"Auto Import Settings",
[],
None,
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
always,
|s, p| {
if let Some(f) = p.get("folder") {
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
None => None,
};
}
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
s.import_defaults.auto_copy = c;
}
if let Some(a) = p.get("album") {
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
}
s.save_prefs()?;
let d = &s.import_defaults;
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
}
),
cmd!(
"library.autoImportScan",
"Auto Import Now",
[],
None,
"{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, 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
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Some(path.clone()),
_ => 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 (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 seen = s.auto_import_seen.insert(ps.clone(), size);
if size > 0 && seen == Some(size) {
fresh.push(ps);
}
}
if fresh.is_empty() {
return Ok(json!({"imported": [], "folder": folder}));
}
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
let mut params = json!({"paths": fresh, "mode": mode});
if let Some(a) = s.import_defaults.auto_album.clone() {
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
Some(al) => params["album"] = json!(al.id.0),
None => params["albumName"] = json!(a),
}
}
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
s.selection = sel;
Ok(json!({"imported": r["imported"], "folder": folder}))
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
@@ -527,7 +896,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Review Import",
[],
None,
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -541,51 +910,21 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"library.import",
"Add Photos…",
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
"{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,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
};
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 opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
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()
{
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() {
@@ -629,6 +968,14 @@ pub fn specs() -> Vec<CommandSpec> {
"logRecords": j.log_records(),
"logBytes": j.log_bytes(),
"lastError": lib.last_error,
// settings files that were unreadable or damaged at open (kept, defaults used)
"settingsWarnings": lib.settings_warnings,
// changes applied in memory whose write failed (retried by every save)
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
"unsavedError": s.unsaved().map(|u| u.1),
// untouched Local records forgotten when the library opened
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
"persistence": j.stats(),
"cache": cache,
"load": {
"created": lib.report.created,
@@ -653,7 +1000,11 @@ pub fn specs() -> Vec<CommandSpec> {
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
return Err(bad(c, problem));
}
Ok(f)
}
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
@@ -697,3 +1048,48 @@ impl Session {
fn has_library(s: &Session) -> std::result::Result<(), String> {
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
}
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
let up = s.to_ascii_uppercase();
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let nums: Vec<f64> =
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
let v = match nums.as_slice() {
[d] => *d,
[d, m] => d + m / 60.0,
[d, m, s] => d + m / 60.0 + s / 3600.0,
_ => return None,
};
let _ = pos;
Some(sign * v)
};
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
}
#[cfg(test)]
mod gps_tests {
#[test]
fn coordinates_people_type() {
let p = super::parse_gps;
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the
//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the
//! list lives in the library's prefs and the LUTs are registered with the pipeline when the
//! library opens.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// One imported LUT profile.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LutProfile {
/// `lut:<slug>`.
pub id: String,
pub name: String,
pub group: String,
/// The `.cube` file (the library's copy when there is a library on disk).
pub file: String,
}
fn profiles_dir(s: &Session) -> Option<PathBuf> {
s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles"))
}
/// Register every imported profile's LUT with the pipeline (missing files are skipped).
pub fn register_all(s: &Session) {
for p in &s.lut_profiles {
if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) {
lightcraft_pipeline::lut::register(&p.id, l);
}
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
// (name, text, group)
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let ep = e.path();
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if ep.is_dir() {
stack.push(ep);
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
&& let Ok(t) = std::fs::read_to_string(&ep)
{
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
if inner.to_ascii_lowercase().ends_with(".cube") {
let ip = Path::new(&inner);
let group = ip
.parent()
.and_then(|d| d.file_name())
.map(|n| n.to_string_lossy().to_string())
.or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string()));
out.push((
ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
String::from_utf8_lossy(&data).to_string(),
group,
));
}
}
} else if let Ok(t) = std::fs::read_to_string(path) {
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
}
}
out
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "profile.import";
let paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if paths.is_empty() {
return Err(bad(C, "no paths"));
}
let dir = profiles_dir(s);
if let Some(d) = &dir {
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for (name, text, group) in cube_files(&paths) {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
failed.push(json!([name, e]));
continue;
}
};
let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone());
let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into());
let slug = crate::presets::slug(&format!("{group}-{display}"));
let mut id = format!("lut:{slug}");
let mut n = 2;
while s.lut_profiles.iter().any(|x| x.id == id) {
id = format!("lut:{slug}-{n}");
n += 1;
}
let file = match &dir {
Some(d) => {
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
f.to_string_lossy().to_string()
}
None => {
// no library on disk: remember the original (the in-memory session ends with the app)
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
f.to_string_lossy().to_string()
}
};
lightcraft_pipeline::lut::register(&id, lut);
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
Ok(json!({"imported": imported, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"profile.import",
"Import Profiles",
[],
None,
"{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}",
always,
import
),
cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string();
let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else {
return Err(bad("profile.deleteImported", format!("no imported profile `{id}`")));
};
let gone = s.lut_profiles.remove(i);
if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) {
let _ = std::fs::remove_file(&gone.file);
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
Ok(json!({"deleted": id}))
}),
]
}
+198 -3
View File
@@ -1,4 +1,4 @@
//! Photo management commands: batch rename, capture time, colour label names.
//! Photo management commands: batch rename, capture time, colour label names and sets.
use serde_json::{Value, json};
@@ -13,9 +13,149 @@ fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft
Ok((s.targets(p), template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LabelSet {
pub name: String,
pub names: [String; 5],
}
/// Our built-in sets.
pub fn builtin_label_sets() -> Vec<LabelSet> {
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
}
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
}
/// The set whose names are the catalog's current ones.
fn current_label_set(s: &crate::Session) -> Option<String> {
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
all_label_sets(s)
.into_iter()
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
.map(|x| x.name)
}
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
pub fn label_sets_json(s: &crate::Session) -> Value {
let builtin = builtin_label_sets().len();
let sets: Vec<Value> =
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
json!({"sets": sets, "current": current_label_set(s)})
}
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
ColorLabel::ALL
.iter()
.zip(names)
.filter_map(|(l, n)| {
let n = n.trim();
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
})
.collect()
}
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
use lightcraft_meta::{DateTime, Match, parse_gpx};
const C: &str = "photo.autoTagTracklog";
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
};
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
let offset: Option<i64> = match p.get("offset") {
None | Some(Value::Null) => None,
Some(Value::String(o)) if o.trim().is_empty() => None,
Some(Value::Number(h)) => {
let h = h.as_f64().unwrap_or(0.0);
if h.abs() > 18.0 {
return Err(bad(C, "offset is at most ±18 hours"));
}
Some((h * 60.0).round() as i64)
}
Some(Value::String(o)) => {
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
}
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
};
let max_gap = f64_or(p, "maxGap", 600.0);
if max_gap.is_nan() || max_gap < 0.0 {
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
}
let replace = bool_or(p, "replace", false);
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
let mut ops = Vec::new();
let mut photos = Vec::new();
for id in s.targets(p) {
let Some(ph) = s.catalog.photo(id) else { continue };
if ph.meta.gps.is_some() && !replace {
has_gps += 1;
continue;
}
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
no_time += 1;
continue;
};
// UTC seconds: the recorded zone wins, else the caller's offset
let utc = match dt.offset_minutes {
Some(_) => dt.unix_seconds(),
None => {
if offset.is_none() {
assumed_utc += 1;
}
dt.unix_seconds() - offset.unwrap_or(0) * 60
}
} as f64
+ dt.millis as f64 / 1000.0;
let Some((g, m)) = log.locate(utc, max_gap) else {
outside += 1;
continue;
};
if m == Match::Interpolated {
interpolated += 1;
}
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
if ph.meta.gps != Some(pos) {
let mut meta = ph.meta.clone();
meta.gps = Some(pos);
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
}
}
let tagged = photos.len();
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
}
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
Ok(json!({
"tagged": tagged,
"interpolated": interpolated,
"nearest": tagged - interpolated,
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
"assumedUtc": assumed_utc,
"points": log.points.len(),
"start": iso(start),
"end": iso(end),
"photos": photos,
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
Ok(crate::rename::token_help_json())
}),
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
}),
@@ -24,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Photos",
[],
None,
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
has_selection,
|s, p| {
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
@@ -81,6 +221,15 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n, "captured": out}))
}
),
cmd!(
"photo.autoTagTracklog",
"Auto-Tag Photos from Tracklog",
[],
None,
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
has_selection,
auto_tag_tracklog
),
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
Ok(json!(ColorLabel::ALL
.iter()
@@ -112,5 +261,51 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
let set = all_label_sets(s)
.into_iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
let ops = set_names_ops(s, &set.names);
let n = ops.len();
if n > 0 {
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}),
cmd!(
"label.saveSet",
"Save Color Label Set",
[],
None,
"{name} — save the current label names as a set (replaces a user set of that name)",
always,
|s, p| {
let name =
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
}
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.label_sets.push(set),
}
s.save_prefs()?;
Ok(json!({"name": name}))
}
),
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
let before = s.label_sets.len();
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.label_sets.len() == before {
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
}
s.save_prefs()?;
Ok(json!({"deleted": name}))
}),
]
}
+423 -13
View File
@@ -5,6 +5,7 @@ use lightcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_param};
use crate::segment::{Click, MAX_CLICKS};
use crate::{Result, Session};
fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
@@ -38,10 +39,100 @@ fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
.unwrap_or_default();
MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) }
}
other => return Err(bad(c, format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange)"))),
"object" => MaskShape::Object { hint: points(p, "points"), exclude: points(p, "exclude"), seg: seg_param(p), detail: vec![], edge: 0.0 },
"prompt" => {
MaskShape::Prompt { text: str_param(p, "text").unwrap_or_default().trim().to_string(), seg: seg_param(p), detail: vec![], edge: 0.0 }
}
other => {
return Err(bad(
c,
format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt)"),
));
}
})
}
/// `[[x, y], …]` normalized points under `key`.
fn points(p: &Value, key: &str) -> Vec<Point> {
p.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
.unwrap_or_default()
}
/// A segmentation passed in (`seg`: a stored or replayed AI mask; the model isn't needed).
fn seg_param(p: &Value) -> Option<lightcraft_develop::SegMask> {
p.get("seg").and_then(|v| serde_json::from_value(v.clone()).ok())
}
/// What a new AI shape still needs once it is added.
enum Later {
Nothing,
/// Background mode: these Object clicks are segmented on the worker.
Clicks(Vec<Point>, Vec<Point>),
/// Background mode: the Describe selection is computed first; the mask is added when found.
Text(String),
}
/// Compute the segmentation of a new AI shape (Object with clicks, Prompt) — or, in background
/// mode, say what to queue. An Object without clicks stays empty until the photo is clicked
/// (the photo is analyzed meanwhile); a Prompt that matches nothing is an error. Shapes given
/// with their `seg` need no model.
fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let background = s.segmenter.background;
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
s.segmenter.model_dir().map_err(ai_err)?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
}
MaskShape::Object { .. } => {
// the clicks come later: refuse an Object mask that could never be computed (no
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
s.segmenter.model_dir().map_err(ai_err)?;
if background {
s.segment_prepare(id).map_err(ai_err)?;
}
}
MaskShape::Prompt { text, .. } if background => {
s.segmenter.model_dir().map_err(ai_err)?;
if text.trim().is_empty() {
return Err(ai_err("describe what to select"));
}
return Ok(Later::Text(text.clone()));
}
MaskShape::Prompt { text, seg, .. } => {
let found = s.segment_text(id, text).map_err(ai_err)?;
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
}
_ => {}
}
Ok(Later::Nothing)
}
/// Queue what a new AI component (component `comp` of mask `mask`) still needs.
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
if let Later::Clicks(hint, exclude) = later {
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
out["pending"] = json!(true);
}
Ok(out)
}
/// AI mask failures are shown as they are (not as "invalid parameters").
fn ai_err(msg: impl Into<String>) -> crate::EngineError {
crate::EngineError::Other(msg.into())
}
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
}
fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mask>, &mut Option<u32>) -> Result<()>) -> Result<Value> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
@@ -67,23 +158,30 @@ pub fn specs() -> Vec<CommandSpec> {
"Create New Mask",
[],
None,
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…), name?}",
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…; object: points/exclude [[x,y],…]; prompt: text; object/prompt: seg? a stored segmentation, no model needed), name?} — AI kinds need the SAM 3 model; in the app a prompt returns {pending} and the mask appears when found",
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("radial").to_string();
let shape = shape_from(&kind, p, "mask.add")?;
let mut shape = shape_from(&kind, p, "mask.add")?;
let later = resolve_ai(s, &mut shape, "mask.add")?;
let name = str_param(p, "name").map(str::to_string);
if let Later::Text(text) = &later {
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
masks_edit(s, "mask.add", "Add Mask", |masks, active| {
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
..Default::default()
});
*active = Some(next);
Ok(())
})
})?;
queue_later(s, later, next, 0, out)
}
),
cmd!(
@@ -95,16 +193,29 @@ pub fn specs() -> Vec<CommandSpec> {
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("brush").to_string();
let shape = shape_from(&kind, p, "mask.addComponent")?;
let op: MaskOp =
serde_json::from_value(p.get("op").cloned().unwrap_or(json!("add"))).map_err(|e| bad("mask.addComponent", e.to_string()))?;
let active = s.active_mask;
let mid = mask_id(p, active, "mask.addComponent")?;
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let id = s.active().ok_or_else(|| bad("mask.addComponent", "no active photo"))?;
if !s.develop_of(id).is_some_and(|d| d.masks.iter().any(|m| m.id == mid)) {
return Err(bad("mask.addComponent", format!("no mask {mid}")));
}
let mut shape = shape_from(&kind, p, "mask.addComponent")?;
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
if let Later::Text(text) = &later {
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
return Ok(json!({"pending": true}));
}
let mut comp = 0;
let out = masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.addComponent")?;
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
comp = masks[i].components.len() - 1;
Ok(())
})
})?;
queue_later(s, later, mid, comp, out)
}
),
cmd!(
@@ -144,12 +255,17 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let m = &mut masks[i];
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
} else {
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
m.components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Brush { strokes: vec![stroke] },
});
}
Ok(())
})
@@ -167,6 +283,216 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"mask.sampleColor",
"Sample Mask Color",
[],
None,
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
has_active,
|s, p| {
let c = "mask.sampleColor";
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
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.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))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
let k = comp
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
return Err(bad(c, "not a colour range component"));
};
if !add {
samples.clear();
}
if samples.len() < 5 {
samples.push(lab);
}
out = samples.clone();
Ok(())
})?;
Ok(json!({"samples": out}))
}
),
cmd!(
"mask.objectPoint",
"Add Object Click",
[],
None,
"{x, y: normalized image point, exclude?: bool (⌥-click: leave this part out), id?: maskId} — a click on the selected mask's Object selection (its last Object component); SAM 3 re-segments the object → {include, exclude}",
has_active,
|s, p| {
let c = "mask.objectPoint";
let at = Point::new(super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
if !(0.0..=1.0).contains(&at.x) || !(0.0..=1.0).contains(&at.y) {
return Err(bad(c, "the point is outside the photo"));
}
let exclude = bool_or(p, "exclude", false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
let k = m
.components
.iter()
.rposition(|x| matches!(x.shape, MaskShape::Object { .. }))
.ok_or_else(|| bad(c, "the mask has no Object selection (add one first)"))?;
let Some(MaskShape::Object { hint, exclude: ex, edge, .. }) = m.components.get(k).map(|x| &x.shape) else {
return Err(bad(c, "not an Object component"));
};
let (mut hint, mut ex, edge) = (hint.clone(), ex.clone(), *edge);
// clicks still on their way to the model count too
if let Some(pending) = s.segmenter.pending_clicks().filter(|q| (q.photo, q.mask, q.comp) == (id, mid, k)) {
(hint, ex) = (pending.hint.clone(), pending.exclude.clone());
}
if hint.len() + ex.len() >= MAX_CLICKS {
return Err(ai_err(format!("an Object selection takes at most {MAX_CLICKS} clicks; start a new one")));
}
if exclude {
ex.push(at)
} else {
hint.push(at)
}
if s.segmenter.background && !hint.is_empty() {
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
return Ok(out);
}
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
let out = json!({"include": hint.len(), "exclude": ex.len()});
masks_edit(s, c, "Object Mask", |masks, _| {
let i = find(masks, mid, c)?;
if let Some(comp) = masks[i].components.get_mut(k) {
comp.shape = MaskShape::Object { hint, exclude: ex, seg, detail: vec![], edge };
}
Ok(())
})?;
Ok(out)
}
),
cmd!(
"mask.refineDetail",
"Refine AI Mask Detail",
[],
None,
"{id?: maskId, component?} — a zoomed-in SAM 3 pass over an Object/Describe selection (default: the mask's last one): the photo around it is analyzed again at a higher resolution, in the background; the mask updates when it's done → {started}",
has_active,
|s, p| {
let c = "mask.refineDetail";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
let k = match p.get("component").and_then(Value::as_u64) {
Some(k) => k as usize,
None => m
.components
.iter()
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
};
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
Ok(json!({"started": started}))
}
),
cmd!(
query "segment.prepare",
"Prepare AI Masks",
[],
None,
"{} — load SAM 3 and analyze the active photo in the background, so Object clicks are instant → {busy}",
has_active,
|s, _| {
let c = "segment.prepare";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
s.segment_prepare(id).map_err(ai_err)?;
Ok(json!({"busy": s.segmenter.busy()}))
}
),
cmd!(
query "segment.model.status",
"AI Mask Model Status",
[],
None,
"{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
super::always,
|s, _| {
let g = &s.segmenter;
Ok(json!({
"available": crate::segment::Segmenter::AVAILABLE,
"installed": g.installed(),
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
"loaded": g.loaded(),
"busy": g.busy(),
"analyzing": g.analyzing(),
"sizeBytes": crate::segment::MODEL_BYTES,
"license": crate::segment::LICENSE_NAME,
"licenseUrl": crate::segment::LICENSE_URL,
"mirrors": g.mirrors().len(),
"download": g.download_status(),
}))
}
),
cmd!(
query "segment.model.download",
"Download AI Mask Model",
[],
None,
"{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not LightCraft's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}",
super::always,
|s, p| {
let c = "segment.model.download";
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
return Err(bad(
c,
format!(
"the SAM 3 model is a {:.1} GB download under Meta's {} ({}): pass `acknowledged: true` once the user has agreed to download it",
crate::segment::MODEL_BYTES as f64 / 1e9,
crate::segment::LICENSE_NAME,
crate::segment::LICENSE_URL
),
));
}
let started = s.segmenter.start_download().map_err(ai_err)?;
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
}
),
cmd!(
query "segment.model.cancel",
"Cancel AI Mask Model Download",
[],
None,
"{} — stop the SAM 3 download (what has arrived is kept, and a new download resumes from it) → {cancelled}",
super::always,
|s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()}))
),
cmd!(
"mask.refine",
"Refine Mask Edges",
[],
None,
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
has_active,
|s, p| {
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
let mid = mask_id(p, s.active_mask, "mask.refine")?;
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
let i = find(masks, mid, "mask.refine")?;
masks[i].refine = v;
Ok(())
})
}
),
cmd!(
"mask.adjust",
"Set Mask Adjustments",
@@ -258,6 +584,54 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"mask.component",
"Edit Mask Component",
[],
None,
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
has_active,
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option<MaskOp> = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
let label = match action.as_str() {
"invert" => "Invert Component",
"duplicate" => "Duplicate Component",
"delete" => "Delete Component",
"rename" => "Rename Component",
"op" => "Change Component Mode",
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
};
masks_edit(s, c, label, |masks, active| {
let i = find(masks, mid, c)?;
let comps = &mut masks[i].components;
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
match action.as_str() {
"invert" => comp.invert = !comp.invert,
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
"duplicate" => {
let copy = comp.clone();
comps.insert(k + 1, copy);
}
_ => {
comps.remove(k);
if comps.is_empty() {
masks.remove(i);
*active = masks.last().map(|m| m.id);
}
}
}
Ok(())
})
}
),
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.move")?;
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
@@ -279,6 +653,42 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
// ---- Remove tool (spots)
cmd!(
"spot.findDust",
"Find Dust Spots",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
has_active,
|s, p| {
let c = "spot.findDust";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for d in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(d.x, d.y)],
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Dust Spots")?;
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
@@ -499,7 +909,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)
}
+2 -1
View File
@@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| bad(id, e.to_string()))?;
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
s.check_write_target(path).map_err(|e| bad(id, e))?;
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
info["previewPath"] = json!(path);
}
if let Some(img) = &out.preview {
+183
View File
@@ -0,0 +1,183 @@
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
use lightcraft_catalog::{CopyrightStatus, Meta};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// The fields a metadata preset can hold (photo.setMeta names).
pub const FIELDS: &[&str] = &[
"title",
"caption",
"copyright",
"copyrightStatus",
"usageTerms",
"copyrightUrl",
"creator",
"location",
"city",
"state",
"country",
"keywords",
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetadataPreset {
pub name: String,
/// photo.setMeta fields; `keywords` are added, not replaced.
pub fields: Value,
}
fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
Some(match k {
"title" => &mut m.title,
"caption" => &mut m.caption,
"copyright" => &mut m.copyright,
"usageTerms" => &mut m.usage_terms,
"copyrightUrl" => &mut m.copyright_url,
"creator" => &mut m.creator,
"location" => &mut m.location,
"city" => &mut m.city,
"state" => &mut m.state,
"country" => &mut m.country,
_ => return None,
})
}
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
pub fn apply_to(m: &mut Meta, fields: &Value) {
for (k, v) in fields.as_object().into_iter().flatten() {
if k == "copyrightStatus" {
if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) {
m.copyright_status = st;
}
} else if k == "keywords" {
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
m.keywords.push(kw.to_string());
}
}
} else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) {
*f = t.to_string();
}
}
}
fn list(s: &Session) -> Value {
json!(s.metadata_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let fields = match p.get("fields") {
Some(Value::Object(o)) => {
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
}
if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) {
return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain")));
}
Value::Object(o.clone())
}
Some(_) => return Err(bad(C, "`fields` must be an object")),
// from the active photo: its non-empty fields among `only` (default: copyright, creator, place)
None => {
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
let only: Vec<String> =
p.get("only").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_else(
|| {
["copyright", "copyrightStatus", "usageTerms", "copyrightUrl", "creator", "location", "city", "state", "country"]
.map(String::from)
.to_vec()
},
);
let mut o = Map::new();
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
if k == "keywords" {
if !m.keywords.is_empty() {
o.insert(k.clone(), json!(m.keywords));
}
} else if k == "copyrightStatus" {
if !m.copyright_status.is_unknown() {
o.insert(k.clone(), json!(m.copyright_status.id()));
}
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
o.insert(k.clone(), json!(v.clone()));
}
}
Value::Object(o)
}
};
let preset = MetadataPreset { name: name.clone(), fields };
match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) {
Some(m) => *m = preset,
None => s.metadata_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.metadata_presets.len();
s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name));
if s.metadata_presets.len() == n {
return Err(bad(C, format!("no metadata preset `{name}`")));
}
if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) {
s.import_defaults.metadata_preset = None;
}
s.save_prefs()?;
Ok(list(s))
}
/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let preset = s
.metadata_presets
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.cloned()
.ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?;
let mut params = Map::new();
for (k, v) in preset.fields.as_object().into_iter().flatten() {
params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone());
}
if let Some(ids) = p.get("ids") {
params.insert("ids".into(), ids.clone());
}
s.execute("photo.setMeta", &Value::Object(params))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))),
cmd!(
"metadata.savePreset",
"Save Metadata Preset",
[],
None,
"{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces",
always,
save
),
cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete),
cmd!(
"metadata.applyPreset",
"Apply Metadata Preset",
[],
None,
"{name, ids?} — text fields replace, keywords are added; one undo step",
always,
apply
),
]
}
+289 -44
View File
@@ -1,33 +1,59 @@
//! Missing originals: photos whose file is no longer where the library expects it (moved,
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
//! searching a folder for files with the same name and size (`library.findMissing`).
//! searching a folder for files with the same name and size, or the same content
//! (`library.findMissing`).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId, Source};
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
///
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
/// them in the catalog, they never show in library views (Missing Photos included), and a
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
/// `library.missing` and Find Missing Photos all use this one rule.
pub fn checked_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } if p.in_library() => Some(path),
_ => None,
}
}
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
pub fn candidates(cat: &Catalog) -> Vec<String> {
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
}
/// [`missing`] with the existence check supplied (tests count the file-system calls).
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
}
/// Library photos whose original file can't be found: (id, path).
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
s.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
_ => None,
})
.collect()
missing_with(&s.catalog, |f| Path::new(f).exists())
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
/// 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 {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
@@ -42,53 +68,272 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "only photos from files can be relinked"));
}
let abs = abs.to_string_lossy().to_string();
s.commit("Relink Photo", relink_op(id, &abs))?;
// the file may differ from the one imported (another export, a raw that decodes now or not):
// its size, dimensions and preview-only state follow it
let mut ops = vec![relink_op(id, &abs)];
if s.media.file_probe.is_some()
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
{
ops.extend(crate::cmd::convert::content_op(id, ph, info));
}
let op = match <[Op; 1]>::try_from(ops) {
Ok([op]) => op,
Err(ops) => Op::Batch { ops },
};
s.commit("Relink Photo", op)?;
s.media.forget(id);
Ok(json!({"id": id.0, "path": abs}))
}
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
/// library knows it — and relink every match in one undoable step.
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`"))?;
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
fn stored_hash(p: &Photo) -> Option<&str> {
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
}
/// The content hash of the file at `path` (as import computes it), if it can be read.
fn file_hash(path: &str) -> Option<String> {
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
}
/// 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;
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
/// read, so this stays cheap);
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
///
/// Files that are already some photo's original, or that another photo was just matched with,
/// are not used.
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}));
return Ok(FindPlan::default());
}
// file name (lower case) → candidate paths
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();
for f in crate::import::expand(&[folder.to_string()], None) {
if let Some(n) = Path::new(&f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
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());
}
}
let mut ops = Vec::new();
let mut found = Vec::new();
for (id, old) in &lost {
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
let hit = by_name.get(&name).and_then(|c| {
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
});
if let Some(path) = hit {
ops.push(relink_op(*id, path));
found.push(json!({"id": id.0, "from": old, "to": path}));
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
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 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(|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, 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
&& size > 0
{
let sizes = by_size.get_or_insert_with(|| {
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
for f in &files {
if let Some(n) = size_of(f) {
m.entry(n).or_default().push(f.clone());
}
}
m
});
hit = sizes
.get(&size)
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
.map(|f| (f.clone(), MatchBy::Content));
}
}
let still = lost.len() - found.len();
if !ops.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops })?;
for f in &found {
if let Some(id) = f["id"].as_u64() {
s.media.forget(PhotoId(id));
// 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.as_slice() {
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
[] => {}
_ => {
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 };
used.insert(path.clone());
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
}
Ok(json!({"found": found, "missing": still}))
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 {
s.media.forget(f.id);
}
}
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> {
@@ -110,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Missing Photos",
[],
None,
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
"{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
),
+20 -5
View File
@@ -5,20 +5,27 @@
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
mod before;
mod browse;
pub(crate) mod browse;
mod color;
pub(crate) mod convert;
mod cull;
pub mod curves;
mod develop;
mod edit;
mod export;
mod keywords;
mod library;
mod manage;
pub mod filters;
pub mod keywords;
pub mod library;
pub mod lut_profiles;
pub mod manage;
mod masks;
mod merge;
mod missing;
pub mod metadata;
pub mod missing;
mod organize;
mod prefs;
mod preset_files;
pub mod previews;
mod query;
mod xmp;
@@ -112,10 +119,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(library::specs());
v.extend(develop::specs());
v.extend(color::specs());
v.extend(curves::specs());
v.extend(masks::specs());
v.extend(organize::specs());
v.extend(keywords::specs());
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());
@@ -125,6 +138,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v
})
}
+30 -6
View File
@@ -4,7 +4,7 @@
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
use crate::{Result, Selection, Session};
/// Stacks containing any of `targets`, in first-seen order.
@@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 {
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
/// copy is visible).
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
/// copy is visible). `name` replaces the default "Copy N".
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
let master = orig.copy_of.unwrap_or(src);
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
@@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
let mut c = (*orig).clone();
c.id = id;
c.copy_of = Some(master);
c.copy_name = Some(format!("Copy {n}"));
c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string));
c.versions.clear();
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
c.deleted = false;
@@ -108,13 +108,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Virtual Copy",
["Photo"],
Some("Cmd+'"),
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let mut made = Vec::new();
for t in targets {
if let Some(id) = virtual_copy(s, t)? {
if let Some(id) = virtual_copy(s, t, name.as_deref())? {
made.push(id);
}
}
@@ -164,6 +165,29 @@ pub fn specs() -> Vec<CommandSpec> {
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, -1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, 1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!(
"stack.split",
"Split Stack",
["Photo", "Stack"],
None,
"{id?} — this photo and the ones after it become their own stack",
in_stack,
|s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
}
),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p));
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
+18 -1
View File
@@ -14,6 +14,7 @@ fn current(s: &Session) -> Value {
"xmp": s.xmp,
"import": s.import_defaults,
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
"forgetLocalDays": s.forget_local_days,
"persistent": s.library.is_some(),
})
}
@@ -48,6 +49,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = i.get("creator").and_then(Value::as_str) {
d.creator = v.trim().to_string();
}
match i.get("metadataPreset") {
Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None,
Some(Value::String(n)) => {
if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) {
return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)")));
}
d.metadata_preset = Some(n.clone());
}
Some(Value::Null) => d.metadata_preset = None,
_ => {}
}
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
d.cameras.clear();
for c in a {
@@ -75,6 +87,11 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
changed = true;
}
s.import_defaults = d;
if let Some(v) = p.get("forgetLocalDays") {
let days = v.as_u64().ok_or_else(|| bad(ID, "forgetLocalDays must be a whole number of days (0 = never)"))?;
s.forget_local_days = days.min(36_500) as u32;
changed = true;
}
if let Some(mb) = p.get("cacheMb") {
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
@@ -90,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
always,
prefs
)]
+28 -14
View File
@@ -1,9 +1,10 @@
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
use crate::{Result, Session};
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
@@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let mut imported = Vec::new();
let mut failed = Vec::new();
let mut skipped = 0usize;
for f in expand_preset_paths(&paths) {
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
match parsed {
Ok(list) => {
let n = list.len();
let ids = s.add_presets(list);
skipped += n - ids.len();
for id in ids {
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
for top in &paths {
let from_dir = std::path::Path::new(top).is_dir();
for f in expand_preset_paths(std::slice::from_ref(top)) {
// presets in a folder go to a group named after it (unless the file names its own)
// the path from the imported folder (inclusive) to the file's folder
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
match parsed {
Ok(list) => {
for mut it in list {
if let Some(g) = &group {
it.preset.group = g.clone();
}
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
match id {
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
None => skipped += 1,
}
}
}
Err(e) => failed.push(json!([f, e])),
}
Err(e) => failed.push(json!([f, e])),
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
@@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], skipped, failed}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
always,
import
),
+362
View File
@@ -0,0 +1,362 @@
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
//! with `wait` (CLI, MCP, tests).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::media::{RenderJob, THUMB_SIZES};
use crate::{Result, Session};
/// The long edge of a standard-sized preview.
pub const STANDARD_EDGE: usize = 2048;
/// A preview build in progress.
#[derive(Debug, Default)]
pub struct PreviewBuild {
pub total: usize,
pub done: AtomicUsize,
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 {
pub fn json(&self) -> Value {
json!({
"total": self.total,
"done": self.done.load(Ordering::Relaxed),
"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
/// `edge` (`None` = 1:1, the photo's full size).
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
let full = p.width.max(p.height) as usize;
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
let mut v = Vec::new();
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
v.extend(s.loupe_job(id, e, e, true));
v
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
for photo_jobs in jobs {
if state.cancel.load(Ordering::Relaxed) {
break;
}
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
}
state.done.fetch_add(1, Ordering::Relaxed);
}
state.finished.store(true, Ordering::Relaxed);
}
fn build(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.buildPreviews";
let edge = match str_param(p, "size").unwrap_or("standard") {
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
"full" | "1:1" => None,
other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
};
if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
// explicit ids, else the selection, else everything in view
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
if wait {
run_all(jobs, &state);
return Ok(state.json());
}
#[cfg(not(target_arch = "wasm32"))]
{
let st = state.clone();
std::thread::Builder::new()
.name("lc-build-previews".into())
.spawn(move || run_all(jobs, &st))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
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. 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 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));
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source));
}
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
/// used now (a chosen folder on an unplugged drive is `available: false`).
#[cfg(not(target_arch = "wasm32"))]
fn location_json(s: &Session) -> Value {
let dir = s.media.smart_dir.clone();
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
json!({
"path": dir.as_ref().map(|d| d.to_string_lossy()),
"default": default.map(|d| d.to_string_lossy().to_string()),
"custom": s.smart_previews_dir.is_some(),
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
"count": count,
"bytes": bytes,
})
}
/// Show or change where smart previews are kept (per library).
#[cfg(not(target_arch = "wasm32"))]
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.smartPreviewsLocation";
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
if !reset && chosen.is_none() {
return Ok(location_json(s));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let default = crate::smart::dir(&lib_dir);
let (new_dir, custom) = match chosen {
Some(x) if !reset => {
let d = std::path::PathBuf::from(x.trim());
if !d.is_absolute() {
return Err(bad(C, "path must be an absolute folder path"));
}
(d.clone(), (d != default).then_some(d))
}
_ => (default, None),
};
if new_dir == current {
return Ok(location_json(s));
}
// the new place must work before anything moves; there is no fallback to another drive
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
let (held, _) = crate::smart::stats(&current);
let existing = match str_param(p, "existing") {
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
None if held == 0 => Some(crate::smart::Existing::Leave),
None => None,
};
let Some(existing) = existing else {
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
};
let (moved, failed) = crate::smart::migrate(&current, &new_dir, existing);
s.smart_previews_dir = custom;
s.media.smart_dir = Some(new_dir);
s.save_prefs()?;
let mut r = location_json(s);
r["handled"] = json!(moved);
r["failed"] = json!(failed);
Ok(r)
}
/// Whether photo `id` has a smart preview.
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
match (&s.media.smart_dir, s.catalog.photo(id)) {
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
_ => false,
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.smartPreviews",
"Build Smart Previews",
[],
None,
"{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
),
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.smartPreviewsLocation",
"Smart Previews Location",
[],
None,
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
always,
smart_location
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
_ => true,
};
Ok(json!({"smartPreview": has_smart_preview(s, id), "originalOnline": online}))
}),
cmd!(
"library.buildPreviews",
"Build Previews",
[],
None,
"{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}",
always,
build
),
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, _| {
if let Some(b) = &s.preview_build {
b.cancel.store(true, Ordering::Relaxed);
}
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
]
}
+33 -2
View File
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
"deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name,
// an undecodable raw variant, shown and edited from its embedded JPEG: why
"previewOnly": p.preview_only,
})
}
@@ -95,6 +97,29 @@ pub fn specs() -> Vec<CommandSpec> {
"clipboard": s.clipboard.is_some(),
}))
}),
cmd!(query "photo.allMetadata", "All Metadata", [], None, "{id?} → {exif: [{group, tag, name, value}], xmp: [{name, value}]} — every EXIF / TIFF / GPS tag of the file and its XMP properties", always, |s, p| {
let id = photo_arg(s, p, "photo.allMetadata")?;
let ph = s.catalog.photo(id).ok_or_else(|| super::bad("photo.allMetadata", "no such photo"))?.clone();
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Ok(json!({"exif": [], "xmp": [], "note": "a generated demo photo has no file"}));
};
let bytes = match &s.media.file_bytes {
Some(r) => r(path),
None => std::fs::read(path).map_err(|e| format!("{path}: {e}")),
}
.map_err(|e| super::bad("photo.allMetadata", e))?;
let exif: Vec<Value> = lightcraft_meta::file_tag_rows(&bytes)
.into_iter()
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
.collect();
// XMP: the sidecar if there is one, else the file's own packet
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
let xmp: Vec<Value> = packet
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
.unwrap_or_default();
Ok(json!({"exif": exif, "xmp": xmp}))
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
@@ -161,11 +186,17 @@ pub fn specs() -> Vec<CommandSpec> {
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process)", always, |_, p| {
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
lightcraft_gpu::set_enabled(on);
}
Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()}))
Ok(json!({
"enabled": lightcraft_gpu::enabled(),
"available": lightcraft_gpu::available(),
"adapter": lightcraft_gpu::adapter_name(),
"reason": lightcraft_gpu::unavailable_reason(),
"lastFallback": lightcraft_gpu::last_fallback(),
}))
}),
cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| {
Ok(serde_json::to_value(s.memory_report()).unwrap_or_default())
+16 -4
View File
@@ -9,14 +9,26 @@ use crate::{Result, Session};
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut written = Vec::new();
let mut merged = Vec::new();
let mut backups = Vec::new();
let mut failed = Vec::new();
let owners = crate::sidecar::StemOwners::of(&s.catalog);
for id in s.targets(p) {
match s.save_sidecar(id) {
Ok(path) => written.push(path.display().to_string()),
match s.save_sidecar_with(id, &owners) {
Ok(r) => {
let path = r.path.display().to_string();
if r.merged {
merged.push(path.clone());
}
if let Some(b) = r.backup {
backups.push(json!({"path": path, "backup": b.display().to_string()}));
}
written.push(path);
}
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
}
}
Ok(json!({"written": written, "failed": failed}))
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Metadata to File",
["Photo"],
Some("Cmd+S"),
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
has_selection,
save
),
+78
View File
@@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
}
thread_local! {
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
}
fn note(k: &str) {
READ.with(|r| {
if let Some(s) = r.borrow_mut().as_mut() {
s.insert(k.to_string());
}
});
}
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
note(k);
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
}
@@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
fn curve(props: &Props, k: &str) -> Option<Value> {
note(k);
let items = props.get(k)?;
let mut pts = Vec::new();
for it in items {
@@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "Shadows2012", "light.shadows");
n(o, "Whites2012", "light.whites");
n(o, "Blacks2012", "light.blacks");
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
// presets written by newer versions keep the old fields at their defaults: ignore them there
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
let v = num(props, &format!("crs:{crs}"));
if !pv2012
&& !has(o, path)
&& let Some(v) = v
{
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
}
};
old(o, "Exposure", "light.exposure", &|v| v);
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
old(o, "FillLight", "light.shadows", &|v| v);
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
// ---- Presence
n(o, "Texture", "effects.texture");
n(o, "Clarity2012", "effects.clarity");
old(o, "Clarity", "effects.clarity", &|v| v);
n(o, "Dehaze", "effects.dehaze");
n(o, "Vibrance", "color.vibrance");
n(o, "Saturation", "color.saturation");
@@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "ParametricMidtoneSplit", "curve.split_mid");
n(o, "ParametricHighlightSplit", "curve.split_highlights");
n(o, "CurveRefineSaturation", "curve.refine_saturation");
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
// the older single curve, used when no 2012 curve is present
if !props.contains_key("crs:ToneCurvePV2012") {
put(o, "curve.master", c);
}
}
for (crs, ch) in
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
{
@@ -310,6 +352,42 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
out
}
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
///
/// With `values` (the packet's structured properties), local corrections become masks too
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
/// can't be carried over are reported as `Mask: <kind>`.
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
let mut out = to_partial(props, raw);
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut mask_skips = Vec::new();
if let Some(values) = values {
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
if !masks.is_empty() {
put(&mut out, "masks", Value::Array(masks));
}
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
let mut unmapped: Vec<String> = props
.keys()
.filter(|k| k.starts_with("crs:"))
.map(|k| k.split('/').next().unwrap_or(k))
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
.map(|k| k.trim_start_matches("crs:").to_string())
.filter(|k| !quiet(k))
.collect();
unmapped.dedup();
unmapped.extend(mask_skips);
(out, unmapped)
}
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
/// Returns `None` when the packet has no adjustments we understand.
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
+379
View File
@@ -0,0 +1,379 @@
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
//!
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
//!
//! Implemented from the public XMP data model and black-box observation; best effort — the
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
//! fields evidently mean. Unsupported components are reported, never guessed.
use std::collections::BTreeMap;
use lightcraft_meta::XmpValue;
use serde_json::{Value, json};
/// Top-level properties keyed `prefix:name`, with structure.
pub type Values = BTreeMap<String, XmpValue>;
/// The containers this module reads.
pub const CONTAINERS: [&str; 4] =
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
}
fn num(v: &XmpValue, k: &str) -> Option<f64> {
let s = text(v, k)?;
let s = s.strip_prefix('+').unwrap_or(s);
s.parse::<f64>().ok().filter(|x| x.is_finite())
}
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
}
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
const LOCAL: &[(&str, &str, f64)] = &[
("LocalExposure2012", "exposure", 4.0),
("LocalContrast2012", "contrast", 100.0),
("LocalHighlights2012", "highlights", 100.0),
("LocalShadows2012", "shadows", 100.0),
("LocalWhites2012", "whites", 100.0),
("LocalBlacks2012", "blacks", 100.0),
("LocalClarity2012", "clarity", 100.0),
("LocalTexture", "texture", 100.0),
("LocalDehaze", "dehaze", 100.0),
("LocalTemperature", "temp", 100.0),
("LocalTint", "tint", 100.0),
("LocalSaturation", "saturation", 100.0),
("LocalHue", "hue", 100.0),
("LocalSharpness", "sharpness", 100.0),
("LocalLuminanceNoise", "noise", 100.0),
("LocalMoire", "moire", 100.0),
("LocalDefringe", "defringe", 100.0),
("LocalToningSaturation", "color_sat", 100.0),
("LocalToningHue", "color_hue", 1.0),
// older process versions
("LocalExposure", "exposure", 4.0),
("LocalContrast", "contrast", 100.0),
("LocalClarity", "clarity", 100.0),
];
fn adjust(c: &XmpValue) -> Value {
let mut a = serde_json::Map::new();
for (crs, ours, k) in LOCAL {
if a.contains_key(*ours) {
continue;
}
if let Some(v) = num(c, &format!("crs:{crs}")) {
let lim = if *ours == "exposure" {
4.0
} else if *ours == "color_hue" {
360.0
} else {
100.0
};
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
a.insert((*ours).into(), json!(v));
}
}
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
Value::Object(a)
}
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
let y = l.clamp(1e-4, 1.0).powf(2.2);
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
}
fn pt(x: f64, y: f64) -> Value {
json!({"x": x, "y": y})
}
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
let what = text(m, "crs:What").unwrap_or("");
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
1 => "subtract",
2 => "intersect",
_ => "add",
};
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
// positions are fractions of the image's width / height; our radii are in long-edge units
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
let shape = match what {
"Mask/Gradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
}
"Mask/CircularGradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
json!({
"kind": "radial",
"center": pt((l + r) / 2.0, (t + b) / 2.0),
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
"angle": g("Angle", 0.0),
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
"invert": flag(m, "crs:Flipped").unwrap_or(false),
})
}
"Mask/Paint" => {
let pts: Vec<Value> = m
.field("crs:Dabs")
.map(XmpValue::items)
.unwrap_or_default()
.iter()
.filter_map(XmpValue::text)
.filter_map(|d| {
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
Some(pt(it.next()?, it.next()?))
})
.collect();
if pts.is_empty() {
return Err("empty brush".into());
}
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
json!({"kind": "brush", "strokes": [{
"points": pts,
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
"erase": erase,
}]})
}
"Mask/Image" => match text(m, "crs:MaskSubType") {
Some("1") => json!({"kind": "subject"}),
Some("2") => json!({"kind": "sky"}),
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
},
"Mask/RangeMask" => {
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
match text(r, "crs:Type") {
Some("2") => {
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
}
Some("3") => {
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
}
_ => return Err("colour range".into()),
}
}
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
};
Ok(json!({"op": op, "invert": invert, "shape": shape}))
}
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut skipped = Vec::new();
for container in CONTAINERS {
let Some(list) = values.get(container) else { continue };
for c in list.items() {
if flag(c, "crs:CorrectionActive") == Some(false) {
continue;
}
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
let mut parts = Vec::new();
for m in comps {
match component(m, aspect) {
Ok(v) => parts.push(v),
Err(kind) => skipped.push(kind),
}
}
if parts.is_empty() {
continue;
}
// brush dabs of one correction are one brush component
let mut merged: Vec<Value> = Vec::new();
for p in parts {
let is_brush = p["shape"]["kind"] == "brush";
if is_brush
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
{
a.extend(b.iter().cloned());
continue;
}
merged.push(p);
}
let n = out.len() + 1;
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
}
}
skipped.sort();
skipped.dedup();
(out, skipped)
}
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
use crate::preset_import::Lua;
match v {
Lua::Nil => XmpValue::Text(String::new()),
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
Lua::Num(n) => XmpValue::Text(format!("{n}")),
Lua::Str(s) => XmpValue::Text(s.clone()),
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
}
}
/// The aspect radial masks are computed for when the target's shape isn't known yet.
pub const DEFAULT_ASPECT: f64 = 1.5;
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
for m in masks {
for c in m["components"].as_array_mut().into_iter().flatten() {
let s = &mut c["shape"];
if s["kind"] == "radial" {
if let Some(rx) = s["rx"].as_f64() {
s["rx"] = json!(rx / fx * tx);
}
if let Some(ry) = s["ry"].as_f64() {
s["ry"] = json!(ry / fy * ty);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
}
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more