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 ThomasandClaude Opus 5.5 7b59081d22 Issue #104: keep file-system calls off the UI thread
Re-applies fix/issue-104-ui-fs on top of the data-safety fixes (integrate/safety):
- availability cache: originals and smart previews are checked on a worker thread; the UI
  answers from the cache (grid, Info panel, photo menu, Missing Photos view)
- import split into ImportJob::prepare (worker) + commit_prepared, with Cancel; Copy keeps
  #96's verified copies
- Find Missing Photos: find_candidates (snapshot, no I/O) + plan_find_missing (all file I/O,
  on a worker, never the session) with #105's matching rules unchanged (name+size+hash;
  renamed by size+hash; single name+size; ambiguous look-alikes skipped and reported; files
  already in use excluded). The plan is applied with library.findMissing {found, missing,
  ambiguous}, re-checked against the catalog then: photos relinked meanwhile are left alone,
  files now in use are not taken. library.findMissing {folder} still runs synchronously.
- Build / Discard Smart Previews with background: true runs on a worker with progress and
  Stop, keeping #106's atomic writes, is_valid checks and `repaired` count (also in
  library.previewProgress); the render worker's smart-preview fallback uses is_valid
- Rename preview planned on a worker (plan_rename_photos) on the same planning core as
  Session::plan_rename_with (MoveFs, case-only respelling by file identity, #95)
- Local folder listing via fs_cached; tests_offline (slow injected probe, frames < 300 ms)

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

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

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

No files matched your search

+7
View File
@@ -27,6 +27,12 @@ jobs:
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:
@@ -44,5 +50,6 @@ jobs:
# 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'
+4
View File
@@ -7,3 +7,7 @@ plan/
/log
.DS_Store
.claude/
/craft-fonts
# model weights are never committed (SAM 3: docs/ai-masks.md)
*.safetensors
*.safetensors.part
+12 -1
View File
@@ -62,10 +62,21 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## 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
+12 -1
View File
@@ -62,10 +62,21 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## 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
Generated
+1476 -30
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -29,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"
@@ -62,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"
+17 -3
View File
@@ -15,6 +15,20 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
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.
BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors
(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1;
see assets/fonts/OFL-BIZUDMincho.txt.
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.
+22 -2
View File
@@ -334,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).
@@ -356,7 +369,8 @@ 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
@@ -420,7 +434,9 @@ with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTIO
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 1.1). All icons are original.
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
@@ -433,3 +449,7 @@ Forks and modified versions must remove them.
<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)
+23 -16
View File
@@ -23,30 +23,31 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| 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. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **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; no localisation; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; 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 (muted colour on every photo) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| 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% | No segmentation or AI denoise models |
| 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. Raises colour quality for every non-DNG raw at once.
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.
@@ -56,7 +57,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
(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.
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.
@@ -81,12 +84,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW 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 (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -160,7 +163,8 @@ The milestone estimates in the table above were made before work started and are
## 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, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), 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.
@@ -175,7 +179,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
@@ -190,11 +194,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
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.
## Japanese interface and text watermarks
## Chinese and Japanese interfaces, and text watermarks
English/Japanese interface language is persisted in UI state. Core menus have Japanese
translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled
under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept
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
+153 -31
View File
@@ -9,6 +9,7 @@
//! 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)]
@@ -84,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
";
@@ -125,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(())
@@ -144,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;
@@ -416,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| 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())?;
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 {
@@ -474,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;
+46
View File
@@ -183,6 +183,42 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
assert_eq!((d.width, d.height), (640, 480));
}
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
fn snapshot_starts_without_a_gpu() {
let script = tmp("nogpu.jsonl");
std::fs::write(
&script,
format!(
"{}\n{}\n{}\n",
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
json!({"method": "engine.execute", "params": {"command": "app.gpu"}}),
json!({"method": "ui.screenshot"}),
),
)
.unwrap();
for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] {
let out = tmp(&format!("nogpu-{var}.png"));
let o = Command::new(BIN)
.env_remove("LIGHTCRAFT_GPU")
.env_remove("LIGHTCRAFT_GPU_BACKEND")
.env(var, value)
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"])
.output()
.unwrap();
assert!(o.status.success(), "{var}: {}", String::from_utf8_lossy(&o.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
let gpu = &replies[1]["result"];
assert_eq!(gpu["available"], false, "{var}: {gpu}");
assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}");
assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}");
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
assert_eq!((d.width, d.height), (480, 320));
}
}
fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
if let Some(text) = stdin {
@@ -288,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() {
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();
+3
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backup;
pub mod bench;
pub mod files;
pub mod store;
@@ -24,6 +25,8 @@ pub mod wire;
#[cfg(target_arch = "wasm32")]
mod backend;
#[cfg(target_arch = "wasm32")]
mod safety;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
mod workers;
+256
View File
@@ -0,0 +1,256 @@
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
//! instead of a dead page after a panic, and backup / restore of the whole library
//! ([`crate::backup`]).
use std::cell::RefCell;
use std::collections::HashMap;
use js_sys::{Reflect, Uint8Array};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::backend::Backend;
use crate::backup::{
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
valid_hash, verify,
};
use crate::files::{Files, LIBRARY_FILES};
use crate::store::storage_key;
thread_local! {
/// Messages for the user, shown by the app on its next frame.
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
/// Queue a message for the user (shown for several seconds; also logged).
pub fn notice(text: impl Into<String>) {
let text = text.into();
log::warn!("{text}");
NOTICES.with(|n| n.borrow_mut().push(text));
crate::web::request_repaint();
}
/// Messages queued since the last call.
pub fn take_notices() -> Vec<String> {
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
}
fn document() -> Option<web_sys::Document> {
web_sys::window()?.document()
}
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
pub fn confirm(text: &str) -> bool {
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
}
/// Cover the page with a message (the app can't continue, or must not start).
pub fn show_blocking(text: &str) {
let Some(doc) = document() else { return };
let el = match doc.get_element_by_id("lightcraft_blocking") {
Some(el) => el,
None => {
let Ok(el) = doc.create_element("div") else { return };
el.set_id("lightcraft_blocking");
let _ = el.set_attribute(
"style",
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
);
if let Some(body) = doc.body() {
let _ = body.append_child(&el);
}
el
}
};
el.set_text_content(Some(text));
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
loading.remove();
}
}
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
/// frozen canvas, and say that the stored library is intact.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
(Some(s), _) => (*s).to_string(),
(_, Some(s)) => s.clone(),
_ => "unknown error".into(),
};
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
log::error!("LightCraft panicked{at}: {msg}");
show_blocking(&format!(
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
));
}));
}
/// The name of the lock that keeps a library to one tab.
const TAB_LOCK: &str = "lightcraft-library";
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
pub async fn acquire_tab_lock() -> Option<bool> {
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
let opts = js_sys::Object::new();
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
let ours = !lock.is_null() && !lock.is_undefined();
let _ = resolve.call1(&JsValue::NULL, &ours.into());
// keep the lock until the page goes away: a promise that never settles
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
});
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
log::warn!("Web Locks request failed: {e:?}");
}
});
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
}
/// Offer a blob as a download named `name`.
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
let e = |e: JsValue| format!("download failed: {e:?}");
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
let doc = document().ok_or("no document")?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
a.set_href(&url);
a.set_download(name);
a.click();
// revoke once the download has started
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
}
Ok(())
}
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
/// heap) and downloaded. `names`: content hash → the photo's file name.
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
let parts = js_sys::Array::new();
let mut zip = ZipWriter::default();
let mut bytes = 0f64;
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
let header = zip.entry(name, data)?;
parts.push(&Uint8Array::from(&header[..]));
parts.push(&Uint8Array::from(data));
bytes += (header.len() + data.len()) as f64;
Ok(())
};
add(&mut zip, "README.txt", README.as_bytes())?;
for name in LIBRARY_FILES {
if let Some(d) = files.get(name) {
add(&mut zip, &format!("library/{name}"), &d)?;
}
}
let mut originals = 0;
for hash in backend.list("originals").await? {
if !valid_hash(&hash) {
continue;
}
match backend.read(&storage_key(&hash)).await {
Ok(Some(data)) => {
let name = names.get(&hash).map_or("original", String::as_str);
add(&mut zip, &original_entry(&hash, name), &data)?;
originals += 1;
}
Ok(None) => {}
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
}
}
let tail = zip.finish()?;
parts.push(&Uint8Array::from(&tail[..]));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/zip");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
Ok((originals, bytes + tail.len() as f64))
}
/// Bytes `start..end` of a picked file.
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
Ok(Uint8Array::new(&buf).to_vec())
}
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
let len = file.size() as u64;
let tail = read_range(&file, len - tail_len(len), len).await?;
let (cd_offset, cd_size) = find_central(&tail)?;
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
let entries = parse_central(&cd)?;
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
}
let dir = restored_dir_name(js_sys::Date::now());
let mut written = 0usize;
for e in &entries {
let Some(key) = restore_key(&e.name, &dir) else { continue };
// originals are named by their content: one already stored is the same file
if key.starts_with("originals/") && backend.exists(&key).await? {
continue;
}
let local = read_range(&file, e.header, e.header + 30).await?;
let start = data_offset(e, &local)?;
let data = read_range(&file, start, start + e.size).await?;
verify(e, &data)?;
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
written += 1;
}
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
log::info!("lightcraft: restored {written} files into {dir}");
Ok(dir)
}
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
let ok = confirm(
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
The current library is not deleted: it stays in browser storage.",
);
if !ok {
return;
}
let Some(doc) = document() else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".zip,application/zip");
let inp = input.clone();
let mut before_reload = Some(before_reload);
let on_change = Closure::<dyn FnMut()>::new(move || {
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
let backend = backend.clone();
let before = before_reload.take();
notice(format!("Restoring {}…", file.name()));
wasm_bindgen_futures::spawn_local(async move {
match restore(file, backend).await {
Ok(_) => {
if let Some(f) = before {
f();
}
if let Some(w) = web_sys::window() {
let _ = w.location().reload();
}
}
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
}
});
});
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
on_change.forget();
input.click();
}
+142 -21
View File
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
use crate::bench::Bench;
use crate::files::{Files, FlushOp, LIBRARY_FILES};
use crate::safety::{self, notice};
use crate::store::{Originals, content_hash, download_name, storage_key};
use crate::wire::ThumbIndex;
use crate::workers::{THUMB_INDEX, Workers};
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
/// Where the library files live in browser storage.
const LIBRARY_DIR: &str = "library";
/// After a failed save, wait this long before trying again (ms).
const SAVE_RETRY_MS: f64 = 2000.0;
/// How long a notice stays on screen (s).
const NOTICE_SECS: f64 = 8.0;
/// How often view state, UI prefs and the thumbnail index are saved (ms).
const SAVE_EVERY_MS: f64 = 1000.0;
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
}
}
fn library_key(name: &str) -> String {
format!("{LIBRARY_DIR}/{name}")
/// Storage key of library file `name` in library folder `dir`.
fn library_key(dir: &str, name: &str) -> String {
format!("{dir}/{name}")
}
/// Repaint the app (notices from async tasks).
pub(crate) fn request_repaint() {
CTX.with(|c| {
if let Some(c) = c.borrow().as_ref() {
c.request_repaint();
}
});
}
/// URL options (`?bench&store=idb&workers=2&reset`).
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
if let Some(b) = &backend
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
{
// still importable for this session; it won't survive a reload
log::error!("storing {name} in browser storage failed: {e}");
// a photo whose bytes aren't stored would be gone after a reload: don't add it
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
return;
}
originals.added(&name, &hash, Arc::from(bytes));
ctx.request_repaint();
@@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(())
}
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
let (backup_backend, backup_files) = (backend.clone(), files);
let restore_backend = backend.clone();
Services {
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
let names: std::collections::HashMap<String, String> = session
.catalog
.photos()
.filter_map(|p| match &p.source {
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
_ => None,
})
.collect();
let files = backup_files.clone();
notice("Backing up the library… (the download starts when it's ready)");
wasm_bindgen_futures::spawn_local(async move {
match safety::backup(files, b, names).await {
Ok((n, bytes)) => notice(format!(
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
if n == 1 { "" } else { "s" },
bytes / 1e6
)),
Err(e) => notice(format!("Backup failed: {e}")),
}
});
Ok(json!({"started": true}))
})),
restore_library: Some(Box::new(move |_session: &mut Session| {
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
let frozen = frozen.clone();
// once the backup is in place, stop saving the old library (the page reloads)
safety::pick_and_restore(b, move || frozen.set(true));
Ok(json!({"started": true}))
})),
pick_files: Some(Box::new(move || {
open_picker(originals.clone(), backend.clone(), ctx.clone());
Vec::new() // files arrive asynchronously and are imported on a later frame
@@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
}
}
/// Write dirty library files to storage, in order, until none are left.
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
'outer: loop {
let ops = files.take_dirty();
if ops.is_empty() {
if files.error().is_some() {
log::info!("lightcraft: saving works again");
}
files.set_error(None);
break;
}
for (i, op) in ops.iter().enumerate() {
let r = match op {
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
};
if let Err(e) = r {
log::error!("saving {} failed: {e}", op.name());
files.failed(&ops[i..]);
files.set_error(Some(e));
failed_at.set(perf_now());
break 'outer;
}
}
@@ -204,6 +262,8 @@ struct Boot {
backend: Option<Backend>,
files: Files,
index: ThumbIndex,
/// The library folder in storage (`library`, or a restored backup's).
lib_dir: String,
}
async fn boot(opts: Options) -> Boot {
@@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot {
};
let files = Files::default();
let mut index = ThumbIndex::default();
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
if let Some(b) = &backend {
if opts.reset {
match b.clear().await {
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
Err(e) => log::error!("clearing storage: {e}"),
// everything this site keeps in the browser, photos included: never without asking
let ok = safety::confirm(
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
This can't be undone. Delete it?",
);
if ok {
match b.clear().await {
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
Err(e) => log::error!("clearing storage: {e}"),
}
} else {
log::info!("lightcraft: ?reset declined; storage kept");
}
}
// a restored backup lives in its own folder (see `backup`)
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
let d = String::from_utf8_lossy(&d).trim().to_string();
if valid_library_dir(&d) {
lib_dir = d;
}
}
for name in LIBRARY_FILES {
match b.read(&library_key(name)).await {
match b.read(&library_key(&lib_dir, name)).await {
Ok(Some(data)) => files.preload(name, data),
Ok(None) => {}
Err(e) => log::error!("reading {name}: {e}"),
@@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot {
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
index = ThumbIndex::from_json(&j);
}
crate::backend::request_persistence();
crate::backend::request_persistence(|granted| {
if !granted {
notice(
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
);
}
});
// thumbnails stored without an index entry (index saved late, or lost): delete them
let b2 = b.clone();
let known: std::collections::HashSet<String> = b
@@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
index.len(),
index.total() as f64 / 1e6
);
Boot { opts, backend, files, index }
Boot { opts, backend, files, index, lib_dir }
}
struct WebApp {
@@ -276,6 +360,11 @@ struct WebApp {
backend: Option<Backend>,
workers: Option<Workers>,
flushing: Rc<Cell<bool>>,
/// When the last save to storage failed (ms; 0 = never).
flush_failed_at: Rc<Cell<f64>>,
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
frozen: Rc<Cell<bool>>,
lib_dir: String,
bench: Option<Bench>,
first_frame_logged: bool,
last_save: f64,
@@ -284,13 +373,13 @@ struct WebApp {
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
let Boot { opts, backend, files, index } = boot;
let Boot { opts, backend, files, index, lib_dir } = boot;
let originals = Originals::default();
let t = perf_now();
let mut session = Session::new();
originals.install(&mut session);
let stores = LibraryStores {
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
catalog: Box::new(files.store()),
files: Box::new(files.store()),
on_disk: false,
@@ -319,9 +408,16 @@ impl WebApp {
});
}
}
if backend.is_some() {
notice(
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
elsewhere and back up with File ▸ Back Up Library….",
);
}
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
let frozen = Rc::new(Cell::new(false));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
app.ui = ui;
@@ -347,6 +443,9 @@ impl WebApp {
backend,
workers,
flushing: Rc::new(Cell::new(false)),
flush_failed_at: Rc::new(Cell::new(0.0)),
frozen,
lib_dir,
bench: opts.bench.then(|| Bench::new(origin)),
first_frame_logged: false,
last_save: 0.0,
@@ -408,6 +507,9 @@ impl WebApp {
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
fn save(&mut self) {
if self.frozen.get() {
return;
}
let now = perf_now();
if now - self.last_save >= SAVE_EVERY_MS {
self.last_save = now;
@@ -429,12 +531,16 @@ impl WebApp {
});
}
}
// after a failure, retry every couple of seconds rather than every frame
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
if let Some(b) = &self.backend
&& self.files.is_dirty()
&& !self.flushing.get()
&& !backing_off
{
self.flushing.set(true);
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
}
}
}
@@ -464,8 +570,10 @@ impl WebApp {
let (orig_n, orig_bytes) = self.originals.usage();
json!({
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
"library": self.lib_dir,
"dirty": self.files.is_dirty(),
"flushing": self.flushing.get(),
"saveError": self.files.error(),
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
"originalsInMemory": orig_n,
"originalBytesInMemory": orig_bytes,
@@ -490,6 +598,10 @@ impl WebApp {
impl eframe::App for WebApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for text in safety::take_notices() {
let now = ctx.input(|i| i.time);
self.app.ui.toast = Some((text, now + NOTICE_SECS));
}
self.run_inbox();
self.import_dropped(ctx);
self.load_originals(ctx);
@@ -527,9 +639,18 @@ impl eframe::App for WebApp {
#[wasm_bindgen]
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
// one tab per library: two would each keep their own copy and overwrite each other's saves
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
safety::show_blocking(
"LightCraft is already open in another tab or window of this browser.\n\n\
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
);
return;
}
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
+24 -10
View File
@@ -15,10 +15,9 @@ use std::sync::Arc;
use lightcraft_engine::catalog::Source;
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::Lru;
use lightcraft_raster::Rgb32f;
use serde::{Deserialize, Serialize};
use crate::store::hash_of_path;
@@ -31,6 +30,8 @@ pub struct WireJob {
/// Decode the source at most this long.
pub max_edge: usize,
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
@@ -55,7 +56,7 @@ impl WireJob {
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Loaded(img) => img.width.max(img.height),
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
SourceRef::Smart { .. } => 2560,
};
WireJob {
@@ -63,6 +64,7 @@ impl WireJob {
origin: job.origin.clone(),
max_edge,
raw: job.info.raw,
relative_wb: job.info.relative_wb,
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
@@ -79,7 +81,14 @@ impl WireJob {
}
pub fn info(&self) -> SourceInfo {
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
SourceInfo {
raw: self.raw,
as_shot_temp: self.as_shot_temp,
as_shot_tint: self.as_shot_tint,
lens: self.lens,
relative_wb: self.relative_wb,
..Default::default()
}
}
pub fn request(&self) -> RenderRequest {
@@ -119,7 +128,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
/// The synchronous part of a worker: decoded-source cache + rendering.
pub struct WorkerCore {
sources: Lru<String, Arc<Rgb32f>>,
sources: Lru<String, DecodedSource>,
media: MediaCache,
stages: HashMap<String, Arc<StageCache>>,
}
@@ -150,21 +159,25 @@ impl WorkerCore {
Some(s) => s,
None => {
let src = match (&job.origin, original) {
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
(Source::File { .. }, Some(bytes)) => {
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
DecodedSource::new(Arc::new(image), Some(info))
}
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
};
let cost = src.width * src.height * 12 + 64;
let cost = src.image.width * src.image.height * 12 + 64;
self.sources.insert(key, src.clone(), cost);
src
}
};
let info = src.info_or(job.info());
Ok(match &job.stages {
Some(view) => {
let st = self.stages.entry(view.clone()).or_default().clone();
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
}
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
})
}
}
@@ -295,6 +308,7 @@ mod tests {
origin,
max_edge: 512,
raw: false,
relative_wb: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
+1 -1
View File
@@ -10,7 +10,7 @@ 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 }
+142 -23
View File
@@ -71,17 +71,45 @@ impl eframe::App for App {
}
}
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
}
/// Written while the GPU compute device is created, removed once that returned
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
fn gpu_marker_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("gpu-init.marker"))
}
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
/// driver while creating the device), the notice to show — GPU rendering then starts off.
fn gpu_crash_check() -> Option<String> {
let marker = gpu_marker_path();
let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
lightcraft_engine::gpu::backend::set_init_marker(marker);
left.map(|what| gpu_crash_notice(&what))
}
fn gpu_crash_notice(what: &str) -> String {
format!(
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
)
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::camera_profiles::config_dir()
}
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
@@ -202,7 +230,9 @@ impl PrefsWriter {
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
@@ -254,7 +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",
@@ -268,8 +298,11 @@ fn services() -> Services {
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -278,24 +311,24 @@ fn services() -> Services {
})),
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Auto-Tag from Tracklog")
.add_filter("GPS Track Log", &["gpx"])
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Presets")
.add_filter("LightCraft Preset", &["lcpreset"])
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve", "json"])
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -304,8 +337,8 @@ fn services() -> Services {
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Point Curve Presets")
.add_filter("Point Curve Presets", &["lccurve"])
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -316,6 +349,9 @@ fn services() -> Services {
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
}
}
@@ -366,10 +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();
@@ -408,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")
@@ -420,17 +497,25 @@ 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, problem) = 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
@@ -440,6 +525,11 @@ fn main() -> eframe::Result {
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -497,6 +587,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
#[test]
fn gpu_crash_marker_is_reported_once() {
let d = dir("gpu-marker");
let m = d.join("gpu-init.marker");
assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None);
std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap();
let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap();
let notice = gpu_crash_notice(&what);
assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}");
assert!(!m.exists());
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default.
#[test]
fn window_backends_follow_the_platform_default() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
let b = n.instance_descriptor.backends;
if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() {
if cfg!(windows) {
assert_eq!(b, eframe::wgpu::Backends::DX12);
} else if cfg!(target_os = "macos") {
assert_eq!(b, eframe::wgpu::Backends::METAL);
}
}
assert!(!b.is_empty());
}
#[test]
fn ui_json_is_written_atomically() {
let d = dir("atomic");
+61 -22
View File
@@ -148,7 +148,7 @@ fn yields_to_text(sc: &str) -> bool {
/// `&` marks a mnemonic in muda labels.
fn label_text(s: &str) -> String {
s.replace('&', "&&")
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
}
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
out.push(';');
}
}
let mut s = String::new();
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -210,30 +210,30 @@ impl NativeMenu {
// the application menu
let app_menu = Submenu::new("LightCraft", true);
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
&PredefinedMenuItem::separator(),
&quit,
]);
let _ = self.menu.append(&app_menu);
for (title, nodes) in &bar {
let sub = Submenu::new(title, true);
if title == app.ui.language.tr("Window") {
let sub = Submenu::new(label_text(title), true);
if title == "Window" {
let _ = sub.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(Some("Zoom")),
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
&PredefinedMenuItem::separator(),
]);
}
@@ -245,15 +245,21 @@ impl NativeMenu {
.collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == app.ui.language.tr("File") {
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
let _ = sub.append_items(&[
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
]);
}
if title == app.ui.language.tr("Window") {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
if title == "Window" {
let _ = sub.append_items(&[
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
]);
sub.set_as_windows_menu_for_nsapp();
}
if title == app.ui.language.tr("Help") {
if title == "Help" {
sub.set_as_help_menu_for_nsapp();
}
let _ = self.menu.append(&sub);
@@ -282,12 +288,23 @@ impl NativeMenu {
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
let handle = match checked {
Some(c) => {
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
let it = CheckMenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
*c,
accel,
);
let _ = sub.append(&it);
Handle::Check(it)
}
None => {
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
let it = MenuItem::with_id(
key.clone(),
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
*enabled,
accel,
);
let _ = sub.append(&it);
Handle::Plain(it)
}
@@ -325,6 +342,7 @@ impl NativeMenu {
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
/// accelerators with the app state.
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
lightcraft_ui_egui::i18n::set_language(app.ui.language);
while let Ok(key) = self.rx.try_recv() {
if key == QUIT {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -352,8 +370,8 @@ impl NativeMenu {
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
if it.label != *label {
match &it.handle {
Handle::Plain(h) => h.set_text(label_text(label)),
Handle::Check(h) => h.set_text(label_text(label)),
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
}
it.label = label.clone();
}
@@ -401,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)));
+6 -2
View File
@@ -9,9 +9,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|---|---|---|---|---|---|---|
| `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/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none |
| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | 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 |
@@ -20,7 +19,12 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `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
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+1 -1
View File
@@ -35,7 +35,7 @@ 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};
+28 -1
View File
@@ -183,6 +183,11 @@ pub struct Meta {
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)]
@@ -331,6 +336,12 @@ impl Photo {
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 {
@@ -342,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(),
};
@@ -415,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());
}
}
}
+70 -3
View File
@@ -39,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>,
@@ -113,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),
@@ -125,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 {
@@ -152,9 +161,14 @@ impl Filter {
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}"));
}
@@ -267,6 +281,11 @@ impl Filter {
{
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)
{
@@ -330,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)]
+79 -8
View File
@@ -1,11 +1,23 @@
//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and
//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]).
//!
//! **Write policy** (issue #134): what can't be recreated — originals, XMP sidecars, the catalog,
//! settings, DNGs that replace raws, smart previews (the only stand-in while an original is
//! offline), an edit copy that becomes a library photo — is written **durably**: synced to disk
//! before it counts as written. What can always be made again from the originals — exports,
//! renders, screenshots, merge previews — is written **atomically** only: a half-written file
//! never replaces a good one, but there is no per-file sync (on a USB drive or a NAS that sync
//! dominates a large export).
//!
//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive
//! either the old or the complete new content is there — never a truncated mix. The content
//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and
//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp
//! file and leaves the target as it was.
//! - [`write_atomic_nosync`] is the same temp file + rename without the two syncs, for
//! recreatable outputs only: a failed write still removes the temp file and leaves the target as
//! it was, but after a power cut the new file may be lost (on some file systems left empty) —
//! it is then simply exported again.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
@@ -21,6 +33,19 @@ use std::sync::atomic::{AtomicU64, Ordering};
thread_local! {
static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
static SYNCS: Cell<u64> = const { Cell::new(0) };
}
/// How many file and folder syncs this module made **on the calling thread** (for tests: a
/// durable write path syncs, a recreatable output doesn't).
#[doc(hidden)]
pub fn syncs_on_this_thread() -> u64 {
SYNCS.with(Cell::get)
}
fn sync_file(f: &File) -> io::Result<()> {
SYNCS.with(|c| c.set(c.get() + 1));
f.sync_all()
}
/// Fault injection for tests (in any crate): while the returned guard lives, every write made by
@@ -78,7 +103,7 @@ fn parent_of(path: &Path) -> &Path {
/// where NTFS journals the rename itself, so errors are ignored.
pub fn sync_dir(dir: &Path) {
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
let _ = sync_file(&d);
}
}
@@ -100,9 +125,9 @@ fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display())))
}
/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp
/// file is removed. Returns its path and length.
fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
/// Write a temp file next to `path` with what `fill` writes, synced to disk when `sync`. On
/// failure the temp file is removed. Returns its path and length.
fn write_temp(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
let (tmp, file) = create_temp(path)?;
let r = (|| {
let limit = FAIL_AFTER.with(Cell::get);
@@ -111,7 +136,9 @@ fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()
w.flush()?;
let n = w.n;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
f.sync_all()?;
if sync {
sync_file(&f)?;
}
Ok(n)
})();
match r {
@@ -132,12 +159,26 @@ pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old
/// file). Returns the length written.
pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
let (tmp, n) = write_temp(path, fill)?;
replace_with(path, true, fill)
}
/// Replace (or create) `path` with `data` atomically but **without syncing** it to disk — only for
/// outputs that can always be recreated (exports, renders, screenshots); see the module docs.
/// Anything that can't be made again goes through [`write_atomic`].
pub fn write_atomic_nosync(path: &Path, data: &[u8]) -> io::Result<()> {
replace_with(path, false, &mut |w| w.write_all(data)).map(|_| ())
}
/// Temp file (synced when `sync`) renamed over `path`, then the folder synced when `sync`.
fn replace_with(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
let (tmp, n) = write_temp(path, sync, fill)?;
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(parent_of(path));
if sync {
sync_dir(parent_of(path));
}
Ok(n)
}
@@ -155,8 +196,11 @@ pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u
let Some(first) = candidates.peek().cloned() else {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write"));
};
let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?;
let (tmp, _) = write_temp(&first, true, &mut |w| w.write_all(data))?;
let r = (|| {
// Kept (issue #134 point 4): these files replace raws (Convert / Copy as DNG) or become
// library photos (merges). On a local disk the read usually comes from the cache, but on a
// network volume it can go back to the server, and next to encoding a DNG it costs little.
if !same_content(&tmp, data)? {
return Err(io::Error::other("the written file reads back different (failing drive or connection?)"));
}
@@ -287,6 +331,33 @@ mod tests {
let _ = fs::remove_dir_all(&d);
}
#[test]
fn recreatable_outputs_are_atomic_without_a_sync() {
let d = temp("nosync");
let p = d.join("export.jpg");
let before = syncs_on_this_thread();
write_atomic_nosync(&p, b"one").unwrap();
write_atomic_nosync(&p, b"two two").unwrap();
assert_eq!(syncs_on_this_thread(), before, "no file or folder sync");
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["export.jpg"], "no temp file left");
// still atomic: a failure part-way leaves the previous file and no temp file
{
let _f = fail_writes_after(3);
assert!(write_atomic_nosync(&p, &[9u8; 10_000]).is_err());
}
assert_eq!(fs::read(&p).unwrap(), b"two two");
assert_eq!(names(&d), vec!["export.jpg"]);
// the durable writers do sync (the file; the folder too where it can be opened)
let before = syncs_on_this_thread();
write_atomic(&p, b"three").unwrap();
let mid = syncs_on_this_thread();
assert!(mid > before);
write_new(&d.join("new.dng"), b"dng").unwrap();
assert!(syncs_on_this_thread() > mid);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn new_files_never_replace_anything() {
let d = temp("new");
+83
View File
@@ -297,3 +297,86 @@ fn folder_identity_ignores_spelling() {
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);
}
+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 }
+2
View File
@@ -8,10 +8,12 @@
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;
+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());
}
}
+25
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,
+5
View File
@@ -30,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());
}
}
+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);
+77 -5
View File
@@ -26,7 +26,12 @@ fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
/// written to a temp file, synced, read back identical and only then given its name — never
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match &s.media.file_bytes {
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
}
/// [`write_dng_for`] without the session (imports convert on a worker thread).
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match file_bytes {
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
@@ -144,7 +149,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
crate::export::write_file(path, bytes)?;
// durable: the TIFF becomes a library photo the external editor then changes
crate::export::write_file_durable(path, bytes)?;
written.push(path.to_string());
Ok(())
};
@@ -177,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
})
}
/// 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> {
@@ -193,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
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 };
let Some(op) = content_op(id, ph, info.clone()) 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()));
@@ -271,9 +314,9 @@ pub fn edit_specs() -> Vec<CommandSpec> {
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 → {reloaded}",
"{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
),
@@ -288,3 +331,32 @@ pub fn edit_specs() -> Vec<CommandSpec> {
),
]
}
#[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());
}
}
+38 -12
View File
@@ -98,7 +98,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings| {
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
for (k, v) in &vals {
controls::set(d, k, *v);
if k == "wb.temp" || k == "wb.tint" {
@@ -113,15 +117,18 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
let mut d = (*d).clone();
apply(&mut d);
let info = s.source_info(id);
apply(&mut d, &info);
s.develop_op(id, d, &label)
})
.collect();
s.commit(&label, Op::Batch { ops })?;
return ok();
}
let id = active(s, "develop.set")?;
let info = s.source_info(id);
edit(s, "develop.set", &label, |d| {
apply(d);
apply(d, &info);
Ok(())
})
}
@@ -130,7 +137,15 @@ pub fn specs() -> Vec<CommandSpec> {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
let info = s.source_info(active(s, "develop.adjust")?);
edit(s, "develop.adjust", spec.label, |d| {
if c == "wb.temp" || c == "wb.tint" {
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
d.wb.mode = WbMode::Custom;
}
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
Ok(())
@@ -149,12 +164,18 @@ pub fn specs() -> Vec<CommandSpec> {
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
.filter_map(|(id, _d)| {
// back to the photo's import defaults when a default preset gave it its look
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
let fresh = match look {
Some(l) => (*l).clone(),
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
None => {
let info = s.source_info(id);
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
}
}
};
s.develop_op(id, fresh, "Reset")
})
@@ -203,7 +224,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let id = active(s, "develop.autoBwMix")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
@@ -218,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
let id = active(s, "develop.auto")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
edit(s, "develop.auto", "Auto", |d| {
@@ -245,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mode: WbMode =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
let id = active(s, "develop.wb")?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let (mut t, mut tint) = match mode {
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
WbMode::Auto => {
@@ -256,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
};
@@ -277,7 +298,7 @@ pub fn specs() -> Vec<CommandSpec> {
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
let id = active(s, "develop.wbPick")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
// Sample a small neighbourhood in the oriented source.
let o = src.oriented(d.orientation);
@@ -405,7 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| {
let id = active(s, "crop.autoStraighten")?;
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
@@ -501,7 +522,7 @@ pub fn specs() -> Vec<CommandSpec> {
Upright::Off | Upright::Guided => None,
m => {
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
}
};
@@ -657,6 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
let info = s.source_info(id);
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
if ctl == "wb.temp" || ctl == "wb.tint" {
+180 -79
View File
@@ -7,6 +7,105 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
Ok(rd
.flatten()
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.filter_map(|e| {
let m = std::fs::metadata(e.path()).ok()?;
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
})
.collect())
}
/// `library.import`'s params, checked (no file-system calls).
pub struct ImportRequest {
pub paths: Vec<String>,
pub opts: crate::import::ImportOptions,
pub album: Option<u64>,
pub album_name: Option<String>,
}
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
/// worker thread, with the same options).
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
let paths = strs(p, "paths");
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
"move" => crate::import::ImportMode::Move,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
};
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
None => None,
};
let album = p.get("album").and_then(Value::as_u64);
if let Some(a) = album
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
{
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"))
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
convert_dng: bool_or(p, "dng", false),
local: bool_or(p, "local", false),
};
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
Ok(ImportRequest { paths, opts, album, album_name })
}
/// After a batch of an import was committed: the album (a new one named `album_name` when there
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
/// `album`).
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
let mut report = serde_json::to_value(report).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if album.is_none()
&& let Some(name) = album_name
&& !imported.is_empty()
{
let r = s.execute("album.create", &json!({"name": name}))?;
album = r["id"].as_u64();
}
if let Some(a) = album
&& !imported.is_empty()
{
// (album.addPhotos wants a selection: the new photos are about to be it)
s.selection = Selection::single(PhotoId(imported[0]));
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
report["album"] = json!(a);
}
Ok(report)
}
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
}
@@ -164,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, 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();
@@ -347,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 }))?;
@@ -354,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 })?;
@@ -657,9 +816,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Now",
[],
None,
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
always,
|s, _| {
|s, p| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
// only files that stopped growing: a file still being written is left for later
let known: std::collections::HashSet<String> = s
@@ -670,18 +829,20 @@ pub fn specs() -> Vec<CommandSpec> {
_ => None,
})
.collect();
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
};
let mut fresh = Vec::new();
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
let path = e.path();
let ps = path.to_string_lossy().to_string();
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
for (ps, size) in listing {
if known.contains(&ps) {
continue;
}
// each file is tried once (a non-photo isn't retried every scan)
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
continue;
}
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
let seen = s.auto_import_seen.insert(ps.clone(), size);
if size > 0 && seen == Some(size) {
fresh.push(ps);
@@ -701,6 +862,10 @@ pub fn specs() -> Vec<CommandSpec> {
for f in &fresh {
s.auto_import_seen.insert(f.clone(), u64::MAX);
}
if p.get("start").and_then(Value::as_bool) == Some(false) {
// the caller imports them (the app: on a worker thread)
return Ok(json!({"imported": [], "folder": folder, "import": params}));
}
let sel = s.selection.clone();
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
@@ -751,79 +916,15 @@ pub fn specs() -> Vec<CommandSpec> {
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
always,
|s, p| {
let paths = strs(p, "paths");
if paths.is_empty() {
let req = import_params(s, p)?;
if req.paths.is_empty() {
return Err(bad("library.import", "no paths"));
}
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
"move" => crate::import::ImportMode::Move,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
};
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
Some(id) => {
Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?)
}
None => None,
};
let mut album = p.get("album").and_then(Value::as_u64);
if let Some(a) = album
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
{
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad(
"library.import",
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
)
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
convert_dng: super::bool_or(p, "dng", false),
local: super::bool_or(p, "local", false),
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if album.is_none()
&& let Some(name) = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty())
&& !imported.is_empty()
{
let r = s.execute("album.create", &json!({"name": name}))?;
album = r["id"].as_u64();
}
if let Some(a) = album
&& !imported.is_empty()
{
// (album.addPhotos wants a selection: the new photos are about to be it)
s.selection = Selection::single(PhotoId(imported[0]));
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
report["album"] = json!(a);
}
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
// one undo step for the whole import
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
let n = s.undo.len().saturating_sub(undo0);
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
if let Some(f) = imported.first() {
+273 -10
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,14 +158,20 @@ 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}")),
@@ -83,7 +180,8 @@ pub fn specs() -> Vec<CommandSpec> {
});
*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 { 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!(
@@ -185,7 +296,7 @@ pub fn specs() -> Vec<CommandSpec> {
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
@@ -213,6 +324,158 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@@ -646,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)
}
+184 -42
View File
@@ -45,9 +45,10 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
/// the photos its query already narrowed to).
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
/// the photos its query already narrowed to). In the app `avail` answers from its cache (files
/// not checked yet count as present until the background check finds them gone).
pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
}
fn relink_op(id: PhotoId, path: &str) -> Op {
@@ -94,8 +95,112 @@ fn file_hash(path: &str) -> Option<String> {
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
}
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
/// undoable step.
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
/// content hash the library knows (0 / `None` = unknown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FindCandidate {
pub id: PhotoId,
pub path: String,
pub size: u64,
pub hash: Option<String>,
}
/// Every in-scope photo, for [`plan_find_missing`]: the missing ones are looked for, and no
/// photo's file is used for another. No file-system calls, so the app takes this snapshot on the
/// UI thread and plans on a worker without holding the session.
pub fn find_candidates(cat: &Catalog) -> Vec<FindCandidate> {
cat.photos()
.filter_map(|p| {
checked_path(p).map(|f| FindCandidate { id: p.id, path: f.to_string(), size: p.file_size, hash: stored_hash(p).map(str::to_string) })
})
.collect()
}
/// How a missing photo's file was recognised.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MatchBy {
/// Same name and size (and content, when the library knows its hash).
Name,
/// Renamed: same size and content hash.
Content,
}
impl MatchBy {
pub fn as_str(self) -> &'static str {
match self {
MatchBy::Name => "name",
MatchBy::Content => "content",
}
}
}
/// A missing photo and the file found for it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FoundFile {
pub id: PhotoId,
pub from: String,
pub to: String,
pub by: MatchBy,
}
/// A missing photo with several look-alike files (same name and size, no hash to tell them
/// apart): skipped, never guessed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AmbiguousFile {
pub id: PhotoId,
pub from: String,
pub candidates: Vec<String>,
}
/// What Find Missing Photos found ([`plan_find_missing`]), before relinking.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FindPlan {
pub found: Vec<FoundFile>,
/// In-scope photos that stay missing (the ambiguous ones included).
pub missing: usize,
pub ambiguous: Vec<AmbiguousFile>,
}
impl FindPlan {
/// `{found: [{id, from, to, by}], missing, ambiguous: [{id, from, candidates}]}` — the
/// command's result, and what `library.findMissing {found, …}` takes back.
pub fn to_json(&self) -> Value {
let found: Vec<Value> = self.found.iter().map(|f| json!({"id": f.id.0, "from": f.from, "to": f.to, "by": f.by.as_str()})).collect();
let ambiguous: Vec<Value> = self.ambiguous.iter().map(|a| json!({"id": a.id.0, "from": a.from, "candidates": a.candidates})).collect();
json!({"found": found, "missing": self.missing, "ambiguous": ambiguous})
}
/// The inverse of [`FindPlan::to_json`] (entries it can't read are dropped).
pub fn from_json(v: &Value) -> FindPlan {
let list = |k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
let found = list("found")
.iter()
.filter_map(|f| {
let by = if f.get("by").and_then(Value::as_str) == Some("content") { MatchBy::Content } else { MatchBy::Name };
Some(FoundFile {
id: PhotoId(f.get("id")?.as_u64()?),
from: str_param(f, "from")?.to_string(),
to: str_param(f, "to")?.to_string(),
by,
})
})
.collect();
let ambiguous = list("ambiguous")
.iter()
.filter_map(|a| {
let candidates = a.get("candidates")?.as_array()?.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
Some(AmbiguousFile { id: PhotoId(a.get("id")?.as_u64()?), from: str_param(a, "from")?.to_string(), candidates })
})
.collect();
let missing = v.get("missing").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
FindPlan { found, missing, ambiguous }
}
}
/// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in
/// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app
/// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes
/// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`.
///
/// Matching, per photo:
/// 1. a file with the same name (any letter case), the same size and the same content hash;
@@ -107,20 +212,21 @@ fn file_hash(path: &str) -> Option<String> {
///
/// Files that are already some photo's original, or that another photo was just matched with,
/// are not used.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::result::Result<FindPlan, String> {
if !Path::new(folder).is_dir() {
return Err(bad(C, format!("{folder}: not a folder")));
return Err(format!("{folder}: not a folder"));
}
let lost = missing(s);
if cfg!(target_arch = "wasm32") {
return Ok(FindPlan::default());
}
let lost: Vec<&FindCandidate> = candidates.iter().filter(|c| !Path::new(&c.path).exists()).collect();
if lost.is_empty() {
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
return Ok(FindPlan::default());
}
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
let in_use: HashSet<&str> = candidates.iter().map(|c| c.path.as_str()).collect();
// file name (lower case) → candidate paths; size → paths (filled lazily)
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect();
for f in &files {
if let Some(n) = Path::new(f).file_name() {
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
@@ -131,24 +237,21 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
let mut used: HashSet<String> = HashSet::new();
let mut ops = Vec::new();
let mut found = Vec::new();
let mut ambiguous = Vec::new();
for (id, old) in &lost {
let Some(ph) = s.catalog.photo(*id) else { continue };
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = ph.file_size;
let hash = stored_hash(ph).map(str::to_string);
let mut plan = FindPlan::default();
for c in &lost {
let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let size = c.size;
let hash = c.hash.as_deref();
let named: Vec<&String> = by_name
.get(&name)
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
.map(|v| v.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
.unwrap_or_default();
// 1. same name and size, confirmed by the content hash
let mut hit: Option<(String, &str)> =
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
let mut hit: Option<(String, MatchBy)> =
hash.and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), MatchBy::Name));
// 2. renamed: same size and content
if hit.is_none()
&& let Some(h) = &hash
&& let Some(h) = hash
&& size > 0
{
let sizes = by_size.get_or_insert_with(|| {
@@ -162,36 +265,75 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
});
hit = sizes
.get(&size)
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
.map(|f| (f.clone(), "content"));
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
.map(|f| (f.clone(), MatchBy::Content));
}
// 3. same name and size only (no hash, or the file was edited in place): one candidate
// is taken, several are ambiguous and skipped rather than guessed
if hit.is_none() {
match named.len() {
1 => hit = Some((named[0].clone(), "name")),
0 => {}
match named.as_slice() {
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
[] => {}
_ => {
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
plan.ambiguous.push(AmbiguousFile { id: c.id, from: c.path.clone(), candidates: named.iter().map(|f| (*f).clone()).collect() });
continue;
}
}
}
let Some((path, by)) = hit else { continue };
ops.push(relink_op(*id, &path));
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
used.insert(path);
used.insert(path.clone());
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
}
let still = lost.len() - found.len();
if !ops.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops })?;
plan.missing = lost.len() - plan.found.len();
Ok(plan)
}
/// Relink what [`plan_find_missing`] found, in one undoable step, re-checked against the
/// catalog as it is now (the search may have run on a worker while the library changed): a
/// photo that no longer points at the path it was missing from (relinked, removed) is left
/// alone, and a file that is now some photo's original — or that the plan names twice — is not
/// used (that photo stays missing). `missing` is the plan's count, corrected for what changed
/// among the photos the plan names.
fn commit_find(s: &mut Session, plan: FindPlan) -> Result<Value> {
let still_at = |cat: &Catalog, id: PhotoId, from: &str| cat.photo(id).and_then(|p| checked_path(p)) == Some(from);
let mut in_use: HashSet<String> = candidates(&s.catalog).into_iter().collect();
let mut found = Vec::new();
let mut missing = plan.missing;
for f in plan.found {
if !still_at(&s.catalog, f.id, &f.from) {
continue;
}
if in_use.contains(&f.to) {
missing += 1;
continue;
}
in_use.insert(f.to.clone());
found.push(f);
}
let planned = plan.ambiguous.len();
let ambiguous: Vec<AmbiguousFile> = plan.ambiguous.into_iter().filter(|a| still_at(&s.catalog, a.id, &a.from)).collect();
// ambiguous photos relinked meanwhile aren't missing any more
missing = missing.saturating_sub(planned - ambiguous.len());
if !found.is_empty() {
s.commit("Find Missing Photos", Op::Batch { ops: found.iter().map(|f| relink_op(f.id, &f.to)).collect() })?;
for f in &found {
if let Some(id) = f["id"].as_u64() {
s.media.forget(PhotoId(id));
}
s.media.forget(f.id);
}
}
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
Ok(FindPlan { found, missing, ambiguous }.to_json())
}
/// Search `folder` (recursively) for each missing photo's file ([`plan_find_missing`]) and
/// relink every match in one undoable step. With `found` instead (what a search on a worker
/// thread returned, as `{found, missing, ambiguous}`), only the relinking is done.
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findMissing";
if p.get("found").is_some_and(Value::is_array) {
return commit_find(s, FindPlan::from_json(p));
}
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
let plan = plan_find_missing(&find_candidates(&s.catalog), folder).map_err(|e| bad(C, e))?;
commit_find(s, plan)
}
pub fn specs() -> Vec<CommandSpec> {
@@ -213,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Missing Photos",
[],
None,
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content; or {found, missing, ambiguous} — relink what a search already found (the app searches on a worker thread; photos relinked meanwhile and files now in use are skipped) → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
always,
find_missing
),
+1 -1
View File
@@ -15,7 +15,7 @@ mod edit;
mod export;
pub mod filters;
pub mod keywords;
mod library;
pub mod library;
pub mod lut_profiles;
pub mod manage;
mod masks;
+131 -38
View File
@@ -23,6 +23,12 @@ pub struct PreviewBuild {
pub failed: AtomicUsize,
pub cancel: AtomicBool,
pub finished: AtomicBool,
/// Damaged smart previews rebuilt (Build Smart Previews only).
pub repaired: AtomicUsize,
/// What is built ("" = previews; "smart previews" — Build Smart Previews runs the same way).
pub what: &'static str,
/// Why the whole build failed (e.g. the smart previews folder isn't there).
pub error: std::sync::Mutex<Option<String>>,
}
impl PreviewBuild {
@@ -33,8 +39,15 @@ impl PreviewBuild {
"failed": self.failed.load(Ordering::Relaxed),
"running": !self.finished.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
"what": if self.what.is_empty() { "previews" } else { self.what },
"repaired": self.repaired.load(Ordering::Relaxed),
"error": self.error(),
})
}
pub fn error(&self) -> Option<String> {
self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
}
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
@@ -94,56 +107,136 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state.json())
}
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
/// how to load the original at preview size).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
/// What Build / Discard Smart Previews did.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct SmartCounts {
/// Proxies there now (complete ones kept, missing or damaged ones written).
built: usize,
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
repaired: usize,
removed: usize,
/// `[[id, why]]`
failed: Vec<Value>,
}
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
/// recreated. With `state`, progress and cancel.
#[cfg(not(target_arch = "wasm32"))]
fn smart_run(
dir: &std::path::Path,
custom: bool,
discard: bool,
jobs: Vec<SmartJob>,
state: Option<&PreviewBuild>,
) -> std::result::Result<SmartCounts, String> {
let mut n = SmartCounts::default();
if !discard {
// a chosen folder on another drive must be there: never recreate it on this one
if custom {
crate::smart::check_writable(dir)?;
} else {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
for (id, path, source) in jobs {
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
break;
}
if discard {
if std::fs::remove_file(&path).is_ok() {
n.removed += 1;
}
} else {
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
n.built += 1;
} else {
// atomic: a failed write leaves no partial proxy that would pass for a built one;
// and synced (issue #134): built so the photo can be edited while its original
// is offline, when it is the only copy — the sync is small next to the decode
let r = source
.ok_or_else(|| "nothing to build from".to_string())
.and_then(|s| s.load_source())
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
match r {
Ok(()) => {
n.built += 1;
if damaged {
n.repaired += 1;
if let Some(st) = state {
st.repaired.fetch_add(1, Ordering::Relaxed);
}
}
}
Err(e) => {
n.failed.push(json!([id.0, e]));
if let Some(st) = state {
st.failed.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
if let Some(st) = state {
st.done.fetch_add(1, Ordering::Relaxed);
}
}
Ok(n)
}
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
/// photos editable while their originals are offline.
/// photos editable while their originals are offline. With `background` (the app) the files are
/// read and written on a worker thread; progress like Build Previews (`library.previewProgress`).
#[cfg(not(target_arch = "wasm32"))]
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.smartPreviews";
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
if !discard {
// a chosen folder on another drive must be there: never recreate it on this one
if s.smart_previews_dir.is_some() {
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
} else {
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let mut jobs: Vec<SmartJob> = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
continue;
}
let path = dir.join(crate::smart::file_name(&ph));
if discard {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
continue;
}
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
built += 1;
continue;
}
// atomic: a failed write leaves no partial proxy that would pass for a built one
let r = s
.source_now(id, crate::media::SourceLevel::Preview)
.and_then(|src| crate::smart::encode(&src))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
match r {
Ok(()) => {
built += 1;
repaired += usize::from(damaged);
}
Err(e) => failed.push(json!([id.0, e])),
}
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source));
}
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
let custom = s.smart_previews_dir.is_some();
if background {
let what = if discard { "discarding smart previews" } else { "smart previews" };
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
s.preview_build = Some(state.clone());
let st = state.clone();
std::thread::Builder::new()
.name("lc-smart-previews".into())
.spawn(move || {
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
log::warn!("{C}: {e}");
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
}
st.finished.store(true, Ordering::Relaxed);
})
.map_err(|e| bad(C, format!("could not start: {e}")))?;
return Ok(state.json());
}
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
}
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
@@ -225,7 +318,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Build Smart Previews",
[],
None,
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
"{ids?, discard?: bool, background?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}; with background (the app's menu) the files are read and written on a worker thread → {total, done, failed, repaired, running, what} like library.buildPreviews (progress: library.previewProgress, stop: library.cancelPreviews)",
always,
smart
),
@@ -256,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
build
),
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | null", always, |s, _| {
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
+84 -29
View File
@@ -244,14 +244,24 @@ impl Default for Watermark {
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// BIZ UDMincho (OFL), Japanese fallback on both native and web.
/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback.
pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf");
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(WATERMARK_FONT)
.chain(cjk.into_iter().map(|f| f.bytes))
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
.collect()
}
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
let width = img.width;
watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| {
let p = &mut img.data[y * width + x];
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
@@ -264,14 +274,14 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
let (width, trc) = (img.width, img.space.trc());
match &mut img.samples {
DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| {
let i = (y * width + x) * 3;
for c in 0..3 {
let p = v[i + c] as f32;
v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16;
}
}),
DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| {
let i = (y * width + x) * 3;
for c in 0..3 {
let target = trc.decode(col[c] as f32 / 255.0);
@@ -282,9 +292,16 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
}
/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for
/// every covered pixel (shadow pass first).
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
/// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter
/// so tests can render without it).
fn watermark_coverage(
width: usize,
height: usize,
wm: &Watermark,
craft: &'static [crate::fonts::CraftFont],
mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]),
) {
use ab_glyph::{Font, PxScale, ScaleFont, point};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
@@ -293,9 +310,8 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
if text.is_empty() || width == 0 || height == 0 {
return;
}
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return };
let fonts = [font, japanese];
let fonts = watermark_fonts(craft);
let Some(latin) = fonts.first() else { return };
let short = width.min(height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
let mut glyphs = Vec::new();
@@ -333,9 +349,11 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
'…' => '︙',
other => other,
};
let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original };
let face = usize::from(fonts[0].glyph_id(ch).0 == 0);
let sf = fonts[face].as_scaled(PxScale::from(px));
let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0;
let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original };
// The first face with the glyph; Inter (its missing-glyph box) when none has it.
let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0);
let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if wm.vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
@@ -1131,15 +1149,27 @@ pub fn export_batch(
}
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then
/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The
/// native writer behind exports, renders and screenshots (app, CLI, MCP).
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file
/// renamed into place), so a failure part-way leaves any previous file intact and no truncated
/// one. Not synced to disk (issue #134): an export can always be made again from the original.
/// The native writer behind exports, renders and screenshots (app, CLI, MCP).
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
write_with(path, bytes, false)
}
/// [`write_file`], synced to disk: for a written file that becomes a library photo (Edit a Copy)
/// and so can't simply be exported again once the catalog points at it.
pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> {
write_with(path, bytes, true)
}
fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> {
use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync};
let p = std::path::Path::new(path);
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}"))
if durable { write_atomic(p, bytes) } else { write_atomic_nosync(p, bytes) }.map_err(|e| format!("{path}: {e}"))
}
/// The path of the sidecar with extension `ext` of the exported file `main`.
@@ -1334,21 +1364,46 @@ mod tests {
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[test]
fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() {
use ab_glyph::Font;
let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap();
for c in "日本語".chars() {
assert_ne!(font.glyph_id(c).0, 0);
}
let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}}));
assert!(!old.watermark.unwrap().vertical);
fn vertical_japanese() -> Watermark {
let options =
ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}}));
let wm = options.watermark.unwrap();
options.watermark.unwrap()
}
#[test]
fn japanese_watermark_options_and_legacy_defaults() {
let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}}));
assert!(!old.watermark.unwrap().vertical);
let wm = vertical_japanese();
assert!(wm.vertical);
let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap();
assert!(round.vertical);
}
/// Built without craft-fonts, a Japanese watermark still lays out and draws (Inter's
/// missing-glyph boxes), and Latin text is unaffected.
#[test]
fn watermarks_work_without_craft_fonts() {
assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only");
for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] {
let mut covered = 0usize;
watermark_coverage(400, 300, &wm, &[], |_, _, k, _| covered += usize::from(k > 0.0));
assert!(covered > 0, "{:?} draws something", wm.text);
}
}
#[test]
fn japanese_watermarks_support_vertical_columns() {
use ab_glyph::Font;
let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS);
if fonts.len() < 2 {
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
return;
}
for c in "日本語の文字".chars() {
assert!(fonts[1..].iter().any(|f| f.glyph_id(c).0 != 0), "a craft-fonts face has {c}");
}
let wm = vertical_japanese();
let mut img = Rgba8::new(400, 300);
img.data.fill([0, 0, 0, 255]);
draw_watermark(&mut img, &wm);
+72 -10
View File
@@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
copyright_url: m.copyright_url.clone().unwrap_or_default(),
creator: m.artist.clone().unwrap_or_default(),
keywords: m.keywords.clone(),
regions: m.regions.clone(),
};
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
}
@@ -131,7 +132,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
let as_shot_wb = Some((t.round(), tint.round()));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
embedded_lens,
@@ -221,6 +224,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
}
Err(e) => return Err(e.to_string()),
};
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
drop(bytes);
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
let lens = embedded_lens(&raw.info());
@@ -242,21 +248,28 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
// the samples aren't needed any more (the colour model below reads only the tags)
raw.data = lightcraft_raw::RawData::U16(Vec::new());
let mut stages = vec![("develop", t0.elapsed())];
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
stages.push(("transform", t0.elapsed()));
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
stages.push(("highlights", t0.elapsed()));
let m = t.matrix.to_f32();
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
let gain = 2f32.powf(t.baseline_exposure as f32);
let wb = t.wb;
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
[
((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0),
((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0),
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
]
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match &tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
// after the baseline exposure, as when it was fitted
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
@@ -276,7 +289,10 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let (temp, tint) = xy_to_temp_tint(xy);
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
}
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
drop(bytes);
@@ -285,6 +301,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
}
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white
/// the camera tone's shoulder continues it.
pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option<lightcraft_pipeline::tone::CameraTone> {
let knots: [[f32; 2]; 32] = std::array::from_fn(|i| {
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0);
[x, curve.eval(x).min(0.9995)]
});
lightcraft_pipeline::tone::CameraTone::new(knots)
}
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
if jpeg_orientation > 1 {
@@ -390,6 +417,41 @@ mod tests {
b
}
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
#[test]
fn dng_profile_look_is_applied() {
use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve};
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&plain).unwrap();
let sat = |img: &Rgb32f| {
img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::<f32>()
/ img.data.len() as f32
};
let (before, info) = load_bytes(&plain, 64).unwrap();
assert!(sat(&before) > 0.05, "the synthetic scene is coloured");
assert!(info.camera_tone.is_none());
// a map that removes all saturation, and a tone curve
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
raw.color.profile =
ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) };
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let (after, info) = load_bytes(&with, 64).unwrap();
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
// a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB)
let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::<f32>() / img.data.len() as f32;
assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before));
let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone");
assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18));
// a look table alone changes the render too (applied after exposure)
raw.color.profile = ProfileLook {
look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }),
..Default::default()
};
let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
}
#[test]
fn unsupported_raw_falls_back_to_embedded_preview() {
let b = cr3_with_preview(48, 32);
+115
View File
@@ -0,0 +1,115 @@
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is
//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export
//! watermark renderer read it from here. See craftrules `standards/fonts.md`.
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
pub style: &'static str,
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
pub scripts: &'static [&'static str],
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
impl CraftFont {
/// Whether the font is meant for `script` (ISO 15924, e.g. `"Jpan"`).
pub fn covers(&self, script: &str) -> bool {
self.scripts.contains(&script)
}
/// Mincho (serif) faces, the preferred Japanese faces for document-like text.
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
/// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
/// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
/// characters too (with mainland glyph forms; still closer than the Japanese faces).
pub fn serves(&self, script: &str) -> bool {
self.covers(script) || (script == "Hant" && self.covers("Hans"))
}
}
/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts).
pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
fonts.iter().filter(|f| f.covers("Jpan"))
}
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
}
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
/// scripts), which Inter has no glyphs for.
pub fn is_cjk_script(script: &str) -> bool {
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
}
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
/// Empty without craft-fonts.
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
faces.sort_by_key(|font| {
(
// The language's own script first. A face dedicated to the script is preferred over a
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
!font.serves(script),
font.scripts.len() != 1,
font.family != "BIZ UDPGothic",
font.style != style,
font.is_mincho(),
)
});
faces
}
/// Whether the font is for any CJK script.
fn covers_cjk(font: &CraftFont) -> bool {
font.scripts.iter().any(|script| is_cjk_script(script))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn craft_fonts_are_well_formed_or_absent() {
if CRAFT_FONTS.is_empty() {
eprintln!("built without CRAFT_FONTS_DIR: no craft-fonts to check");
return;
}
for f in CRAFT_FONTS {
assert!(!f.family.is_empty() && !f.style.is_empty() && !f.scripts.is_empty(), "{}", f.family);
assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style);
}
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
}
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
/// text still reaches Inter before any CJK fallback.
#[test]
fn a_language_gets_its_own_script_first() {
static FACES: &[CraftFont] = &[
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
];
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
// No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
// Every CJK face is still offered to every language.
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
}
}
+376 -186
View File
@@ -375,13 +375,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
/// synced and compared byte for byte); a bad one is removed and the photo reported as failed.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
/// byte for byte with the source when there is none); a bad one is removed and the photo
/// reported as failed.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name
.map(str::to_string)
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.to_string_lossy().to_string())
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
}
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
@@ -517,205 +520,392 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
}
/// Import files/folders with options (preset, keywords). See the module docs.
///
/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars,
/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can
/// run the first on a worker thread; here both run in turn.
pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result<ImportReport> {
let mode = opts.mode;
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
// added file in its own storage already, so "copy" there means "add".
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
if mode == ImportMode::Move && opts.local {
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut seq = opts.rename_start.max(1);
let files = expand(paths, lib_dir.as_deref());
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
let mut job = ImportJob::new(s, opts.clone())?;
let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false));
// probes a scan left for files not imported now stay for a later import
s.import_probes.extend(std::mem::take(&mut job.cache));
commit_prepared(s, &job.opts, &job.now, prepared)
}
// existing paths and content hashes
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.as_str(), p.id);
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let mut todo = Vec::new();
// a file that was only browsed (Local) joins the library when it is imported for real
let mut promote = Vec::new();
for f in files {
match by_path.get(f.as_str()) {
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
promote.push(Op::SetLocal { id: *id, local: false });
report.imported.push(id.0);
/// Everything the file-system half of an import needs, detached from the session so it can run
/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`];
/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting,
/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session.
pub struct ImportJob {
pub opts: ImportOptions,
/// The mode in effect (Copy / Move become Add in a library without its own folder).
mode: ImportMode,
lib_dir: Option<PathBuf>,
copy_root: Option<PathBuf>,
/// path → (photo, a Local browse record?)
by_path: HashMap<String, (PhotoId, bool)>,
/// content hash → the photo that has it (`None`: a file earlier in this import)
by_hash: HashMap<String, Option<PhotoId>>,
probe: Option<crate::media::FileProbe>,
file_bytes: Option<crate::merge::ByteReader>,
/// Probes from a preceding scan (not read again).
cache: HashMap<String, ProbeInfo>,
naming: crate::sidecar::SidecarNaming,
now: String,
seq: usize,
/// Files found so far (folders expanded) and handled so far, for progress.
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
#[derive(Debug, Default)]
pub struct Prepared {
pub scanned: usize,
items: Vec<PreparedItem>,
}
#[derive(Debug)]
enum PreparedItem {
/// A Local (browsed) record imported for real.
Promote(PhotoId),
Duplicate {
path: String,
existing: Option<PhotoId>,
reason: &'static str,
hash: Option<String>,
},
Failed(String, String),
Kept(Kept),
Ready(Box<ReadyFile>),
}
#[derive(Debug)]
struct ReadyFile {
path: String,
stored: String,
info: ProbeInfo,
sidecar: Option<crate::sidecar::SidecarData>,
placed: Option<crate::import_move::Placed>,
}
impl Prepared {
/// Undo what the batch did on disk that only a commit would make permanent: files placed by a
/// Move (their sources were never touched). Copies stay, like copies of a failed commit.
pub fn rollback(&self) {
for it in &self.items {
if let PreparedItem::Ready(r) = it
&& let Some(pl) = &r.placed
{
crate::import_move::rollback(pl);
}
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
None => todo.push(f),
}
}
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let now = (s.clock)();
let mut ops = promote;
// Move: placed at the destination, sources untouched until the records are committed
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
for (path, info) in todo.into_iter().zip(probed) {
let info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
report.failed.push((path, e));
/// Entries in the batch (after expanding folders).
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl ImportJob {
/// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes
/// the probes a preceding scan left ([`Session::import_probes`]).
pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result<Self> {
let mode = opts.mode;
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of
// every added file in its own storage already, so "copy" there means "add".
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
if mode == ImportMode::Move && opts.local {
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
}
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
let mut by_path = HashMap::new();
let mut by_hash = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.clone(), (p.id, p.local));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), Some(p.id));
}
}
Ok(ImportJob {
seq: opts.rename_start.max(1),
opts,
mode,
lib_dir,
copy_root,
by_path,
by_hash,
probe: s.media.file_probe.clone(),
file_bytes: s.media.file_bytes.clone(),
cache: std::mem::take(&mut s.import_probes),
naming: s.xmp.naming,
now: (s.clock)(),
total: Default::default(),
done: Default::default(),
})
}
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
/// catalog changes. Stops before the next file once `cancel` is set.
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
let files = self.expand(paths);
self.prepare_files(files, cancel)
}
/// Expand `paths` (folders recursively, the library's own folder skipped) into the files an
/// import would handle; counted in [`ImportJob::total`].
pub fn expand(&self, paths: &[String]) -> Vec<String> {
let files = expand(paths, self.lib_dir.as_deref());
self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed);
files
}
/// When the import started ([`Session::clock`]), for [`commit_prepared`].
pub fn now(&self) -> &str {
&self.now
}
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
use std::sync::atomic::Ordering::Relaxed;
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
let mut todo = Vec::new();
for f in files {
match self.by_path.get(f.as_str()).copied() {
Some((id, true)) if !opts.local => {
// a file that was only browsed (Local) joins the library when it is imported for real
self.by_path.insert(f, (id, false));
out.items.push(PreparedItem::Promote(id));
self.done.fetch_add(1, Relaxed);
}
Some((id, _)) => {
out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });
self.done.fetch_add(1, Relaxed);
}
None => todo.push(f),
}
}
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let progress = ScanProgress::default();
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
for (path, info) in todo.into_iter().zip(probed) {
if cancel.load(Relaxed) {
break;
}
self.done.fetch_add(1, Relaxed);
let mut info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
out.items.push(PreparedItem::Failed(path, e));
continue;
}
};
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(existing) = self.by_hash.get(h)
{
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
continue;
}
};
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(id) = by_hash.get(h)
{
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, self.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
Ok(sc) => Some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
}
});
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
});
let mut info = info;
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
let stored = match (mode, &copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
let mut placed = None;
let stored = match (mode, &self.copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
&& !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
let n = crate::rename::expand(t, &q, self.seq);
self.seq += 1;
n
});
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed = Some(pl);
dst
}
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
}
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
}
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
Ok(p) => p,
Err(e) => {
out.items.push(PreparedItem::Failed(path, e));
continue;
}
}
}
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
path.clone()
}
_ => path.clone(),
};
if let Some(h) = &info.content_hash {
self.by_hash.insert(h.clone(), None);
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
if opts.convert_dng
&& mode == ImportMode::Copy
&& stored != path
&& info.kind == MediaKind::Raw
&& !info.format.eq_ignore_ascii_case("DNG")
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
}
out
}
}
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
/// taken back.
pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result<ImportReport> {
let now = now.to_string();
let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() };
let mut ops = Vec::new();
// Move: placed at the destination, sources untouched until the records are committed
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
for it in prepared.items {
match it {
PreparedItem::Promote(id) => {
if s.catalog.photo(id).is_some_and(|p| p.local) {
ops.push(Op::SetLocal { id, local: false });
}
report.imported.push(id.0);
}
PreparedItem::Duplicate { path, existing, reason, hash } => {
let existing = existing.or_else(|| {
let h = hash?;
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
});
let folders = opts.organize.folders(&q);
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
let name = name.unwrap_or(own);
match crate::import_move::place(Path::new(&path), &dir, &name) {
Ok(pl) => {
let dst = pl.dst.to_string_lossy().to_string();
placed.push(pl);
dst
}
Err(e) => {
report.failed.push((path, e));
continue;
}
}
} else {
if mode == ImportMode::Move {
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
}
match copy_into(root, &path, &folders, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
continue;
}
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
}
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
PreparedItem::Kept(k) => report.kept.push(k),
PreparedItem::Ready(r) => {
let ReadyFile { path, stored, info, sidecar, placed: pl } = *r;
placed.extend(pl);
let id = s.catalog.alloc_photo_id();
if let Some(h) = &info.content_hash {
new_hash.insert(h.clone(), id);
}
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
p.meta.keywords.push(k);
}
}
}
(ImportMode::Move, _) => {
let reason = "already inside the destination or the library: added where it is";
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
path.clone()
}
_ => path.clone(),
};
let id = s.catalog.alloc_photo_id();
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
Ok(dng) => {
let _ = std::fs::remove_file(&stored);
stored = dng;
info.format = "DNG".into();
if let Some(preset) = &opts.preset {
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
let label = format!("Preset: {}", preset.name);
p.develop = d.clone();
p.edited = Some(now.clone());
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
report.imported.push(id.0);
p.local = opts.local;
if opts.local {
// the state as browsed: a later change keeps the record from being forgotten
p.set_local_baseline();
}
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
}
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
p.meta.keywords.push(k);
}
}
if let Some(preset) = &opts.preset {
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
let label = format!("Preset: {}", preset.name);
p.develop = d.clone();
p.edited = Some(now.clone());
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
}
report.imported.push(id.0);
p.local = opts.local;
if opts.local {
// the state as browsed: a later change keeps the record from being forgotten
p.set_local_baseline();
}
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let log0 = s.pending_log.len();
if !ops.is_empty()
+37 -11
View File
@@ -171,9 +171,15 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
/// is never wiped on the strength of it. Returns the new path.
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, String> {
/// disk and verified; a bad copy is removed and reported, so a card is never wiped on the strength
/// of it. Returns the new path.
///
/// `expect` is the content hash the import's probe computed from its full read of the source
/// ([`lightcraft_preview::hash_bytes`]): the copy is read back and checked against it, which spares
/// a third read of the card (issue #134). It also catches a source that changed since the probe
/// (whose recorded metadata and hash would be stale). Without one the copy is compared byte for
/// byte with the source.
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str, expect: Option<lightcraft_preview::Hash128>) -> Result<PathBuf, String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
@@ -181,7 +187,7 @@ pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, St
};
for i in 0..100_000u32 {
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
match copy_verified(src, &d) {
match copy_verified(src, &d, expect) {
Ok(()) => return Ok(d),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())),
@@ -262,12 +268,14 @@ fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> {
Err(_) => {}
}
}
copy_verified(src, dst)
// Move keeps the byte-for-byte comparison: the source is deleted on the strength of it
copy_verified(src, dst, None)
}
/// Copy into a new file, sync it to disk and compare it with the original; on any failure the
/// partial copy is removed.
fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
/// Copy into a new file, sync it to disk and verify it: against `expect` (the probe's hash of the
/// source) when given, else byte for byte against the source. On any failure the partial copy is
/// removed.
pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_preview::Hash128>) -> io::Result<()> {
let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?;
let r = (|| {
let mut inp = File::open(src)?;
@@ -283,10 +291,14 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
let _ = out.set_modified(m);
}
if !same_bytes(src, dst)? {
return Err(io::Error::other("the copy differs from the original"));
match expect {
Some(h) if hash_file(dst)? != h => {
Err(io::Error::other("the copy differs from the original as it was read for the import (a bad copy, or the file changed meanwhile)"))
}
Some(_) => Ok(()),
None if !same_bytes(src, dst)? => Err(io::Error::other("the copy differs from the original")),
None => Ok(()),
}
Ok(())
})();
drop(out);
if r.is_err() {
@@ -295,6 +307,20 @@ fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
r
}
/// [`lightcraft_preview::hash_bytes`] of a file's content, read in chunks.
fn hash_file(path: &Path) -> io::Result<lightcraft_preview::Hash128> {
let mut f = File::open(path)?;
let mut h = lightcraft_preview::Hasher128::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = fill(&mut f, &mut buf)?;
if n == 0 {
return Ok(h.finish());
}
h.update(buf.get(..n).unwrap_or(&[]));
}
}
/// Do two files hold the same bytes?
pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result<bool> {
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
+43 -7
View File
@@ -10,6 +10,9 @@
//! run them off the UI thread.
#![forbid(unsafe_code)]
pub mod availability;
mod camera_preview;
pub mod camera_profiles;
pub mod cmd;
pub mod crs;
pub mod crs_masks;
@@ -17,6 +20,7 @@ pub mod demo;
pub mod devices;
pub mod export;
pub mod files;
pub mod fonts;
pub mod guard;
pub mod import;
mod import_move;
@@ -29,6 +33,7 @@ pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod smart;
mod view;
@@ -36,6 +41,7 @@ mod view;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use fonts::{CRAFT_FONTS, CraftFont};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
pub use media::{RenderJob, SourceLevel};
@@ -121,6 +127,9 @@ pub struct Session {
pub undo: Vec<UndoEntry>,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
@@ -146,6 +155,8 @@ pub struct Session {
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
/// AI masks (SAM 3): the model and the last photo prepared for it.
pub segmenter: segment::Segmenter,
/// Selected spot (Remove panel), by index into the active photo's spots.
pub active_spot: Option<usize>,
/// The persistent library this session writes to (`None` = in-memory only).
@@ -219,6 +230,7 @@ impl Session {
undo: Vec::new(),
redo: Vec::new(),
interaction: None,
skip_auto_write: false,
clipboard: None,
meta_clipboard: None,
browse: None,
@@ -233,6 +245,7 @@ impl Session {
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
depth: 0,
active_mask: None,
segmenter: segment::Segmenter::default(),
active_spot: None,
library: None,
xmp: sidecar::XmpPrefs::default(),
@@ -272,11 +285,22 @@ impl Session {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
}
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
self.run_command(id, None, f)
}
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
let log_start = self.pending_log.len();
let was_active = self.active();
self.depth += 1;
// last-resort guard: a panic in a command is that command's error, not a crash
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e)));
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
@@ -284,14 +308,20 @@ impl Session {
self.auto_version(left);
}
}
if r.is_ok() && spec.journal && self.depth == 0 {
if r.is_ok()
&& let Some(params) = journal
&& self.depth == 0
{
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
}
if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
if self.depth == 0 {
let skip = std::mem::take(&mut self.skip_auto_write);
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
}
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success. When the command's own records
@@ -560,7 +590,11 @@ impl Session {
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
@@ -595,8 +629,8 @@ impl Session {
}
if self.source == LibrarySource::Missing {
// only the photos the query kept (library photos, not Local browse records) are checked
let cat = &self.catalog;
visible.retain(|id| cmd::missing::is_missing(cat, *id));
let (cat, avail) = (&self.catalog, &self.media.availability);
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
}
if self.source != LibrarySource::RecentlyDeleted {
visible = self.catalog.arrange_stacks(&visible);
@@ -693,6 +727,8 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
#[cfg(test)]
mod tests_spots;
+5 -11
View File
@@ -4,7 +4,7 @@
//! ```text
//! LightCraft Library/
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
//! presets.json view.json (user presets + favourites; last source/sort/selection)
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
@@ -102,7 +102,8 @@ struct PresetsFile {
struct ViewFile {
source: LibrarySource,
browse: Option<crate::Browse>,
filter: lightcraft_catalog::Filter,
// No filter: a library opens unfiltered. A date, keyword or person left over from the last session
// would silently hide photos, with only a small badge to say so.
sort: lightcraft_catalog::Sort,
selection: Selection,
}
@@ -346,7 +347,6 @@ impl Session {
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
self.source = v.source;
self.browse = v.browse;
self.filter = v.filter;
self.sort = v.sort;
self.selection = v.selection;
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
@@ -494,17 +494,11 @@ impl Session {
}
fn view_json(&self) -> Vec<u8> {
let view = ViewFile {
source: self.source,
browse: self.browse.clone(),
filter: self.filter.clone(),
sort: self.sort,
selection: self.selection.clone(),
};
let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() };
serde_json::to_vec_pretty(&view).unwrap_or_default()
}
/// Save the view state (source, filter, sort, selection) if it changed since it was last
/// Save the view state (source, sort, selection) if it changed since it was last
/// written. Native hosts get this from [`Session::close_library`]; the browser host calls it
/// periodically, since a tab can be closed without notice.
pub fn save_view(&mut self) {
+232 -45
View File
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 4;
pub const RENDER_CACHE_VERSION: u64 = 11;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -71,9 +71,31 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
/// `max_edge` (set by the app). `None`: no usable preview.
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
/// Pixels and the source interpretation learned while decoding; they must be cached together.
#[derive(Clone)]
pub struct DecodedSource {
pub image: Arc<Rgb32f>,
pub info: Option<SourceInfo>,
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
/// catalog's header facts lack (used when `info` is `None`).
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
}
impl DecodedSource {
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
DecodedSource { image, info, camera_tone: None }
}
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
/// with any stored camera tone curve.
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
}
}
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
Loaded(Box<DecodedSource>),
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
@@ -82,6 +104,9 @@ pub enum SourceRef {
path: String,
max_edge: usize,
loader: Option<FileLoader>,
/// The photo's smart preview, used when the original can't be read (offline drive). The
/// UI thread never checks whether the original is there; the render worker finds out.
fallback: Option<std::path::PathBuf>,
},
/// A smart preview standing in for a missing original.
Smart {
@@ -91,28 +116,41 @@ pub enum SourceRef {
impl SourceRef {
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
match self {
SourceRef::Loaded(a) => Ok(a.clone()),
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
SourceRef::File { path, max_edge, loader } => match loader {
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
None => Err(format!("no decoder available for {path}")),
},
self.load_source().map(|s| s.image)
}
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
SourceRef::File { path, max_edge, loader, fallback } => {
let r = match loader {
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
None => Err(format!("no decoder available for {path}")),
};
// An offline original renders from its smart preview (no decoder facts: header ones).
return match (r, fallback) {
#[cfg(not(target_arch = "wasm32"))]
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
(r, _) => r,
};
}
#[cfg(not(target_arch = "wasm32"))]
SourceRef::Smart { path } => crate::smart::load(path),
SourceRef::Smart { path } => return crate::smart::load(path),
#[cfg(target_arch = "wasm32")]
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
}
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
};
Ok(DecodedSource::new(image, None))
}
}
pub struct MediaCache {
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
thumbs: Lru<PhotoId, DecodedSource>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
previews: Vec<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
full: Option<(PhotoId, DecodedSource, u64)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -125,6 +163,9 @@ pub struct MediaCache {
pub file_bytes: Option<crate::merge::ByteReader>,
/// The library's smart previews folder (originals offline: render from the proxy).
pub smart_dir: Option<std::path::PathBuf>,
/// Whether originals (and smart previews) are on disk: cached and checked off the UI thread
/// in the app (see [`crate::availability`]).
pub availability: crate::availability::Availability,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -144,6 +185,7 @@ impl Default for MediaCache {
preview_loader: None,
file_bytes: None,
smart_dir: None,
availability: Default::default(),
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
}
@@ -156,7 +198,7 @@ fn rendered_budget(share: usize) -> usize {
}
fn source_bytes(img: &Rgb32f) -> usize {
img.data.len() * 12 + 64
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
}
impl MediaCache {
@@ -207,8 +249,8 @@ impl MediaCache {
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
let freed = match which {
0 => self.thumbs.pop_oldest(),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
2 => self.full.take().map(|e| source_bytes(&e.1)),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
_ => self.rendered.evict_oldest(),
};
match freed {
@@ -219,6 +261,10 @@ impl MediaCache {
}
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
self.get_source(id, level).map(|s| s.image)
}
fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option<DecodedSource> {
match level {
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| {
@@ -232,11 +278,31 @@ impl MediaCache {
}
}
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
/// (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
let cached = || {
self.thumbs
.peek(&id)
.into_iter()
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
};
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
Some(s) => s.info_or(header),
None => header,
}
}
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
self.insert_source(id, level, DecodedSource::new(img, None));
}
fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) {
let tick = lightcraft_preview::next_tick();
match level {
SourceLevel::Thumb => {
let cost = source_bytes(&img);
let cost = source_bytes(&img.image);
self.thumbs.insert(id, img, cost);
}
SourceLevel::Preview => {
@@ -270,29 +336,36 @@ impl MediaCache {
/// Decoded sources held: (thumbnail level, preview level, full size).
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
use crate::memory::Usage;
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
if let Some(a) = self.get_source(p.id, level) {
return SourceRef::Loaded(Box::new(a));
}
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
let max_edge = level.max_edge().min(match (&p.source, level) {
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
_ => usize::MAX,
});
// the original is offline: its smart preview, when there is one
// the original is offline: its smart preview, when there is one. Only cached answers are
// used here (no file-system call on the UI thread in the app); an original not known to
// be offline is tried first and its smart preview is the render worker's fallback.
#[cfg(not(target_arch = "wasm32"))]
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
&& !std::path::Path::new(path).exists()
{
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
let sp = dir.join(crate::smart::file_name(p));
if crate::smart::is_valid(&sp) {
// (a proxy cut short fails to load, like the offline original; the worker's fallback
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
return SourceRef::Smart { path: sp };
}
let mut r = self.origin_ref(&p.source, max_edge);
if let SourceRef::File { fallback, .. } = &mut r {
*fallback = Some(sp);
}
return r;
}
self.origin_ref(&p.source, max_edge)
}
@@ -307,10 +380,10 @@ impl MediaCache {
}
match self.scenes.iter().find(|s| s.id == *scene) {
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
}
}
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
}
}
}
@@ -344,7 +417,7 @@ pub struct RenderResult {
pub key: u64,
pub rendered: Result<Rendered, String>,
/// A source that was loaded by this job (to be inserted into the cache).
pub loaded: Option<Arc<Rgb32f>>,
pub loaded: Option<DecodedSource>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
pub quick: Option<QuickSource>,
}
@@ -410,12 +483,14 @@ impl RenderJob {
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
Ok(src) => {
match self.source.load_source() {
Ok(source) => {
let src = &source.image;
let info = source.info_or(self.info);
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
if let Some((cache, key)) = &self.cache {
cache.put(*key, Arc::new(rendered.image.clone()));
}
@@ -429,7 +504,7 @@ impl RenderJob {
level: self.level,
key: self.key,
rendered: Ok(rendered),
loaded: (!was_loaded).then_some(src),
loaded: (!was_loaded).then_some(source),
quick: None,
}
}
@@ -521,11 +596,16 @@ pub fn content_key(p: &Photo) -> String {
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
return SourceInfo { raw: true, ..Default::default() };
}
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
// balance and the display tone curve, like any other rendered file.
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
if p.develops_raw() {
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
} else {
SourceInfo::default()
}
}
impl crate::Session {
@@ -567,7 +647,13 @@ impl crate::Session {
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ content.rotate_left(17);
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
let k = Hasher128::new()
.str(&content_key(&p))
.u64(settings.hash64())
.u64(b as u64)
.u64(RENDER_CACHE_VERSION)
.u64(crate::camera_profiles::cache_key())
.finish();
(self.media.rendered.clone(), k)
});
Some(RenderJob {
@@ -596,7 +682,14 @@ impl crate::Session {
/// Cache key of a variant thumbnail ([`Self::variant_job`]).
pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 {
Hasher128::new().str(&content_key(p)).str("variant").u64(settings.hash64()).u64(edge as u64).u64(RENDER_CACHE_VERSION).finish()
Hasher128::new()
.str(&content_key(p))
.str("variant")
.u64(settings.hash64())
.u64(edge as u64)
.u64(RENDER_CACHE_VERSION)
.u64(crate::camera_profiles::cache_key())
.finish()
}
/// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset
@@ -625,9 +718,58 @@ impl crate::Session {
})
}
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
// user's Rotate Left/Right, so carry the box (and the photo's size) over to it.
let orient = p.develop.orientation;
let face = orient.map_norm_rect(face);
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) };
let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7;
// finite, within the photo, and never wider than its short side (so the clamps below are valid)
let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) };
let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h);
let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 });
let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0);
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
let mut settings = (*p.develop).clone();
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
// pixels along the long edge that leave `edge` across the crop
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
let level = SourceLevel::for_size(needed as usize);
let source = self.media.source_ref(&p, level);
let ck = Self::variant_key(&p, &settings, edge);
Some(RenderJob {
photo: id,
level,
source,
origin: p.source.clone(),
info: source_info(&p),
settings: Arc::new(settings),
request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) },
key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64),
cache: Some((self.media.rendered.clone(), ck)),
stages: None,
view_cache: None,
})
}
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish()
Hasher128::new()
.str(&content_key(p))
.str("view")
.u64(p.develop.hash64())
.u64(apply_crop as u64)
.u64(RENDER_CACHE_VERSION)
.u64(crate::camera_profiles::cache_key())
.finish()
}
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
@@ -680,7 +822,7 @@ impl crate::Session {
/// Accept a finished job's loaded source into the cache.
pub fn accept(&mut self, r: &RenderResult) {
if let Some(src) = &r.loaded {
self.media.insert(r.photo, r.level, src.clone());
self.media.insert_source(r.photo, r.level, src.clone());
}
}
@@ -723,12 +865,19 @@ impl crate::Session {
Ok(job)
}
/// Prefer decoder facts to header-only metadata for pixel-statistics commands.
pub fn source_info(&self, id: PhotoId) -> SourceInfo {
let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default();
self.media.source_facts(id, header)
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
let r = self.media.source_ref(&p, level).load()?;
self.media.insert(id, level, r.clone());
Ok(r)
let r = self.media.source_ref(&p, level).load_source()?;
let image = r.image.clone();
self.media.insert_source(id, level, r);
Ok(image)
}
}
@@ -743,7 +892,9 @@ pub struct ProbeInfo {
pub captured: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection.
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
/// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it),
/// Import → Copy verifies each copy against it instead of reading the source again.
pub content_hash: Option<String>,
/// Lens corrections embedded in the file (DNG opcodes).
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
@@ -760,6 +911,42 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
mod tests {
use super::*;
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.01 * (i + 1) as f32;
[x, (x * 2.0).min(0.9)]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
job.source = SourceRef::File {
path: "synthetic.arw".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| {
let mut image = Rgb32f::new(64, 64);
image.data.fill([0.1; 3]);
Ok((image, info))
})),
fallback: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());
let r = job.clone().run();
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
s.accept(&r);
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
let again = job.run();
assert!(again.loaded.is_none());
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
s.media.forget(id);
assert!(s.media.get_source(id, r.level).is_none());
}
#[test]
fn budget_evicts_least_recently_used_across_caches() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
+23 -2
View File
@@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source};
pub struct OriginalGuard {
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
by_name: HashMap<String, Vec<(PathBuf, String)>>,
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
}
impl OriginalGuard {
@@ -32,6 +34,7 @@ impl OriginalGuard {
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
pub fn add(&mut self, path: &Path, label: &str) {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
self.links = std::sync::OnceLock::new();
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
@@ -58,6 +61,19 @@ impl OriginalGuard {
}
}
}
// An imported symlink can have a different name from its original. A write to
// that original would change what the library reads through the link. Only do
// this fallback for existing targets that the name index did not protect; the links are
// looked up once per guard, not once per target.
let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
for (p, what) in links {
if same_file(p, target) {
return Err(format!(
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
target.display()
));
}
}
Ok(())
}
}
@@ -68,9 +84,14 @@ impl crate::Session {
OriginalGuard::new(&self.catalog)
}
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar.
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar. A free
/// path (the usual case) is answered without building the guard over the whole library.
pub fn check_write_target(&self, path: &str) -> Result<(), String> {
self.original_guard().check(Path::new(path))
let path = Path::new(path);
if std::fs::symlink_metadata(path).is_err() {
return Ok(()); // nothing there to replace
}
self.original_guard().check(path)
}
}
+87 -64
View File
@@ -538,6 +538,92 @@ fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
}
}
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
/// rename once. No file-system calls.
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
let mut seen_paths: HashSet<String> = HashSet::new();
ids.iter()
.filter_map(|id| cat.photo(*id))
.filter(|p| match &p.source {
Source::File { path } => seen_paths.insert(path.clone()),
_ => true,
})
.cloned()
.collect()
}
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
/// drive). Whether a case-only change names this very file is decided on the disk.
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
}
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
/// two spellings name one file (see [`MoveFs`]).
fn plan_rename_core(
photos: &[std::sync::Arc<Photo>],
template: &str,
start: usize,
exists: &dyn Fn(&Path) -> bool,
same_file: &dyn Fn(&Path, &Path) -> bool,
) -> Vec<RenamePlan> {
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
for (seq, p) in (start..).zip(photos) {
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
let candidate = |k: usize| {
if k == 0 {
want.clone()
} else if ext.is_empty() {
format!("{stem}-{k}")
} else {
format!("{stem}-{k}.{ext}")
}
};
let (to, to_path) = match &p.source {
Source::File { path } => {
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut k = 0;
loop {
let name = candidate(k);
let tp = dir.join(&name).to_string_lossy().to_string();
let key = tp.to_lowercase();
// this very file, maybe spelled differently (case-insensitive volume)? By
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
let (from, to) = (Path::new(path), Path::new(&tp));
let same = tp == *path || (from != to && key == path.to_lowercase() && exists(to) && same_file(from, to));
// free: not claimed in this batch and not on disk (unless it is this very file).
// A file this batch moves away still counts as taken: simple and safe.
if !taken.contains(&key) && (same || !exists(to)) {
taken.insert(key);
break (name, Some(tp));
}
k += 1;
}
}
Source::Demo { .. } => {
let mut k = 0;
loop {
let name = candidate(k);
if taken.insert(format!("demo:{}", name.to_lowercase())) {
break (name, None);
}
k += 1;
}
}
};
let from_path = match &p.source {
Source::File { path } => Some(path.clone()),
_ => None,
};
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
}
out
}
impl Session {
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
@@ -546,70 +632,7 @@ impl Session {
/// [`Session::plan_rename`] on the file system `fs`.
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
// photos sharing one file (virtual copies) rename once
let mut seen_paths: HashSet<String> = HashSet::new();
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
let mut seq = start;
for id in ids {
let Some(p) = self.catalog.photo(*id) else { continue };
if let Source::File { path } = &p.source
&& !seen_paths.insert(path.clone())
{
continue;
}
let want = expand(template, p, seq);
seq += 1;
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
let candidate = |k: usize| {
if k == 0 {
want.clone()
} else if ext.is_empty() {
format!("{stem}-{k}")
} else {
format!("{stem}-{k}.{ext}")
}
};
let (to, to_path) = match &p.source {
Source::File { path } => {
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut k = 0;
loop {
let name = candidate(k);
let tp = dir.join(&name).to_string_lossy().to_string();
let key = tp.to_lowercase();
// this very file, maybe spelled differently (case-insensitive volume)? By
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
// free: not claimed in this batch and not on disk (unless it is this very file).
// A file this batch moves away still counts as taken: simple and safe.
let on_disk = fs.exists(Path::new(&tp)) && !same;
if !taken.contains(&key) && !on_disk {
taken.insert(key);
break (name, Some(tp));
}
k += 1;
}
}
Source::Demo { .. } => {
let mut k = 0;
loop {
let name = candidate(k);
if taken.insert(format!("demo:{}", name.to_lowercase())) {
break (name, None);
}
k += 1;
}
}
};
let from_path = match &p.source {
Source::File { path } => Some(path.clone()),
_ => None,
};
out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path });
}
out
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
}
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
+146
View File
@@ -0,0 +1,146 @@
//! The model download as a background task the session starts, watches and cancels without
//! ever blocking: progress lives in atomics, messages behind a mutex only ever `try_lock`ed
//! by the session (the download thread holds it for a few instructions at a time).
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use lightcraft_segment::fetch::{self, Options, Progress};
/// What the session sees of a download.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct Status {
pub running: bool,
pub done: u64,
pub total: u64,
/// The file being fetched.
pub file: String,
/// The last download's error (cleared when a new one starts).
pub error: Option<String>,
/// The last download finished and the files are in place.
pub finished: bool,
}
#[derive(Default)]
struct Text {
file: String,
error: Option<String>,
}
#[derive(Default)]
struct Shared {
running: AtomicBool,
cancel: AtomicBool,
finished: AtomicBool,
done: AtomicU64,
total: AtomicU64,
text: Mutex<Text>,
}
/// Clears `running` when the download thread ends, however it ends.
struct Running(Arc<Shared>);
impl Drop for Running {
fn drop(&mut self) {
self.0.running.store(false, Ordering::SeqCst);
}
}
#[derive(Default)]
pub struct Downloader {
shared: Arc<Shared>,
/// The last text seen (when the download thread held the lock at that moment).
last: Mutex<Text>,
}
fn lock(m: &Mutex<Text>) -> std::sync::MutexGuard<'_, Text> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Downloader {
pub fn status(&self) -> Status {
let s = &self.shared;
if let Ok(t) = s.text.try_lock()
&& let Ok(mut last) = self.last.try_lock()
{
last.file.clone_from(&t.file);
last.error.clone_from(&t.error);
}
// (only the session's thread locks `last`)
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
Status {
running: s.running.load(Ordering::SeqCst),
done: s.done.load(Ordering::SeqCst),
total: s.total.load(Ordering::SeqCst),
file,
error,
finished: s.finished.load(Ordering::SeqCst),
}
}
pub fn running(&self) -> bool {
self.shared.running.load(Ordering::SeqCst)
}
/// Ask a running download to stop (it keeps its partial files to resume later).
pub fn cancel(&self) -> bool {
let running = self.running();
if running {
self.shared.cancel.store(true, Ordering::SeqCst);
}
running
}
/// Start downloading `files` from `mirrors` into `dir` on a background thread. False when
/// one is already running.
pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec<String>, dir: PathBuf, opts: Options) -> Result<bool, String> {
let s = self.shared.clone();
if s.running.swap(true, Ordering::SeqCst) {
return Ok(false);
}
s.cancel.store(false, Ordering::SeqCst);
s.finished.store(false, Ordering::SeqCst);
s.done.store(0, Ordering::SeqCst);
s.total.store(files.iter().filter_map(|f| f.size).sum(), Ordering::SeqCst);
// nothing else runs while `running` was false
*lock(&s.text) = Text::default();
*lock(&self.last) = Text::default();
let guard = Running(s.clone());
let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || {
let s = guard.0.clone();
let mut last_file = String::new();
let mut on_progress = |p: &Progress| {
s.done.store(p.done, Ordering::SeqCst);
s.total.store(p.total, Ordering::SeqCst);
if p.file != last_file {
last_file.clone_from(&p.file);
lock(&s.text).file.clone_from(&p.file);
}
};
let r =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
let error = match r {
Ok(Ok(())) => {
s.finished.store(true, Ordering::SeqCst);
log::info!("SAM 3 model downloaded to {}", dir.display());
None
}
Ok(Err(e)) => Some(e.to_string()),
Err(_) => Some("the download failed unexpectedly".to_string()),
};
if let Some(e) = &error {
log::warn!("SAM 3 download: {e}");
}
lock(&s.text).error = error;
drop(guard);
});
match spawned {
Ok(_) => Ok(true),
Err(e) => {
// the closure (and its guard) was dropped: `running` is false again
Err(format!("could not start the download: {e}"))
}
}
}
}
+715
View File
@@ -0,0 +1,715 @@
//! AI masks: Object (clicks) and Describe (text) selections computed with SAM 3
//! (`lightcraft-segment`, cargo feature `sam`; the desktop app enables it).
//!
//! **Nothing requires the model.** It is not part of LightCraft (SAM License); the user
//! downloads it when they first use an AI mask and agree to (`segment.model.download`), or
//! puts the files in the model folder themselves. Without it, AI mask requests fail with a
//! clear "not installed" error; renders, exports and every other feature never touch it
//! (stored segmentations are plain data in the develop settings).
//!
//! The model sees the photo as developed, uncropped and without masks, at 1008 px — the frame
//! mask coordinates live in — so a stored segmentation lines up with the photo at any size.
//! Everything that touches the model (loading it, rendering and encoding the photo, clicks,
//! descriptions, detail passes) runs on one worker thread ([`worker`]); the session talks to it
//! over channels and never waits on a lock. With [`Segmenter::background`] (the desktop app)
//! commands return at once and [`Session::segment_poll`] applies the results; without it (CLI,
//! MCP, tests) they wait for the worker.
#[cfg(feature = "sam")]
mod download;
#[cfg(feature = "sam")]
mod worker;
use std::path::PathBuf;
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{MaskShape, SegMask};
use lightcraft_geom::Point;
use crate::Session;
/// Long edge of the image the model sees.
pub const INPUT_EDGE: usize = 1008;
/// Download size of the model (`model.safetensors`; the tokenizer files add ~2 MB).
pub const MODEL_BYTES: u64 = 3_439_938_512;
/// The model's licence (not LightCraft's): shown before downloading.
pub const LICENSE_NAME: &str = "SAM License (Meta)";
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
/// How errors about a missing model start (the UI offers the download on it).
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
const WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
/// Most clicks one Object selection takes (the model's prompt grows with each).
pub const MAX_CLICKS: usize = 64;
/// One click of an Object selection (normalized image coordinates).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Click {
pub at: Point,
pub include: bool,
}
/// What a request's result is for (how [`Session::segment_poll`] applies it).
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
pub(crate) enum Tag {
/// A command waiting for the result itself.
Wait,
/// Load the model and encode the photo ahead of the first click.
Prepare,
/// Object clicks on component `comp` of mask `mask`.
Clicks { photo: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point> },
/// A Describe selection: a new mask (`mask: None`) or a component combined by `op`.
NewPrompt { photo: PhotoId, mask: Option<u32>, op: String, name: Option<String>, text: String },
/// A zoomed-in pass for a component, computed from `shape` (without its detail).
Detail { photo: PhotoId, mask: u32, comp: usize, shape: MaskShape },
}
/// The model input: 8-bit RGB.
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
pub(crate) struct Input {
rgb: Vec<u8>,
w: usize,
h: usize,
}
/// What a detail pass re-runs.
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
pub(crate) enum Prompt {
Clicks(Vec<Click>),
Text(String),
}
/// Object clicks sent to the worker and not applied yet (background mode): further clicks add
/// to these, and the loupe shows them.
#[derive(Clone, Debug, PartialEq)]
pub struct PendingClicks {
pub photo: PhotoId,
pub mask: u32,
pub comp: usize,
pub hint: Vec<Point>,
pub exclude: Vec<Point>,
}
/// What [`Session::segment_poll`] did.
#[derive(Debug, Default)]
pub struct Polled {
/// A mask changed.
pub changed: bool,
/// Errors and notices from background work, for the user.
pub messages: Vec<String>,
/// A mask that got a new selection (worth a detail pass).
pub refine: Option<u32>,
}
/// The model download (`segment.model.*`).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct DownloadStatus {
pub running: bool,
pub done: u64,
pub total: u64,
pub file: String,
pub error: Option<String>,
pub finished: bool,
}
/// The model (on its worker thread), the download, and AI mask requests in flight.
pub struct Segmenter {
/// Where the `facebook/sam3` files live (set by the app; `None`: no AI masks).
pub dir: Option<PathBuf>,
/// The user's list of download mirrors (one base URL per line), if any.
pub mirrors_file: Option<PathBuf>,
/// Run requests in the background and apply them in [`Session::segment_poll`] (the
/// desktop app); otherwise commands wait for their result (CLI, MCP, tests).
pub background: bool,
pending: Option<PendingClicks>,
messages: Vec<String>,
#[cfg(feature = "sam")]
worker: worker::Worker,
#[cfg(feature = "sam")]
download: download::Downloader,
#[cfg(feature = "sam")]
results: (std::sync::mpsc::Sender<worker::Outcome>, std::sync::mpsc::Receiver<worker::Outcome>),
}
// (derivable only without the `sam` feature: the results channel has no Default)
#[allow(clippy::derivable_impls)]
impl Default for Segmenter {
fn default() -> Self {
Segmenter {
dir: None,
mirrors_file: None,
background: false,
pending: None,
messages: Vec::new(),
#[cfg(feature = "sam")]
worker: worker::Worker::default(),
#[cfg(feature = "sam")]
download: download::Downloader::default(),
#[cfg(feature = "sam")]
results: std::sync::mpsc::channel(),
}
}
}
impl Segmenter {
/// Whether this build can compute AI masks at all.
pub const AVAILABLE: bool = cfg!(feature = "sam");
/// Whether the model's files are in place.
pub fn installed(&self) -> bool {
#[cfg(feature = "sam")]
if let Some(dir) = &self.dir {
return lightcraft_segment::is_model_dir(dir);
}
false
}
/// Whether requests are queued or running (loading, analyzing the photo, a click…).
pub fn busy(&self) -> bool {
#[cfg(feature = "sam")]
return self.worker.pending() > 0;
#[cfg(not(feature = "sam"))]
false
}
/// Whether the slow part runs now: loading the model or analyzing a photo.
pub fn analyzing(&self) -> bool {
#[cfg(feature = "sam")]
return self.worker.encoding();
#[cfg(not(feature = "sam"))]
false
}
/// Whether a zoomed-in detail pass is queued or running.
pub fn detail_busy(&self) -> bool {
#[cfg(feature = "sam")]
return self.worker.detail() > 0;
#[cfg(not(feature = "sam"))]
false
}
/// Whether the model is in memory (it is unloaded after a while without use).
pub fn loaded(&self) -> bool {
#[cfg(feature = "sam")]
return self.worker.loaded();
#[cfg(not(feature = "sam"))]
false
}
/// Clicks waiting for their segmentation.
pub fn pending_clicks(&self) -> Option<&PendingClicks> {
self.pending.as_ref()
}
/// The model download's state.
pub fn download_status(&self) -> DownloadStatus {
#[cfg(feature = "sam")]
{
let s = self.download.status();
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
}
#[cfg(not(feature = "sam"))]
DownloadStatus::default()
}
/// Where the model is downloaded from, in order (the user's mirrors first).
pub fn mirrors(&self) -> Vec<String> {
#[cfg(feature = "sam")]
{
let env = std::env::var(lightcraft_segment::fetch::MIRRORS_ENV).ok();
lightcraft_segment::fetch::mirrors(env.as_deref(), self.mirrors_file.as_deref())
}
#[cfg(not(feature = "sam"))]
Vec::new()
}
/// The model folder, if the model is there; otherwise an error that says what to do.
pub fn model_dir(&self) -> Result<PathBuf, String> {
if !Self::AVAILABLE {
return Err("AI masks are not available in this build".into());
}
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
if self.installed() {
return Ok(dir);
}
let d = self.download_status();
if d.running {
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
}
Err(format!(
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
MODEL_BYTES as f64 / 1e9,
dir.display()
))
}
/// Start downloading the model (on a background thread; see `segment.model.download`).
/// `Ok(false)` when it is installed or downloading already.
pub fn start_download(&self) -> Result<bool, String> {
if !Self::AVAILABLE {
return Err("AI masks are not available in this build".into());
}
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
if self.installed() {
return Ok(false);
}
#[cfg(feature = "sam")]
{
let mirrors = self.mirrors();
if mirrors.is_empty() {
return Err(lightcraft_segment::fetch::DownloadError::NoMirrors.to_string());
}
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default())
}
#[cfg(not(feature = "sam"))]
{
let _ = dir;
Ok(false)
}
}
/// Stop a running download (its partial files stay, to resume). False when none runs.
pub fn cancel_download(&self) -> bool {
#[cfg(feature = "sam")]
return self.download.cancel();
#[cfg(not(feature = "sam"))]
false
}
}
/// Mix `v` into a 64-bit hash.
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
fn mix(h: u64, v: u64) -> u64 {
(h ^ v).wrapping_mul(0x0100_0000_01b3).rotate_left(29)
}
/// Objects covering more than this much of the photo gain little from a zoomed pass.
const DETAIL_MAX_AREA: f64 = 0.4;
/// The part of the photo to zoom into for `seg`'s selection: its bounding box with a margin of
/// a quarter of its size, at least 12 % of the long edge, at most 2:1, inside the photo
/// (`aspect` = width / height). `None` when nothing is selected or it is most of the photo.
pub fn detail_region(seg: &SegMask, aspect: f64) -> Option<[f64; 4]> {
let logits = seg.logits()?;
let r = seg.bounds()?;
let side = seg.side as usize;
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
for (i, l) in logits.iter().enumerate() {
if *l > 0.0 {
let (x, y) = (i % side, i / side);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
}
if x0 == usize::MAX {
return None;
}
let (rw, rh) = (r[2] - r[0], r[3] - r[1]);
let nx = |c: usize| r[0] + c as f64 / side as f64 * rw;
let ny = |c: usize| r[1] + c as f64 / side as f64 * rh;
let (bx0, by0, bx1, by1) = (nx(x0), ny(y0), nx(x1), ny(y1));
if (bx1 - bx0) * (by1 - by0) > DETAIL_MAX_AREA {
return None;
}
// in units of the photo's height: x × aspect
let a = if aspect.is_finite() { aspect.clamp(0.1, 10.0) } else { 1.0 };
let (cx, cy) = ((bx0 + bx1) / 2.0 * a, (by0 + by1) / 2.0);
let size = ((bx1 - bx0) * a).max(by1 - by0);
let m = 0.25 * size;
let mut w = ((bx1 - bx0) * a + 2.0 * m).max(0.12 * a.max(1.0));
let mut h = ((by1 - by0) + 2.0 * m).max(0.12 * a.max(1.0));
w = w.max(h / 2.0).min(a);
h = h.max(w / 2.0).min(1.0);
let x = (cx - w / 2.0).clamp(0.0, (a - w).max(0.0));
let y = (cy - h / 2.0).clamp(0.0, (1.0 - h).max(0.0));
let out = [x / a, y, (x + w) / a, y + h];
// a region nearly the whole photo isn't worth a second pass
((out[2] - out[0]) * (out[3] - out[1]) < 0.8).then_some(out)
}
/// `shape` without its detail patches (what a detail pass was computed from)
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
fn strip_detail(mut shape: MaskShape) -> MaskShape {
// (nor its edge setting: moving the slider meanwhile keeps the pass)
if let MaskShape::Object { detail, edge, .. } | MaskShape::Prompt { detail, edge, .. } = &mut shape {
detail.clear();
*edge = 0.0;
}
shape
}
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()
}
impl Session {
/// The key and settings of photo `id` as the model sees it: its look without masks, uncropped.
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
fn segment_key(&self, id: PhotoId) -> Option<(u64, lightcraft_develop::DevelopSettings)> {
let p = self.catalog.photo(id)?;
let mut d = (*p.develop).clone();
d.masks.clear();
let content = crate::media::content_key(p);
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
h = mix(h, id.0);
for b in content.bytes() {
h = mix(h, u64::from(b));
}
Some((h, d))
}
/// Clicks (in normalized photo coordinates) for the model.
#[cfg(feature = "sam")]
fn model_clicks(clicks: &[Click]) -> Vec<lightcraft_segment::Click> {
clicks.iter().map(|c| lightcraft_segment::Click { x: c.at.x as f32, y: c.at.y as f32, positive: c.include }).collect()
}
/// A worker request about photo `id` (the render job for its model input goes along; the
/// worker runs it only when its cache doesn't hold the photo). `region` renders that part
/// of the photo, from a source sharp enough for it (detail passes).
#[cfg(feature = "sam")]
fn segment_job(
&mut self,
id: PhotoId,
kind: worker::Kind,
tag: Tag,
reply: std::sync::mpsc::Sender<worker::Outcome>,
region: Option<[f64; 4]>,
) -> Result<worker::Job, String> {
let dir = self.segmenter.model_dir()?;
let (key, mut d) = self.segment_key(id).ok_or("no such photo")?;
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
let missing = match &p.source {
lightcraft_catalog::Source::File { path } if !std::path::Path::new(path).exists() => Some(path.clone()),
_ => None,
};
let render = match region {
None => self.preview_job(id, INPUT_EDGE, INPUT_EDGE, false, &d).ok_or("no such photo")?,
Some(region) => {
d.crop.geometry =
lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(region[0], region[1], region[2], region[3]), angle: 0.0 };
d.crop.flip_h = false;
d.crop.flip_v = false;
d.geometry.constrain_crop = false;
let mut job = self.preview_job(id, INPUT_EDGE, INPUT_EDGE, true, &d).ok_or("no such photo")?;
let frac = (region[2] - region[0]).max(region[3] - region[1]).max(0.01);
job.level = crate::media::SourceLevel::for_size((INPUT_EDGE as f64 / frac) as usize);
job.source = self.media.source_ref(&p, job.level);
job
}
};
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, render: Some(render), missing, kind, tag, reply })
}
/// Send a request and wait for its result (CLI, MCP, tests; or when not in background mode).
#[cfg(feature = "sam")]
fn segment_wait(&mut self, id: PhotoId, kind: worker::Kind, region: Option<[f64; 4]>) -> Result<Option<SegMask>, String> {
let (tx, rx) = std::sync::mpsc::channel();
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
self.segmenter.worker.submit(job)?;
match rx.recv_timeout(WAIT) {
Ok(o) => o.result,
Err(_) => Err("the AI model took too long; try again".into()),
}
}
/// Send a request whose result [`Session::segment_poll`] applies.
#[cfg(feature = "sam")]
fn segment_queue(&mut self, id: PhotoId, kind: worker::Kind, tag: Tag, region: Option<[f64; 4]>) -> Result<(), String> {
let reply = self.segmenter.results.0.clone();
let job = self.segment_job(id, kind, tag, reply, region)?;
self.segmenter.worker.submit(job)
}
/// Start preparing photo `id` for AI masks (load the model, encode the image), so the first
/// click is fast. In background mode it returns at once.
pub fn segment_prepare(&mut self, id: PhotoId) -> Result<(), String> {
self.segmenter.model_dir()?;
#[cfg(feature = "sam")]
{
if self.segmenter.background {
return self.segment_queue(id, worker::Kind::Prepare, Tag::Prepare, None);
}
self.segment_wait(id, worker::Kind::Prepare, None)?;
}
#[cfg(not(feature = "sam"))]
let _ = id;
Ok(())
}
/// Segment the object `clicks` pick on photo `id`, waiting for the model.
pub fn segment_clicks(&mut self, id: PhotoId, clicks: &[Click]) -> Result<SegMask, String> {
self.segmenter.model_dir()?;
if !clicks.iter().any(|c| c.include) {
return Err("click the object to include it first".into());
}
#[cfg(feature = "sam")]
{
self.segment_wait(id, worker::Kind::Clicks(Self::model_clicks(clicks)), None)?.ok_or_else(|| "nothing was selected".to_string())
}
#[cfg(not(feature = "sam"))]
{
let _ = id;
Err("AI masks are not available in this build".into())
}
}
/// Segment everything `text` describes on photo `id`, waiting for the model; `Ok(None)`
/// when nothing matches.
pub fn segment_text(&mut self, id: PhotoId, text: &str) -> Result<Option<SegMask>, String> {
self.segmenter.model_dir()?;
let text = text.trim();
if text.is_empty() {
return Err("describe what to select".into());
}
#[cfg(feature = "sam")]
{
self.segment_wait(id, worker::Kind::Text(text.to_string()), None)
}
#[cfg(not(feature = "sam"))]
{
let _ = id;
Err("AI masks are not available in this build".into())
}
}
/// Background mode: queue Object clicks for component `comp` of mask `mask`; the result
/// (clicks and segmentation, one undo step) is applied by [`Session::segment_poll`].
pub(crate) fn segment_clicks_later(&mut self, id: PhotoId, mask: u32, comp: usize, hint: Vec<Point>, exclude: Vec<Point>) -> Result<(), String> {
self.segmenter.model_dir()?;
if hint.is_empty() {
return Err("click the object to include it first".into());
}
#[cfg(feature = "sam")]
{
let clicks = Self::model_clicks(&clicks_of(&hint, &exclude));
let tag = Tag::Clicks { photo: id, mask, comp, hint: hint.clone(), exclude: exclude.clone() };
self.segment_queue(id, worker::Kind::Clicks(clicks), tag, None)?;
self.segmenter.pending = Some(PendingClicks { photo: id, mask, comp, hint, exclude });
Ok(())
}
#[cfg(not(feature = "sam"))]
{
let _ = (id, mask, comp, exclude);
Err("AI masks are not available in this build".into())
}
}
/// Background mode: queue a Describe selection; the mask (or component, combined by `op`
/// into mask `mask`) is created by [`Session::segment_poll`] when something matches.
pub(crate) fn segment_text_later(&mut self, id: PhotoId, mask: Option<u32>, op: &str, name: Option<String>, text: &str) -> Result<(), String> {
self.segmenter.model_dir()?;
let text = text.trim();
if text.is_empty() {
return Err("describe what to select".into());
}
#[cfg(feature = "sam")]
{
let tag = Tag::NewPrompt { photo: id, mask, op: op.to_string(), name, text: text.to_string() };
self.segment_queue(id, worker::Kind::Text(text.to_string()), tag, None)
}
#[cfg(not(feature = "sam"))]
{
let _ = (id, mask, op, name);
Err("AI masks are not available in this build".into())
}
}
/// Start a zoomed-in pass for component `comp` of mask `mask` on photo `id` (an Object or
/// Describe selection): the photo around the selection is rendered at a higher resolution
/// and segmented again. In background mode [`Session::segment_poll`] applies the result;
/// otherwise it is applied before this returns. `Ok(false)` when there is nothing to
/// refine (no selection yet, or it is most of the photo) or a pass is running already.
pub fn segment_detail(&mut self, id: PhotoId, mask: u32, comp: usize) -> Result<bool, String> {
self.segmenter.model_dir()?;
let d = self.develop_of(id).ok_or("no such photo")?;
let shape = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()).ok_or("no such mask")?;
let (seg, prompt) = match &shape {
// (a document with absurdly many clicks isn't run through the model)
MaskShape::Object { hint, exclude, seg: Some(seg), .. } if !hint.is_empty() && hint.len() + exclude.len() <= MAX_CLICKS => {
(seg.clone(), Prompt::Clicks(clicks_of(hint, exclude)))
}
MaskShape::Prompt { text, seg: Some(seg), .. } => (seg.clone(), Prompt::Text(text.clone())),
_ => return Ok(false),
};
let p = self.catalog.photo(id).ok_or("no such photo")?;
let (pw, ph) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
let aspect = if d.orientation.swaps_axes() { ph / pw } else { pw / ph };
let Some(region) = detail_region(&seg, aspect) else { return Ok(false) };
#[cfg(feature = "sam")]
{
let kind = worker::Kind::Detail { prompt, region };
let tag = Tag::Detail { photo: id, mask, comp, shape: strip_detail(shape) };
if self.segmenter.background {
if self.segmenter.detail_busy() {
return Ok(false);
}
self.segment_queue(id, kind, tag, Some(region))?;
} else {
let patch = self.segment_wait(id, kind, Some(region))?;
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
let mut polled = Polled::default();
self.apply_outcome(outcome, &mut polled);
}
Ok(true)
}
#[cfg(not(feature = "sam"))]
{
let _ = (prompt, region, mask);
Ok(false)
}
}
/// Apply finished background requests (to masks that haven't changed since) and collect
/// their messages. Cheap when nothing is pending: call it every frame.
pub fn segment_poll(&mut self) -> Polled {
#[cfg_attr(not(feature = "sam"), allow(unused_mut))]
let mut polled = Polled { messages: std::mem::take(&mut self.segmenter.messages), ..Default::default() };
#[cfg(feature = "sam")]
{
// (before draining: a worker that is idle now has sent everything)
let idle = !self.segmenter.busy();
let done: Vec<worker::Outcome> = self.segmenter.results.1.try_iter().collect();
for o in done {
self.apply_outcome(o, &mut polled);
}
// clicks whose request was lost (the worker restarted) aren't pending forever
if idle {
self.segmenter.pending = None;
}
}
polled
}
#[cfg(feature = "sam")]
fn apply_outcome(&mut self, o: worker::Outcome, polled: &mut Polled) {
if o.superseded {
return;
}
match o.tag {
Tag::Wait => {}
Tag::Prepare => {
if let Err(e) = o.result {
polled.messages.push(e);
}
}
Tag::Clicks { photo, mask, comp, hint, exclude } => {
let latest = PendingClicks { photo, mask, comp, hint, exclude };
if self.segmenter.pending.as_ref() != Some(&latest) {
return; // newer clicks are on their way
}
self.segmenter.pending = None;
let seg = match o.result {
Ok(Some(seg)) => seg,
Ok(None) => return,
Err(e) => return polled.messages.push(e),
};
let Some(d) = self.develop_of(photo) else { return };
let mut d = (*d).clone();
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
let MaskShape::Object { edge, .. } = c.shape else { return };
c.shape = MaskShape::Object { hint: latest.hint, exclude: latest.exclude, seg: Some(seg), detail: vec![], edge };
match self.set_develop(photo, d, "Object Mask") {
Ok(()) => {
polled.changed = true;
polled.refine = Some(mask);
}
Err(e) => polled.messages.push(e.to_string()),
}
}
Tag::NewPrompt { photo, mask, op, name, text } => {
let seg = match o.result {
Ok(Some(seg)) => seg,
Ok(None) => return polled.messages.push(format!("Nothing matching “{text}” was found in this photo.")),
Err(e) => return polled.messages.push(e),
};
if self.active() != Some(photo) {
return polled.messages.push(format!("The photo changed before “{text}” was found; try again."));
}
let Ok(seg) = serde_json::to_value(seg) else { return };
let mut p = serde_json::json!({"kind": "prompt", "text": text, "seg": seg});
if let Some(n) = name {
p["name"] = serde_json::json!(n);
}
let r = match mask {
None => self.execute("mask.add", &p),
Some(m) => {
if !self.develop_of(photo).is_some_and(|d| d.masks.iter().any(|x| x.id == m)) {
return polled.messages.push("The mask was removed before the selection was found.".into());
}
self.active_mask = Some(m);
p["op"] = serde_json::json!(op);
self.execute("mask.addComponent", &p)
}
};
match r {
Ok(_) => {
polled.changed = true;
polled.refine = self.active_mask;
}
Err(e) => polled.messages.push(e.to_string()),
}
}
Tag::Detail { photo, mask, comp, shape } => {
let patch = match o.result {
Ok(Some(p)) => p,
Ok(None) => return,
Err(e) => return log::warn!("SAM 3 detail pass: {e}"),
};
let Some(d) = self.develop_of(photo) else { return };
let mut d = (*d).clone();
let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return };
if strip_detail(c.shape.clone()) != shape {
return; // clicked again meanwhile: a newer pass will follow
}
match &mut c.shape {
MaskShape::Object { detail, .. } | MaskShape::Prompt { detail, .. } => *detail = vec![patch],
_ => return,
}
match self.set_develop(photo, d, "AI Mask Detail") {
Ok(()) => polled.changed = true,
Err(e) => log::warn!("AI mask detail: {e}"),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
let l: Vec<f32> =
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
SegMask::from_logits(side, &l)
}
#[test]
fn detail_region_frames_the_selection_with_a_margin() {
// a small object in the middle of a square photo
let r = detail_region(&seg_box(100, 40, 45, 50, 55), 1.0).unwrap();
assert!(r[0] < 0.4 && r[2] > 0.5 && r[1] < 0.45 && r[3] > 0.55, "contains it: {r:?}");
assert!(r[2] - r[0] < 0.25 && r[3] - r[1] < 0.25, "zoomed in: {r:?}");
assert!(r.iter().all(|v| (0.0..=1.0).contains(v)));
// at the corner: shifted inside the photo, not cut
let r = detail_region(&seg_box(100, 0, 0, 5, 5), 1.5).unwrap();
assert!(r[0] >= 0.0 && r[1] >= 0.0 && r[0] < 1e-9 && r[1] < 1e-9);
// a thin object stays at most 2:1 (in photo pixels, 1.5:1 photo)
let r = detail_region(&seg_box(100, 10, 50, 90, 52), 1.5).unwrap();
let (w, h) = ((r[2] - r[0]) * 1.5, r[3] - r[1]);
assert!(w / h <= 2.0 + 1e-9, "{w} × {h}");
// a hostile aspect ratio stays sane
assert!(detail_region(&seg_box(100, 40, 45, 50, 55), f64::NAN).is_some_and(|r| r.iter().all(|v| v.is_finite())));
}
#[test]
fn no_region_for_nothing_or_most_of_the_photo() {
assert!(detail_region(&seg_box(50, 0, 0, 0, 0), 1.0).is_none());
assert!(detail_region(&seg_box(50, 2, 2, 48, 48), 1.0).is_none());
let damaged = SegMask { side: 50, data: "?".into(), rect: None };
assert!(detail_region(&damaged, 1.0).is_none());
}
}
+426
View File
@@ -0,0 +1,426 @@
//! The SAM 3 worker thread: it owns the model and the last encoded photo, and runs every
//! request (load, render the model input, encode, clicks, descriptions, detail passes) in
//! turn. The session only sends jobs and receives outcomes over channels, so no thread ever
//! waits on a lock the model holds, and the UI thread never runs the model.
//!
//! Every job runs under `catch_unwind`: a panic inside candle becomes an error outcome, drops
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use lightcraft_develop::SegMask;
use lightcraft_segment::{Encoded, Sam3};
use super::{Input, Prompt, Tag};
use crate::media::RenderJob;
/// Unload the model after this long without a request.
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
/// What to compute.
pub enum Kind {
/// Load the model and encode the photo (so the first click is fast).
Prepare,
Clicks(Vec<lightcraft_segment::Click>),
Text(String),
/// A zoomed-in pass over `region` (normalized) of the photo, rendered by the job's `render`.
Detail {
prompt: Prompt,
region: [f64; 4],
},
/// Panics (tests of the worker's recovery).
#[cfg(test)]
Panic,
}
/// One request.
pub struct Job {
/// The model folder.
pub dir: PathBuf,
/// Which photo and look the request is about (the encode cache's key).
pub key: u64,
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
pub render: Option<RenderJob>,
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
pub missing: Option<String>,
pub kind: Kind,
pub tag: Tag,
pub reply: mpsc::Sender<Outcome>,
}
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
pub struct Outcome {
pub tag: Tag,
pub result: Result<Option<SegMask>, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
}
/// Counters the session reads without locking.
#[derive(Default)]
pub struct Shared {
/// Requests other than detail passes, queued or running.
pub pending: AtomicUsize,
/// Detail passes queued or running.
pub detail: AtomicUsize,
/// The model is in memory.
pub loaded: AtomicBool,
/// Loading the model or encoding a photo right now (the slow parts).
pub encoding: AtomicBool,
}
/// Decrements a counter when dropped (also when unwinding).
struct Done<'a>(&'a AtomicUsize);
impl Drop for Done<'_> {
fn drop(&mut self) {
let _ = self.0.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
}
}
/// Sets a flag while alive.
struct Flag<'a>(&'a AtomicBool);
impl<'a> Flag<'a> {
fn raise(f: &'a AtomicBool) -> Self {
f.store(true, Ordering::SeqCst);
Flag(f)
}
}
impl Drop for Flag<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
/// The session's handle on the worker thread (started on the first request, restarted if it
/// ever ends).
#[derive(Default)]
pub struct Worker {
tx: Option<mpsc::Sender<Job>>,
thread: Option<std::thread::JoinHandle<()>>,
pub shared: Arc<Shared>,
}
impl Worker {
fn counter(&self, job: &Job) -> &AtomicUsize {
if matches!(job.kind, Kind::Detail { .. }) { &self.shared.detail } else { &self.shared.pending }
}
/// Whether the thread has ended (it never should); its counters are then meaningless.
fn dead(&self) -> bool {
self.thread.as_ref().is_some_and(|t| t.is_finished())
}
pub fn pending(&self) -> usize {
if self.dead() { 0 } else { self.shared.pending.load(Ordering::SeqCst) }
}
pub fn detail(&self) -> usize {
if self.dead() { 0 } else { self.shared.detail.load(Ordering::SeqCst) }
}
pub fn encoding(&self) -> bool {
!self.dead() && self.shared.encoding.load(Ordering::SeqCst)
}
pub fn loaded(&self) -> bool {
!self.dead() && self.shared.loaded.load(Ordering::SeqCst)
}
/// Queue `job`; returns at once.
pub fn submit(&mut self, job: Job) -> Result<(), String> {
if self.dead() {
// a thread that ended took its counters' meaning with it
self.tx = None;
self.thread = None;
self.shared = Arc::new(Shared::default());
}
self.counter(&job).fetch_add(1, Ordering::SeqCst);
let job = match &self.tx {
Some(tx) => match tx.send(job) {
Ok(()) => return Ok(()),
Err(mpsc::SendError(job)) => job,
},
None => job,
};
// (re)start the thread
let (tx, rx) = mpsc::channel();
let shared = self.shared.clone();
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
Ok(t) => {
self.thread = Some(t);
self.tx = Some(tx.clone());
tx.send(job).map_err(|_| "the AI mask worker stopped".to_string())
}
Err(e) => {
let _ = self.counter(&job).try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
Err(format!("could not start the AI mask worker: {e}"))
}
}
}
}
/// The model and the last encoded photo.
#[derive(Default)]
struct State {
model: Option<(PathBuf, Sam3)>,
cache: Option<(u64, Encoded)>,
}
/// Whether `later` makes `job` pointless (it asks for the same selection again).
fn supersedes(later: &Job, job: &Job) -> bool {
match (&later.tag, &job.tag) {
(Tag::Prepare, Tag::Prepare) => later.key == job.key,
(Tag::Clicks { photo: a, mask: am, comp: ac, .. }, Tag::Clicks { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
(Tag::Detail { photo: a, mask: am, comp: ac, .. }, Tag::Detail { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
_ => false,
}
}
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
let mut state = State::default();
let mut queue: VecDeque<Job> = VecDeque::new();
loop {
if queue.is_empty() {
match rx.recv_timeout(idle) {
Ok(j) => queue.push_back(j),
Err(RecvTimeoutError::Timeout) => {
if state.model.is_some() {
state = State::default();
shared.loaded.store(false, Ordering::SeqCst);
log::info!("SAM 3 unloaded after {} minutes without use", idle.as_secs() / 60);
}
continue;
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
while let Ok(j) = rx.try_recv() {
queue.push_back(j);
}
let Some(mut job) = queue.pop_front() else { continue };
let is_detail = matches!(job.kind, Kind::Detail { .. });
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
continue;
}
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
Ok(r) => r,
Err(p) => {
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::error!("SAM 3 panicked: {why}");
// don't trust what the model kept: load it again next time
state = State::default();
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
}
};
if result.is_err() && !is_detail {
state.cache = None;
}
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
}
}
/// The model for `dir`, loading it when needed.
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
state.model = None;
state.cache = None;
let _busy = Flag::raise(&shared.encoding);
let t = web_time::Instant::now();
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
log::info!("SAM 3 loaded in {:?}", t.elapsed());
state.model = Some((dir.to_path_buf(), m));
shared.loaded.store(true, Ordering::SeqCst);
}
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
}
/// Render the model input.
fn render(job: &mut Job) -> Result<Input, String> {
let r = job.render.take().ok_or("the photo is not available")?.run();
let img = match r.rendered {
Ok(r) => r.image,
Err(e) => {
return Err(match &job.missing {
Some(path) => format!(
"This photo's file is missing (moved or deleted), so it can't be analyzed for AI masks: {path}. Library ▸ Find Missing Photos can relink it."
),
None => format!("couldn't render the photo for AI masks: {e}"),
});
}
};
let rgb = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
Ok(Input { rgb, w: img.width, h: img.height })
}
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
model(state, shared, &job.dir)?;
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
state.cache = None;
let input = render(job)?;
let _busy = Flag::raise(&shared.encoding);
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
state.cache = Some((job.key, enc));
}
let State { model, cache } = state;
match (model.as_mut(), cache.as_mut()) {
(Some((_, m)), Some((_, e))) => Ok((m, e)),
_ => Err("the model is not ready".into()),
}
}
/// Every phrase of `text` ("car, road"), merged (the per-pixel maximum); `None` when nothing
/// matches.
fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result<Option<Vec<f32>>, String> {
let mut merged: Option<Vec<f32>> = None;
for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? {
merged = Some(match merged {
None => p.logits,
Some(m) => m.iter().zip(&p.logits).map(|(a, b)| a.max(*b)).collect(),
});
}
}
Ok(merged)
}
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
let side = lightcraft_segment::MASK_SIDE;
match std::mem::replace(&mut job.kind, Kind::Prepare) {
Kind::Prepare => {
encoded(state, shared, job)?;
Ok(None)
}
Kind::Clicks(clicks) => {
let (m, enc) = encoded(state, shared, job)?;
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
Ok(Some(SegMask::from_logits(side, &p.logits)))
}
Kind::Text(text) => {
let (m, enc) = encoded(state, shared, job)?;
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
}
#[cfg(test)]
#[allow(clippy::panic)]
Kind::Panic => panic!("a test panic"),
Kind::Detail { prompt, region } => {
let input = render(job)?;
let m = model(state, shared, &job.dir)?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
if !(rw > 0.0 && rh > 0.0) {
return Ok(None);
}
let logits = match prompt {
Prompt::Clicks(clicks) => {
let inside: Vec<lightcraft_segment::Click> = clicks
.iter()
.map(|c| ((c.at.x - region[0]) / rw, (c.at.y - region[1]) / rh, c.include))
.filter(|(x, y, _)| (0.0..=1.0).contains(x) && (0.0..=1.0).contains(y))
.map(|(x, y, positive)| lightcraft_segment::Click { x: x as f32, y: y as f32, positive })
.collect();
if !inside.iter().any(|c| c.positive) {
return Ok(None);
}
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
}
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
};
log::info!("SAM 3 detail pass ({}×{}) in {:?}", input.w, input.h, t.elapsed());
Ok(logits.map(|l| SegMask::from_logits_in(side, &l, region)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
// an empty folder: loading fails with an error (no model files)
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
Job { dir, key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
}
#[test]
fn errors_come_back_and_the_counters_return_to_zero() {
let mut w = Worker::default();
let (tx, rx) = mpsc::channel();
for _ in 0..3 {
w.submit(job(Tag::Wait, &tx)).unwrap();
}
for _ in 0..3 {
let o = rx.recv_timeout(Duration::from_secs(30)).unwrap();
assert!(o.result.unwrap_err().contains("not found"));
}
let t = web_time::Instant::now();
while w.pending() > 0 && t.elapsed() < Duration::from_secs(5) {
std::thread::yield_now();
}
assert_eq!(w.pending(), 0);
assert!(!w.encoding() && !w.loaded());
}
#[test]
fn a_panic_in_a_job_is_an_error_and_the_worker_carries_on() {
let shared = Shared::default();
let (tx, rx) = mpsc::channel();
let (jtx, jrx) = mpsc::channel();
let mut j = job(Tag::Wait, &tx);
j.kind = Kind::Panic;
jtx.send(j).unwrap();
jtx.send(job(Tag::Wait, &tx)).unwrap();
shared.pending.store(2, Ordering::SeqCst);
drop(jtx);
run(&jrx, &shared, Duration::from_secs(60));
let first = rx.recv().unwrap();
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
let second = rx.recv().unwrap();
assert!(second.result.is_err(), "the next job still runs");
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
assert!(!shared.encoding.load(Ordering::SeqCst));
}
#[test]
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
let (tx, rx) = mpsc::channel();
let (jtx, jrx) = mpsc::channel();
let clicks = |n: usize| Tag::Clicks {
photo: lightcraft_catalog::PhotoId(1),
mask: 1,
comp: 0,
hint: vec![lightcraft_geom::Point::new(0.5, 0.5); n],
exclude: vec![],
};
for n in 1..=3 {
jtx.send(job(clicks(n), &tx)).unwrap();
}
drop(jtx);
let shared = Shared::default();
shared.pending.store(3, Ordering::SeqCst);
run(&jrx, &shared, Duration::from_secs(60));
let out: Vec<Outcome> = rx.try_iter().collect();
assert_eq!(out.len(), 3);
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
assert_eq!(shared.pending.load(Ordering::SeqCst), 0);
}
}
+10
View File
@@ -99,6 +99,7 @@ pub struct SidecarData {
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
pub regions: Option<Vec<lightcraft_meta::Region>>,
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
@@ -133,6 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
// One without it (most writers, LightCraft's own included, which keeps another app's
// `mwg-rs:Regions` byte for byte but never writes one) says nothing about regions, and the
// photo's are kept.
regions: d.values.contains_key("mwg-rs:Regions").then(|| m.regions.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
@@ -202,6 +209,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
if let Some(k) = &sc.keywords {
m.keywords = k.clone();
}
if let Some(r) = &sc.regions {
m.regions = r.clone();
}
let develop = match &sc.develop {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => {
+60 -13
View File
@@ -1,13 +1,16 @@
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
//! at render time and is not baked into the pixels.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::Photo;
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_pipeline::tone::CameraTone;
use lightcraft_raster::{Rgb32f, Rgba8};
const MAGIC: &[u8] = b"LCSP1\n";
@@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String {
format!("{:032x}.lcsp", h.0)
}
/// Encode a source image as a smart preview.
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
const HEADER_MAX: u64 = 4096;
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
// scale so all but the brightest 0.05 % fit into 0..1
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
let scale = if lum.is_empty() {
@@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
)
.map_err(|e| e.to_string())?;
let mut out = MAGIC.to_vec();
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
out.extend_from_slice(head.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&jpg);
Ok(out)
}
/// Decode a smart preview back into a source image.
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
/// curve is ignored, like a missing one).
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
// the decoder undoes the sRGB encoding; the values are the source's own primaries
let mut img = d.image;
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
Ok(img)
Ok((img, tone))
}
/// Where a library keeps its smart previews.
@@ -157,8 +170,8 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
pub fn is_valid(path: &Path) -> bool {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut f) = std::fs::File::open(path) else { return false };
let mut head = Vec::with_capacity(256);
if (&mut f).take(256).read_to_end(&mut head).is_err() {
let mut head = Vec::with_capacity(1024);
if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() {
return false;
}
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
@@ -175,9 +188,9 @@ pub fn is_valid(path: &Path) -> bool {
}
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
decode(&b).map(Arc::new)
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
}
#[cfg(test)]
@@ -228,7 +241,7 @@ mod tests {
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let good = encode(&img).unwrap();
let good = encode(&img, None).unwrap();
std::fs::write(a.join("1.lcsp"), &good).unwrap();
assert!(is_valid(&a.join("1.lcsp")));
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
@@ -242,11 +255,45 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn camera_tone_travels_with_the_proxy() {
let tone = CameraTone::new(std::array::from_fn(|i| {
let x = 0.004 * 1.17f32.powi(i as i32);
[x, 0.95 * (1.0 - (-2.7 * x).exp())]
}))
.unwrap();
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let bytes = encode(&img, Some(&tone)).unwrap();
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
let dir = temp("tone");
let path = dir.join("t.lcsp");
std::fs::write(&path, &bytes).unwrap();
// the longer header is still read by the validity check, and the curve by `load`
assert!(is_valid(&path));
let loaded = load(&path).unwrap();
assert_eq!(loaded.camera_tone, Some(tone));
let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() };
assert_eq!(loaded.info_or(header).camera_tone, Some(tone));
// a hostile curve (reversing knots) is ignored, not trusted
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap();
knots.swap(3, 4);
head["tone"]["knots"] = serde_json::json!(knots);
let mut hostile = MAGIC.to_vec();
hostile.extend_from_slice(head.to_string().as_bytes());
hostile.extend_from_slice(&bytes[nl..]);
let (pixels, tone) = decode(&hostile).unwrap();
assert_eq!((pixels.width, tone), (img.width, None));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_keeps_the_picture() {
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
let back = decode(&encode(&img).unwrap()).unwrap();
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
assert_eq!((back.width, back.height), (img.width, img.height));
assert!(tone.is_none());
let mut err = 0.0f64;
for (a, b) in img.data.iter().zip(&back.data) {
for k in 0..3 {
+114
View File
@@ -10,6 +10,30 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
}
#[test]
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
let mut s = demo();
let id = PhotoId(100);
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
// A catalog made by the old generic-matrix Kelvin inference.
p.as_shot_wb = Some((6829.0, -127.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
let d = active_dev(&s);
assert_eq!(d.wb.mode, WbMode::Custom);
assert_eq!(d.wb.temp, 8000.0);
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
assert_eq!(active_dev(&s).wb.temp, 6500.0);
s.execute("develop.reset", &json!({})).unwrap();
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
}
#[test]
fn demo_library_loads() {
let mut s = demo();
@@ -703,3 +727,93 @@ fn curve_reset_by_channel_and_whole() {
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
}
#[test]
fn ai_masks_without_the_model_explain_themselves() {
let mut s = demo();
let id = s.active().unwrap();
// an Object mask that could never be computed is refused, not left empty
let e = s.execute("mask.add", &json!({"kind": "object"})).unwrap_err().to_string();
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
assert!(!e.contains("invalid parameters"), "shown as is: {e}");
assert!(s.develop_of(id).unwrap().masks.is_empty());
// an empty Object component (as an older document may hold) still takes clicks
let shape = lightcraft_develop::MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 };
let mut d = (*s.develop_of(id).unwrap()).clone();
d.masks.push(lightcraft_develop::Mask {
id: 1,
components: vec![lightcraft_develop::MaskComponent { name: None, op: lightcraft_develop::MaskOp::Add, invert: false, shape }],
..Default::default()
});
s.set_develop(id, d, "test").unwrap();
s.active_mask = Some(1);
// clicks and descriptions need the model: an error that says what's missing, nothing changed
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
assert!(e.contains("SAM 3") || e.contains("not available"), "{e}");
let empty = std::env::temp_dir().join(format!("lc-no-sam3-{}", std::process::id()));
s.segmenter.dir = Some(empty.clone());
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
assert!(e.contains(&empty.display().to_string()) || e.contains("not available"), "names the folder: {e}");
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
// bad input is an error, not a crash
assert!(s.execute("mask.objectPoint", &json!({"x": 1.5, "y": 0.5})).is_err());
assert!(s.execute("mask.add", &json!({"kind": "prompt", "text": " "})).is_err());
}
/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the
/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic.
#[test]
fn face_job_crops_a_square_inside_the_photo() {
use lightcraft_geom::Rect;
let mut s = demo();
let id = s.active().unwrap();
let (w, h) = {
let p = s.catalog.photo(id).unwrap();
(f64::from(p.width), f64::from(p.height))
};
let faces = [
Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 },
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
];
for face in faces {
let job = s.face_job(id, face, 256).expect("a job for a photo in the library");
let r = job.settings.crop.geometry.rect;
assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}");
assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}");
let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h);
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}");
assert_eq!(job.settings.crop.geometry.angle, 0.0);
}
// a face around (0.45, 0.375) is centred in its crop (room to spare on every side)
let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect;
assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}");
assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none());
}
/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box
/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels.
#[test]
fn face_job_follows_rotate_right() {
use lightcraft_geom::Rect;
let mut s = demo();
let id = s.active().unwrap();
let (w, h) = {
let p = s.catalog.photo(id).unwrap();
(f64::from(p.width), f64::from(p.height))
};
// a small face left of centre on the upright photo
let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 };
s.execute("photo.rotateRight", &json!({})).unwrap();
let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect;
// a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325)
assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}");
// the rotated photo is h × w pixels
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
}
+47
View File
@@ -323,3 +323,50 @@ fn a_failed_export_write_keeps_the_previous_file() {
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_protects_the_target_of_an_imported_symlink() {
use crate::export::{Conflict, Destination};
let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original");
drop(original_session);
let alias = dir.join("alias.jpg");
std::os::unix::fs::symlink(&src, &alias).unwrap();
let sidecar = dir.join("metadata.xmp");
std::fs::write(&sidecar, b"<x/>").unwrap();
std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap();
let id = s.catalog.photos().next().unwrap().id;
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) };
let result = disk_batch(&mut s, id, &o, &to);
assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}");
assert_eq!(std::fs::read(&src).unwrap(), original);
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err());
assert_eq!(std::fs::read(&sidecar).unwrap(), b"<x/>");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
/// disk file by file; an edit copy that becomes a library photo still is.
#[test]
fn exports_are_atomic_without_a_sync() {
use crate::export::{Conflict, Destination};
use lightcraft_catalog::safe_file::syncs_on_this_thread;
let (mut s, id, dir, _src, _) = library_with_jpeg("export-nosync");
let out = dir.join("out");
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
let o = ExportOptions { conflict: Conflict::Unique, ..Default::default() };
let before = syncs_on_this_thread();
for _ in 0..3 {
disk_batch(&mut s, id, &o, &to).unwrap();
}
assert_eq!(syncs_on_this_thread(), before, "no per-file sync for exports");
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 3, "three exports, no temp files");
crate::export::write_file_durable(&out.join("edit.tif").to_string_lossy(), b"tiff").unwrap();
assert!(syncs_on_this_thread() > before, "the durable writer syncs");
let _ = std::fs::remove_dir_all(&dir);
}
+75 -7
View File
@@ -144,11 +144,10 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
#[test]
fn find_missing_matches_renamed_files_by_content() {
let dir = temp_dir("missing-content");
/// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with
/// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors.
fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) {
let dir = temp_dir(tag);
std::fs::create_dir_all(dir.join("old")).unwrap();
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
@@ -156,20 +155,26 @@ fn find_missing_matches_renamed_files_by_content() {
}
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
// an impostor: same name and size, other bytes
let impostor = |src: &Path, dst: &Path| {
let mut b = std::fs::read(src).unwrap();
let k = b.len() - 20;
b[k] ^= 0xff;
std::fs::write(dst, b).unwrap();
};
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
std::fs::remove_file(dir.join("old/c.png")).unwrap();
(dir, s)
}
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
#[test]
fn find_missing_matches_renamed_files_by_content() {
let (dir, mut s) = renamed_and_ambiguous_scene("missing-content");
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
let found = r["found"].as_array().unwrap();
assert_eq!(found.len(), 2, "{r}");
@@ -183,6 +188,55 @@ fn find_missing_matches_renamed_files_by_content() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan
/// afterwards; that gives exactly what the synchronous command gives — renamed files by content,
/// impostors and look-alikes skipped (`ambiguous`).
#[test]
fn find_missing_planned_on_a_worker_matches_the_command() {
use crate::cmd::missing::{find_candidates, plan_find_missing};
let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker");
let folder = dir.join("moved").to_string_lossy().to_string();
let candidates = find_candidates(&s.catalog);
let f = folder.clone();
let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap();
assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}");
let applied = s.execute("library.findMissing", &plan.to_json()).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three");
let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap();
assert_eq!(applied, direct);
assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}");
assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked
/// meanwhile is left alone, and a file that is now another photo's original isn't used.
#[test]
fn find_missing_plan_is_rechecked_when_applied() {
use crate::cmd::missing::{find_candidates, plan_find_missing};
let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck");
let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap();
let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone();
let (a, b) = (to("a.png"), to("b.png"));
// meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file
std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap();
s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap();
let c = plan.ambiguous[0].id;
s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap();
let r = s.execute("library.findMissing", &plan.to_json()).unwrap();
assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}");
assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}");
let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => String::new(),
};
assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy());
assert_eq!(path_of(b.id), b.from);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_files_are_found_and_relinked() {
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
@@ -425,6 +479,20 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
// the app's background build (issue #104) checks and repairs the same way, on a worker thread
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap();
assert_eq!(r["what"], "smart previews", "{r}");
let t0 = std::time::Instant::now();
let r = loop {
let r = s.execute("library.previewProgress", &json!({})).unwrap();
if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) {
break r;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}");
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
+113
View File
@@ -207,6 +207,67 @@ fn copy_import_verifies_each_copy() {
let _ = std::fs::remove_dir_all(&base);
}
/// Copy verifies against the probe's content hash (issue #134): the hash, not a re-read of the
/// source, is the reference — a copy that matches the source but not the hash is refused, a
/// corrupted copy is caught, and the file hash agrees with `hash_bytes` across read chunks.
#[test]
fn copy_is_verified_against_the_probe_hash() {
use crate::import_move::copy_verified;
let base = temp_dir("copyhash");
let src = base.join("src.bin");
// larger than one 1 MB read, not a multiple of it
let bytes: Vec<u8> = (0..(3 << 20) + 12_345u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
std::fs::write(&src, &bytes).unwrap();
let good = lightcraft_preview::hash_bytes(&bytes);
copy_verified(&src, &base.join("ok.bin"), Some(good)).unwrap();
assert_eq!(std::fs::read(base.join("ok.bin")).unwrap(), bytes);
// a hash that doesn't match: refused (a byte compare with the source would have passed)
let stale = lightcraft_preview::hash_bytes(b"what the probe read earlier");
let e = copy_verified(&src, &base.join("stale.bin"), Some(stale)).unwrap_err();
assert!(e.to_string().contains("differs"), "{e}");
assert!(!base.join("stale.bin").exists(), "the refused copy is removed");
// a copy that comes out different: caught by the hash
inject(Fault::Corrupt);
let r = copy_verified(&src, &base.join("bad.bin"), Some(good));
inject(Fault::None);
assert!(r.is_err());
assert!(!base.join("bad.bin").exists());
assert_eq!(std::fs::read(&src).unwrap(), bytes, "the source is untouched");
let _ = std::fs::remove_dir_all(&base);
}
/// A file replaced after Review Import probed it is not imported on the strength of the old probe:
/// its copy no longer matches the probe's hash, so it's reported as failed and nothing is left.
#[test]
fn copy_import_refuses_a_file_changed_since_the_review() {
let base = temp_dir("copychanged");
let (card, dest) = (base.join("card"), base.join("out"));
write_png(&card.join("a.png"), 3);
let mut s = session();
let r = s.execute("library.importPreview", &json!({"paths": [card.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 1, "{r}");
write_png(&card.join("a.png"), 9); // other pixels
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
)
.unwrap();
assert_eq!(len(&r, "imported"), 0, "{r}");
assert_eq!(len(&r, "failed"), 1, "{r}");
assert!(r.to_string().contains("changed meanwhile"), "{r}");
assert_eq!(files_under(&dest), Vec::<String>::new());
// importing again probes afresh and succeeds
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
)
.unwrap();
assert_eq!(len(&r, "imported"), 1, "{r}");
let _ = std::fs::remove_dir_all(&base);
}
/// A source that can't be removed (a read-only card) stays and is reported; the photo is
/// imported from its copy.
#[test]
@@ -320,3 +381,55 @@ fn move_into_the_library_is_saved_before_sources_go() {
assert_eq!(photo_paths(&again), [want.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&base);
}
/// Timing for issue #134 (not a pass/fail test): `cargo test -p lightcraft-engine --release
/// write_policy_timing -- --ignored --nocapture`. Writes N export-sized files durably (temp +
/// sync + rename) and atomically only, and copies N files verified byte for byte and against the
/// probe hash, alternating so a loaded machine affects both alike. `LIGHTCRAFT_BENCH_DIR` puts
/// the files on another volume (a USB drive or a NAS is where the difference shows).
#[test]
#[ignore]
fn write_policy_timing() {
use crate::import_move::copy_verified;
use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync};
use std::time::{Duration, Instant};
let root = std::env::var_os("LIGHTCRAFT_BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
let base = root.join(format!("lc-write-policy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let data = |n: usize, seed: u32| -> Vec<u8> { (0..n as u32).map(|i| (i.wrapping_add(seed).wrapping_mul(2_654_435_761) >> 11) as u8).collect() };
let (n, size) = (200usize, 300 << 10);
let (mut synced, mut unsynced) = (Duration::ZERO, Duration::ZERO);
for i in 0..n {
let b = data(size, i as u32);
let t = Instant::now();
write_atomic(&base.join(format!("sync-{i}.jpg")), &b).unwrap();
synced += t.elapsed();
let t = Instant::now();
write_atomic_nosync(&base.join(format!("nosync-{i}.jpg")), &b).unwrap();
unsynced += t.elapsed();
}
println!("export {n} × {} KB: write_atomic (synced) {synced:?}, write_atomic_nosync {unsynced:?}", size >> 10);
let (n, size) = (40usize, 8 << 20);
let card = base.join("card");
std::fs::create_dir_all(&card).unwrap();
let hashes: Vec<_> = (0..n)
.map(|i| {
let b = data(size, 7 * i as u32);
std::fs::write(card.join(format!("{i}.raw")), &b).unwrap();
lightcraft_preview::hash_bytes(&b)
})
.collect();
let (mut bytewise, mut hashed) = (Duration::ZERO, Duration::ZERO);
for (i, h) in hashes.iter().enumerate() {
let src = card.join(format!("{i}.raw"));
let t = Instant::now();
copy_verified(&src, &base.join(format!("bytes-{i}.raw")), None).unwrap();
bytewise += t.elapsed();
let t = Instant::now();
copy_verified(&src, &base.join(format!("hash-{i}.raw")), Some(*h)).unwrap();
hashed += t.elapsed();
}
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
let _ = std::fs::remove_dir_all(&base);
}
+19
View File
@@ -201,6 +201,25 @@ fn album_source_reports_in_library_state_and_persists_the_view() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A library opens unfiltered: the source and sort come back, a filter from the last session does not
/// (it would hide photos behind a small badge).
#[test]
fn filters_are_not_restored_when_a_library_reopens() {
let dir = temp_dir("filter-not-restored");
let mut s = open(&dir);
let all = s.visible_cloned().len();
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap();
assert!(s.visible_cloned().len() < all);
s.close_library().unwrap();
drop(s);
let mut s2 = open(&dir);
assert_eq!(s2.filter, lightcraft_catalog::Filter::default());
assert_eq!(s2.visible_cloned().len(), all);
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
let dir = temp_dir("chips");
+232
View File
@@ -0,0 +1,232 @@
//! AI masks without the SAM 3 model (it is never required): every request is a clear error that
//! comes back at once, nothing waits on the network or the model, a damaged model is an error
//! (not a crash or a stuck "busy"), and the download command needs consent and fails cleanly.
//! No test touches the internet: downloads go to a local server.
use std::time::{Duration, Instant};
use serde_json::json;
use crate::Session;
use crate::segment::{NOT_INSTALLED, Segmenter};
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-sam3-engine-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// A folder that looks installed but holds no usable model.
fn damaged_model(name: &str) -> std::path::PathBuf {
let d = tmp(name);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("model.safetensors"), b"\x10\x00\x00\x00\x00\x00\x00\x00{not json at all}").unwrap();
std::fs::write(d.join("vocab.json"), b"{}").unwrap();
std::fs::write(d.join("merges.txt"), b"#version: 0.2\n").unwrap();
d
}
/// Poll until the worker is idle (or `limit` passes), collecting messages.
fn settle(s: &mut Session, limit: Duration) -> Vec<String> {
let t = Instant::now();
let mut messages = Vec::new();
loop {
let p = s.segment_poll();
messages.extend(p.messages);
if !s.segmenter.busy() && !s.segmenter.detail_busy() {
messages.extend(s.segment_poll().messages);
return messages;
}
assert!(t.elapsed() < limit, "the worker never finished");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn status_and_model_commands_without_the_model() {
let mut s = Session::with_demo();
let st = s.execute("segment.model.status", &json!({})).unwrap();
assert_eq!(st["available"], Segmenter::AVAILABLE);
assert_eq!(st["installed"], false);
assert_eq!(st["download"]["running"], false);
assert!(st["license"].as_str().unwrap().contains("SAM License"));
// downloading needs the user's consent, and names the size and the licence
let e = s.execute("segment.model.download", &json!({})).unwrap_err().to_string();
assert!(e.contains("acknowledged") && e.contains("SAM License") && e.contains("GB"), "{e}");
let e = s.execute("segment.model.download", &json!({"acknowledged": "yes"})).unwrap_err().to_string();
assert!(e.contains("acknowledged"), "{e}");
assert_eq!(s.execute("segment.model.cancel", &json!({})).unwrap()["cancelled"], false);
if !Segmenter::AVAILABLE {
assert!(s.execute("segment.model.download", &json!({"acknowledged": true})).is_err());
return;
}
// no folder, then no download location: clear errors, nothing started
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("no folder"), "{e}");
let dir = tmp("nomirrors");
s.segmenter.dir = Some(dir.clone());
s.segmenter.mirrors_file = Some(dir.join("none.txt"));
if s.segmenter.mirrors().is_empty() {
let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string();
assert!(e.contains("LIGHTCRAFT_SAM3_MIRRORS"), "{e}");
}
assert!(!s.segmenter.download_status().running);
}
#[cfg(feature = "sam")]
#[test]
fn a_failing_download_ends_with_an_error_and_never_blocks() {
use std::io::{BufRead, BufReader, Write};
// a local mirror that has nothing
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/sam3", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut c in l.incoming().flatten() {
let mut r = BufReader::new(c.try_clone().unwrap());
let mut line = String::new();
while r.read_line(&mut line).unwrap_or(0) > 2 {
line.clear();
}
let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n");
}
});
let dir = tmp("download404");
let mirrors = dir.with_extension("mirrors.txt");
std::fs::write(&mirrors, format!("# test mirror\n{base}\n")).unwrap();
let mut s = Session::with_demo();
s.segmenter.dir = Some(dir.clone());
s.segmenter.mirrors_file = Some(mirrors.clone());
let t = Instant::now();
let r = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
assert_eq!(r["started"], true);
assert!(t.elapsed() < Duration::from_secs(1), "starting a download returns at once");
// a second request while it runs starts nothing
let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap();
assert!(again["started"] == false || again["downloading"] == false);
let t = Instant::now();
while s.segmenter.download_status().running {
assert!(t.elapsed() < Duration::from_secs(30), "the download never ended");
// AI mask requests meanwhile: an error at once, never a wait
let q = Instant::now();
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
assert!(e.contains(NOT_INSTALLED), "{e}");
assert!(q.elapsed() < Duration::from_millis(500));
std::thread::sleep(Duration::from_millis(20));
}
let st = s.segmenter.download_status();
assert!(!st.finished);
let e = st.error.unwrap();
assert!(e.contains("not found"), "{e}");
assert!(!s.segmenter.installed());
// nothing was left behind under a real name, and no error page was saved
for f in ["model.safetensors", "vocab.json", "merges.txt", "vocab.json.part"] {
assert!(!dir.join(f).exists(), "{f}");
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&mirrors);
}
/// The app (background mode) never waits: with the model missing every request fails at once;
/// with a damaged model the requests are queued, fail on the worker, and come back as messages.
#[test]
fn background_requests_never_wait_and_failures_come_back_as_messages() {
let mut s = Session::with_demo();
s.segmenter.background = true;
let id = s.active().unwrap();
s.segmenter.dir = Some(tmp("missing"));
for (cmd, p) in [("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
{
let t = Instant::now();
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(t.elapsed() < Duration::from_millis(500), "{cmd}");
if Segmenter::AVAILABLE {
assert!(e.starts_with(NOT_INSTALLED), "{cmd}: {e}");
} else {
assert!(e.contains("not available"), "{cmd}: {e}");
}
}
assert!(s.develop_of(id).unwrap().masks.is_empty());
if !Segmenter::AVAILABLE {
return;
}
// a model folder whose weights are damaged
let dir = damaged_model("damaged");
s.segmenter.dir = Some(dir.clone());
assert!(s.segmenter.installed());
let t = Instant::now();
s.execute("mask.add", &json!({"kind": "object"})).unwrap();
let r = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap();
assert_eq!(r["pending"], true);
assert!(s.segmenter.pending_clicks().is_some_and(|p| p.hint.len() == 1));
// a second click while the first is on its way builds on it
let r = s.execute("mask.objectPoint", &json!({"x": 0.6, "y": 0.5, "exclude": true})).unwrap();
assert_eq!((r["include"].as_u64(), r["exclude"].as_u64()), (Some(1), Some(1)));
let r = s.execute("mask.add", &json!({"kind": "prompt", "text": "the sky"})).unwrap();
assert_eq!(r["pending"], true);
assert!(t.elapsed() < Duration::from_secs(1), "nothing waited for the model: {:?}", t.elapsed());
let messages = settle(&mut s, Duration::from_secs(60));
assert!(!messages.is_empty() && messages.iter().all(|m| !m.is_empty()), "{messages:?}");
assert!(messages.iter().any(|m| m.contains("model.safetensors") || m.contains("SAM 3")), "{messages:?}");
assert!(s.segmenter.pending_clicks().is_none());
// the Object mask is there, without a selection; no Describe mask was made from nothing
let d = s.develop_of(id).unwrap();
assert_eq!(d.masks.len(), 1);
assert!(matches!(&d.masks[0].components[0].shape, lightcraft_develop::MaskShape::Object { seg: None, .. }));
assert!(!s.segmenter.busy() && !s.segmenter.loaded());
let _ = std::fs::remove_dir_all(&dir);
}
/// CLI / MCP (waiting mode): a damaged model is an error from the command, quickly.
#[test]
fn waiting_requests_with_a_damaged_model_are_errors() {
if !Segmenter::AVAILABLE {
return;
}
let mut s = Session::with_demo();
let id = s.active().unwrap();
let dir = damaged_model("damaged-wait");
s.segmenter.dir = Some(dir.clone());
let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string();
assert!(e.contains("model.safetensors") || e.contains("SAM 3"), "{e}");
let e = s.execute("mask.add", &json!({"kind": "object", "points": [[0.5, 0.5]]})).unwrap_err().to_string();
assert!(!e.is_empty());
assert!(s.develop_of(id).unwrap().masks.is_empty());
assert!(!s.segmenter.busy());
// a stored segmentation needs no model at all (replayed or pasted masks)
let seg = serde_json::to_value(lightcraft_develop::SegMask::from_logits(4, &[5.0; 16])).unwrap();
s.segmenter.dir = None;
s.execute("mask.add", &json!({"kind": "prompt", "text": "sky", "seg": seg})).unwrap();
assert_eq!(s.develop_of(id).unwrap().masks.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// Too many clicks on one Object selection are refused, not sent to the model.
#[test]
fn object_clicks_are_capped() {
if !Segmenter::AVAILABLE {
return;
}
let mut s = Session::with_demo();
s.segmenter.background = true;
let dir = damaged_model("clicks");
s.segmenter.dir = Some(dir.clone());
let id = s.active().unwrap();
let mut d = (*s.develop_of(id).unwrap()).clone();
let many = vec![lightcraft_geom::Point::new(0.5, 0.5); crate::segment::MAX_CLICKS];
d.masks.push(lightcraft_develop::Mask {
id: 1,
components: vec![lightcraft_develop::MaskComponent {
name: None,
op: lightcraft_develop::MaskOp::Add,
invert: false,
shape: lightcraft_develop::MaskShape::Object { hint: many, exclude: vec![], seg: None, detail: vec![], edge: 0.0 },
}],
..Default::default()
});
s.set_develop(id, d, "test").unwrap();
s.active_mask = Some(1);
let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string();
assert!(e.contains("at most"), "{e}");
assert!(!s.segmenter.busy());
let _ = std::fs::remove_dir_all(&dir);
}
+156
View File
@@ -173,6 +173,162 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
let _ = std::fs::remove_dir_all(&src);
}
const MWG_REGIONS: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<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:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions>
<rdf:Description>
<mwg-rs:AppliedToDimensions stDim:w="100" stDim:h="100" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</rdf:Description>
</mwg-rs:Regions>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on
/// import — the same path real catalogs use, exercised end to end rather than just at the parser.
#[test]
fn regions_from_sidecar_are_read_on_import() {
let src = temp_dir("regions");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["sidecars"], 1);
let p = s.catalog.photos().next().unwrap().clone();
assert_eq!(p.meta.regions.len(), 1);
let region = &p.meta.regions[0];
assert_eq!(region.name.as_deref(), Some("Jane Doe"));
assert_eq!(region.kind, lightcraft_meta::RegionKind::Face);
assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect);
let _ = std::fs::remove_dir_all(&src);
}
/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's
/// regions and everything else in it stay byte for byte), even with auto-write on.
#[test]
fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() {
let src = temp_dir("region-remove");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
let n = s.catalog.photo(id).unwrap().meta.regions.len();
assert!(n >= 1);
assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range");
assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index");
let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap();
assert_eq!(r["removed"], "Jane Doe");
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1);
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
// an ordinary edit still auto-writes (the guard is per command)
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits");
// undo brings the region back
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n);
let _ = std::fs::remove_dir_all(&src);
}
/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the
/// sidecar alone like removal does.
#[test]
fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() {
let src = temp_dir("region-resize");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
let original = s.catalog.photo(id).unwrap().meta.regions[0].rect;
let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect}));
// hostile input is an error and changes nothing
for bad in [
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}),
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}),
json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}),
json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}),
json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}),
] {
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
}
assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region");
assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect");
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
// corners given in any order and outside the photo are clamped into it
let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap();
assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0}));
let now = s.catalog.photo(id).unwrap().meta.regions[0].rect;
assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0));
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
let _ = std::fs::remove_dir_all(&src);
}
/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list
/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the
/// photo's regions alone.
#[test]
fn rereading_a_sidecar_clears_regions_only_when_it_states_them() {
let src = temp_dir("region-reread");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
// another app rewrote the sidecar without any notion of regions: they stay
std::fs::write(
src.join("portrait.xmp"),
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:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/>
</rdf:RDF></x:xmpmeta>"#,
)
.unwrap();
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.rating, 3, "the sidecar was read");
assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions");
// a region-aware app removed every region: the sidecar says so, and the catalog follows
let emptied = MWG_REGIONS.replace(
r#"<rdf:li>
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>"#,
"",
);
assert_ne!(emptied, MWG_REGIONS);
std::fs::write(src.join("portrait.xmp"), emptied).unwrap();
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions");
// undo brings them back
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
let _ = std::fs::remove_dir_all(&src);
}
fn synthetic_dng(xmp: &str) -> Vec<u8> {
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
}
+3 -1
View File
@@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
if let Some(e) = f.edited {
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
}
for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] {
for (name, key, val) in
[("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)]
{
if let Some(x) = val {
add(format!("{name}: {x}"), json!({key: Null}));
}
+30
View File
@@ -332,6 +332,23 @@ impl Orientation {
// flipH ∘ rot(t) = rot(-t) ∘ flipH
Orientation::from_parts(!f, (4 - t) % 4)
}
/// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns
/// are their own inverses; quarter turns swap.
pub fn inverse(self) -> Orientation {
match self {
Orientation::Rotate90 => Orientation::Rotate270,
Orientation::Rotate270 => Orientation::Rotate90,
o => o,
}
}
/// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates
/// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's
/// Rotate Left/Right).
pub fn map_norm_rect(self, r: Rect) -> Rect {
let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0);
let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0);
Rect::from_points(Point::new(ax, ay), Point::new(bx, by))
}
/// Map a point in stored pixel space (w×h stored dims) to oriented space.
pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
let (flip, t) = self.to_parts();
@@ -417,6 +434,19 @@ mod tests {
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
}
#[test]
fn orientation_inverse_and_norm_rect() {
let r = Rect::new(0.1, 0.2, 0.3, 0.6);
for v in 1..=8 {
let o = Orientation::from_exif(v);
let back = o.inverse().map_norm_rect(o.map_norm_rect(r));
assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}");
}
// a quarter turn clockwise: the left of the upright photo becomes its top
let q = Orientation::Rotate90.map_norm_rect(r);
assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}");
}
proptest::proptest! {
#[test]
fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) {
+234
View File
@@ -0,0 +1,234 @@
//! Which wgpu backends LightCraft lets wgpu use — for its compute device and for the desktop
//! window (eframe) — and the crash sentinel around device creation (issue #136).
//!
//! wgpu loads the system driver of *every* backend in the instance's set while it enumerates
//! adapters, even when it then picks another one: with Vulkan in the set, a broken Vulkan driver
//! can crash the process (an access violation inside the driver DLL, which nothing can catch)
//! although DX12 would have been used. So Windows defaults to DX12 alone. macOS uses Metal, Linux
//! Vulkan (plus GL for the window when the build has it).
//!
//! Overrides, read once per process:
//! - `LIGHTCRAFT_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto`
//! (the platform default), or `off` (no GPU compute; the window still needs one backend and
//! keeps the platform default);
//! - else `WGPU_BACKEND` (wgpu's own variable, same names) for both the window and compute.
//!
//! Backends this build doesn't contain (e.g. `gl`: the GL backend isn't compiled in) are ignored
//! with a warning and the platform default is used.
use std::path::PathBuf;
use std::sync::Mutex;
pub use wgpu::Backends;
/// What the environment asks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BackendChoice {
/// The platform default.
Auto,
/// No GPU compute.
Off,
/// These backends.
Use(Backends),
}
/// Parse a backend list (`"dx12"`, `"vulkan,gl"`, `"auto"`, `"off"`…). `None`: nothing usable in
/// it (empty or unknown names).
pub fn parse_backends(s: &str) -> Option<BackendChoice> {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"" => None,
"auto" | "default" => Some(BackendChoice::Auto),
"off" | "none" | "cpu" | "0" | "false" | "no" => Some(BackendChoice::Off),
_ => {
let b = Backends::from_comma_list(&s);
(!b.is_empty()).then_some(BackendChoice::Use(b))
}
}
}
/// The choice from `LIGHTCRAFT_GPU_BACKEND` (`lightcraft`) or else `WGPU_BACKEND` (`wgpu`): the
/// first one that parses wins; an unknown value is skipped (with a warning).
pub fn choose(lightcraft: Option<&str>, wgpu: Option<&str>) -> BackendChoice {
for (name, v) in [("LIGHTCRAFT_GPU_BACKEND", lightcraft), ("WGPU_BACKEND", wgpu)] {
let Some(v) = v else { continue };
match parse_backends(v) {
// WGPU_BACKEND is wgpu's variable: it selects backends, it never turns the GPU off
Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use LIGHTCRAFT_GPU_BACKEND=off)"),
Some(c) => return c,
None => log::warn!("gpu: {name}={v} names no known backend; ignored"),
}
}
BackendChoice::Auto
}
/// The backends compute devices use when nothing is asked for on `os` (`std::env::consts::OS`).
pub fn default_compute_backends(os: &str) -> Backends {
match os {
// never touch the Vulkan driver unless asked: see the module docs (issue #136)
"windows" => Backends::DX12,
"macos" | "ios" => Backends::METAL,
_ => Backends::PRIMARY,
}
}
/// The backends the window uses when nothing is asked for on `os`.
pub fn default_window_backends(os: &str) -> Backends {
match os {
"windows" => Backends::DX12,
"macos" | "ios" => Backends::METAL,
// eframe's default (GL only when the build has it)
_ => Backends::PRIMARY | Backends::GL,
}
}
/// Resolve `choice` against the backends this build contains (`compiled`): `None` = GPU off.
/// Asked-for backends that aren't compiled in fall back to `default`.
pub fn resolve(choice: BackendChoice, default: Backends, compiled: Backends) -> Option<Backends> {
match choice {
BackendChoice::Off => None,
BackendChoice::Auto => Some(default),
BackendChoice::Use(b) if b.intersects(compiled) => Some(b & compiled),
BackendChoice::Use(b) => {
log::warn!("gpu: backend {b:?} is not in this build ({compiled:?}); using {default:?}");
Some(default)
}
}
}
fn env_choice() -> BackendChoice {
static C: std::sync::OnceLock<BackendChoice> = std::sync::OnceLock::new();
*C.get_or_init(|| {
let lc = std::env::var("LIGHTCRAFT_GPU_BACKEND").ok();
let wg = std::env::var("WGPU_BACKEND").ok();
choose(lc.as_deref(), wg.as_deref())
})
}
/// The backends of the compute device (`None`: `LIGHTCRAFT_GPU_BACKEND=off`).
pub fn compute_backends() -> Option<Backends> {
resolve(env_choice(), default_compute_backends(std::env::consts::OS), wgpu::Instance::enabled_backend_features())
}
/// The backends for the desktop window's renderer (eframe/egui-wgpu). The window always needs
/// one: `off` keeps the platform default.
pub fn window_backends() -> Backends {
let default = default_window_backends(std::env::consts::OS);
let choice = match env_choice() {
BackendChoice::Off => BackendChoice::Auto,
c => c,
};
resolve(choice, default, wgpu::Instance::enabled_backend_features()).unwrap_or(default)
}
/// `LIGHTCRAFT_GPU_BACKEND=off`.
pub(crate) fn env_off() -> bool {
env_choice() == BackendChoice::Off
}
static MARKER: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Write this file before the compute device is created and remove it once creation returned
/// (successfully or not). A file still there at the next launch means the process died inside
/// the driver: see [`take_init_marker`]. `None` (the default) writes nothing.
pub fn set_init_marker(path: Option<PathBuf>) {
*MARKER.lock().unwrap_or_else(|e| e.into_inner()) = path;
}
/// Run device creation `f` between writing and removing the init marker (if one is set).
pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) -> T {
let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(m) = &marker {
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let _ = std::fs::write(m, format!("GPU device creation started (backends {backends:?}, unix time {secs})\n"));
}
let r = f();
if let Some(m) = &marker {
let _ = std::fs::remove_file(m);
}
r
}
/// If the init marker `path` is present — the last process died while creating the GPU device —
/// remove it and return what it recorded.
pub fn take_init_marker(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let _ = std::fs::remove_file(path);
Some(text.trim().to_string())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parses_backend_names() {
assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12)));
assert_eq!(parse_backends(" D3D12 "), Some(BackendChoice::Use(Backends::DX12)));
assert_eq!(parse_backends("vulkan"), Some(BackendChoice::Use(Backends::VULKAN)));
assert_eq!(parse_backends("vk,gl"), Some(BackendChoice::Use(Backends::VULKAN | Backends::GL)));
assert_eq!(parse_backends("metal"), Some(BackendChoice::Use(Backends::METAL)));
assert_eq!(parse_backends("auto"), Some(BackendChoice::Auto));
for off in ["off", "OFF", "none", "cpu", "0"] {
assert_eq!(parse_backends(off), Some(BackendChoice::Off), "{off}");
}
assert_eq!(parse_backends(""), None);
assert_eq!(parse_backends("directx9"), None);
}
#[test]
fn lightcraft_variable_wins_over_wgpu_backend() {
assert_eq!(choose(None, None), BackendChoice::Auto);
assert_eq!(choose(None, Some("dx12")), BackendChoice::Use(Backends::DX12));
assert_eq!(choose(Some("vulkan"), Some("dx12")), BackendChoice::Use(Backends::VULKAN));
assert_eq!(choose(Some("off"), Some("dx12")), BackendChoice::Off);
// unknown LightCraft value: WGPU_BACKEND still applies
assert_eq!(choose(Some("bogus"), Some("gl")), BackendChoice::Use(Backends::GL));
// WGPU_BACKEND never turns the GPU off
assert_eq!(choose(None, Some("off")), BackendChoice::Auto);
}
/// Issue #136: Windows must not load the Vulkan driver unless asked to.
#[test]
fn windows_defaults_to_dx12_only() {
assert_eq!(default_compute_backends("windows"), Backends::DX12);
assert_eq!(default_window_backends("windows"), Backends::DX12);
assert!(!default_window_backends("windows").contains(Backends::VULKAN));
assert_eq!(default_compute_backends("macos"), Backends::METAL);
assert!(default_compute_backends("linux").contains(Backends::VULKAN));
assert!(default_window_backends("linux").contains(Backends::VULKAN | Backends::GL));
}
#[test]
fn resolves_against_the_build() {
let compiled = Backends::DX12 | Backends::VULKAN;
let def = Backends::DX12;
assert_eq!(resolve(BackendChoice::Auto, def, compiled), Some(def));
assert_eq!(resolve(BackendChoice::Off, def, compiled), None);
assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN), def, compiled), Some(Backends::VULKAN));
assert_eq!(resolve(BackendChoice::Use(Backends::VULKAN | Backends::GL), def, compiled), Some(Backends::VULKAN));
// GL isn't compiled in: the default, not an empty set (no adapter at all)
assert_eq!(resolve(BackendChoice::Use(Backends::GL), def, compiled), Some(def));
}
#[test]
fn init_marker_is_written_during_creation_and_removed_after() {
let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id()));
let m = dir.join("gpu-init.marker");
let _ = std::fs::remove_dir_all(&dir);
set_init_marker(Some(m.clone()));
let seen = with_init_marker(Backends::DX12, || m.exists());
set_init_marker(None);
assert!(seen, "the marker exists while the device is created");
assert!(!m.exists(), "and is removed afterwards");
// a marker left behind by a crashed process is reported once
std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap();
assert!(take_init_marker(&m).unwrap().contains("DX12"));
assert_eq!(take_init_marker(&m), None);
let _ = std::fs::remove_dir_all(&dir);
}
}
+8 -5
View File
@@ -233,14 +233,16 @@ pub struct Gpu {
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
fn constants() -> String {
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
use lightcraft_pipeline::tone::{CHROMA_N, LUT_MAX_EV, LUT_MIN_EV, LUT_N};
let mut s = String::new();
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
}
s += &format!("const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\n");
s += &format!(
"const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\nconst CHROMA_N: u32 = {CHROMA_N}u;\n"
);
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
let b = lightcraft_pipeline::geometry::BLANK;
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
@@ -276,14 +278,15 @@ fn module_source(m: &Module, consts: &str) -> String {
impl Gpu {
/// Create a device on the best available adapter (no software fallback), or why there is none.
pub fn new() -> Result<Gpu, String> {
pub fn new(backends: wgpu::Backends) -> Result<Gpu, String> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::PRIMARY;
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
desc.backends = backends;
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.map_err(|e| format!("no GPU adapter ({e})"))?;
.map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?;
let info = adapter.get_info();
if info.device_type == wgpu::DeviceType::Cpu {
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
+33 -9
View File
@@ -6,8 +6,11 @@
//! same settings on both and bound the difference in 8-bit sRGB. Stages without a kernel run on the
//! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`.
//!
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0` or
//! [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
//! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and
//! the crash sentinel around device creation: [`backend`] (issue #136).
//!
//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`,
//! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out
//! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`]
//! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`).
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
@@ -22,6 +25,8 @@ use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_raster::Rgb32f;
#[cfg(not(target_arch = "wasm32"))]
pub mod backend;
#[cfg(not(target_arch = "wasm32"))]
mod ctx;
#[cfg(not(target_arch = "wasm32"))]
@@ -61,6 +66,10 @@ fn record_fallback(reason: String) {
/// Never blocks.
pub fn unavailable_reason() -> Option<String> {
if env_disabled() {
#[cfg(not(target_arch = "wasm32"))]
if backend::env_off() {
return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into());
}
return Some("disabled by LIGHTCRAFT_GPU=0".into());
}
if !ENABLED.load(Ordering::Relaxed) {
@@ -136,9 +145,15 @@ pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled()
}
/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process.
fn env_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")))
*OFF.get_or_init(|| {
let off = std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
#[cfg(not(target_arch = "wasm32"))]
let off = off || backend::env_off();
off
})
}
#[cfg(not(target_arch = "wasm32"))]
@@ -146,10 +161,18 @@ static GPU: std::sync::OnceLock<Result<ctx::Gpu, String>> = std::sync::OnceLock:
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
if env_disabled() {
return None;
// turned off (preference, environment): don't even load the driver (issue #136)
if env_disabled() || !ENABLED.load(Ordering::Relaxed) {
return existing_device();
}
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).unwrap_or_else(|_| Err("device creation panicked".into()))).as_ref().ok()
GPU.get_or_init(|| {
let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) };
backend::with_init_marker(backends, || {
std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into()))
})
})
.as_ref()
.ok()
}
/// The device if it has been created (never creates it).
@@ -158,11 +181,12 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> {
GPU.get().and_then(|g| g.as_ref().ok())
}
/// Create the device and compile the kernels on a background thread now (app start), so the
/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does.
/// Create the device and compile the kernels on a background thread now (once the window is up),
/// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing
/// while GPU rendering is turned off.
pub fn warm_up() {
#[cfg(not(target_arch = "wasm32"))]
if !env_disabled() && GPU.get().is_none() {
if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() {
let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
let _ = device();
});
+2 -1
View File
@@ -136,10 +136,11 @@ pub struct Present {
pub chroma: bool,
}
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | chroma curve | sRGB LUT | curve LUTs |
/// mask terms) for `fp` with `masks` (their local adjustments' terms).
pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Present) -> (Vec<u32>, Vec<f32>) {
let mut aux: Vec<f32> = fp.tone.lut().to_vec();
aux.extend_from_slice(fp.tone.chroma_lut());
let srgb_off = aux.len();
aux.extend_from_slice(&srgb_lut()[..]);
let curve_off = aux.len();
+18 -1
View File
@@ -1,7 +1,7 @@
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
// blurred chromaticity when HAS_CHROMA), aux
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
// (tone LUT | chroma curve | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
fn tone_apply(y: f32) -> f32 {
if (y <= 0.0) {
@@ -18,6 +18,19 @@ fn tone_apply(y: f32) -> f32 {
return v;
}
// The camera chroma curve follows the tone LUT in `aux` (`ToneMap::chroma_scale`).
fn chroma_scale(o: f32) -> f32 {
let f = clamp(o, 0.0, 1.0) * f32(CHROMA_N - 1u);
let i = min(u32(f), CHROMA_N - 2u);
let t = f - f32(i);
let a = aux[TONE_N + i];
let b = aux[TONE_N + i + 1u];
if (a == b) {
return a;
}
return a + (b - a) * t;
}
fn encode_srgb(v: f32) -> f32 {
let o = pu(F_SRGB_OFF);
let f = clamp(v, 0.0, 1.0) * f32(SRGB_N);
@@ -400,6 +413,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (yl > 1e-9) {
d = c * o / yl;
}
let k = chroma_scale(o);
if (k != 1.0) {
d = vec3<f32>(o) + (d - vec3<f32>(o)) * k;
}
let mx = max(d.x, max(d.y, d.z));
if (mx > 1.0) {
let t = clamp((mx - 1.0) / max(mx - o, 1e-6), 0.0, 1.0);
+26
View File
@@ -22,6 +22,32 @@ fn gpu() -> bool {
ok
}
#[test]
fn camera_tone_and_relative_wb() {
if !gpu() {
return;
}
let src = scene(2, 320, 240);
let curve = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() };
let mut s = DevelopSettings::default();
check("camera tone neutral", &src, &info, &s, &RenderRequest::fit(320, 240));
s.light.exposure = 1.0;
s.light.contrast = 40.0;
s.wb.mode = WbMode::Custom;
s.wb.temp = 8000.0;
check("camera tone edited", &src, &info, &s, &RenderRequest::fit(320, 240));
// a camera chroma curve: richer shadows, highlights bleached toward white
let curve = curve.with_chroma([1.4, 1.3, 1.1, 1.0, 0.7, 0.4, 0.25, 0.2]).unwrap();
let info = SourceInfo { camera_tone: Some(curve), ..info };
check("camera chroma curve", &src, &info, &DevelopSettings::default(), &RenderRequest::fit(320, 240));
check("camera chroma curve edited", &src, &info, &s, &RenderRequest::fit(320, 240));
}
fn scene(i: usize, w: usize, h: usize) -> Arc<Rgb32f> {
Arc::new(lightcraft_scenes::demo_library()[i].render(w, h))
}
+1
View File
@@ -45,6 +45,7 @@ pub fn color_data(color: &FrameColor) -> ColorData {
as_shot_neutral: Some([1.0, 1.0, 1.0]),
as_shot_white_xy: None,
baseline_exposure: 0.0,
profile: Default::default(),
}
}
}
+5
View File
@@ -7,6 +7,8 @@
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! IPTC (33723) from IFD0.
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
//! XMP `uuid` box.
use lightcraft_tiff::{Tiff, tags};
@@ -34,6 +36,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
_ => Embedded::default(),
};
}
if let Some(c) = crate::cr3::parse_cr3(bytes) {
return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() };
}
if bytes.starts_with(&[0xff, 0xd8]) {
jpeg_segments(bytes)
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
+344
View File
@@ -0,0 +1,344 @@
//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent
//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files).
//!
//! - `ftyp` with major brand `crx `.
//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF
//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the
//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample
//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`).
//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the
//! XMP packet.
//!
//! Every size and offset is checked against the buffer; nesting and box counts are bounded.
/// A track's sample-entry kind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Cr3TrackKind {
/// `CRAW` with a `JPEG` child: the full-size JPEG.
Jpeg,
/// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file).
Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> },
/// Anything else (`CTMD` timed metadata, unknown entries).
Other([u8; 4]),
}
/// One track's single sample: where it is in the file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cr3Track {
pub kind: Cr3TrackKind,
/// Byte offset and length of the sample (the first one) in the file, when the tables give them.
pub data: Option<(usize, usize)>,
}
/// The parts of a CR3 file LightCraft reads.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Cr3<'a> {
/// `CMT1`…`CMT4` (index 0…3).
pub cmt: [Option<&'a [u8]>; 4],
pub thumbnail: Option<&'a [u8]>,
pub xmp: Option<&'a [u8]>,
pub tracks: Vec<Cr3Track>,
}
const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac];
const MAX_DEPTH: usize = 12;
const MAX_BOXES: usize = 4096;
/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes.
const CRAW_CHILDREN_AT: usize = 82;
/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)?
pub fn is_cr3(bytes: &[u8]) -> bool {
bytes.get(4..12) == Some(b"ftypcrx ".as_slice())
}
/// Parse a CR3 file's boxes; `None` when it is not a CR3 file.
pub fn parse_cr3(bytes: &[u8]) -> Option<Cr3<'_>> {
if !is_cr3(bytes) {
return None;
}
let mut out = Cr3::default();
let mut budget = MAX_BOXES;
for b in boxes(bytes, 0, bytes.len(), &mut budget) {
match &b.kind {
b"moov" => moov(bytes, &b, &mut out, &mut budget),
b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end),
_ => {}
}
}
Some(out)
}
struct BoxRef {
kind: [u8; 4],
/// Start of the payload (after the header and, for `uuid`, the 16-byte uuid).
body: usize,
end: usize,
uuid: Option<[u8; 16]>,
}
/// The boxes directly inside `[from, to)`; stops at the first malformed header.
fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec<BoxRef> {
let mut out = Vec::new();
let mut at = from;
let to = to.min(bytes.len());
while at.saturating_add(8) <= to && *budget > 0 {
*budget -= 1;
let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break };
let mut kind4 = [0u8; 4];
kind4.copy_from_slice(kind);
let (len, mut hdr) = match size {
0 => (to - at, 8),
1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) {
Some(l) => (l, 16),
None => break,
},
n => (n as usize, 8),
};
let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break };
let mut uuid = None;
if &kind4 == b"uuid" {
let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break };
let mut a = [0u8; 16];
a.copy_from_slice(u);
uuid = Some(a);
hdr += 16;
}
out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid });
at = end;
}
out
}
fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) {
for b in boxes(bytes, m.body, m.end, budget) {
match &b.kind {
b"uuid" if b.uuid == Some(CANON_UUID) => {
for c in boxes(bytes, b.body, b.end, budget) {
let slot = match &c.kind {
b"CMT1" => 0,
b"CMT2" => 1,
b"CMT3" => 2,
b"CMT4" => 3,
b"THMB" => {
out.thumbnail = bytes.get(c.body..c.end);
continue;
}
_ => continue,
};
out.cmt[slot] = bytes.get(c.body..c.end);
}
}
b"trak" => {
if let Some(t) = trak(bytes, &b, budget) {
out.tracks.push(t);
}
}
_ => {}
}
}
}
/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location.
fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option<Cr3Track> {
let mut stbl = None;
let mut stack = vec![(t.body, t.end, 0usize)];
while let Some((from, to, depth)) = stack.pop() {
if depth > MAX_DEPTH {
continue;
}
for b in boxes(bytes, from, to, budget) {
match &b.kind {
b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)),
b"stbl" => stbl = Some(b),
_ => {}
}
}
}
let stbl = stbl?;
let (mut kind, mut size, mut offset) = (None, None, None);
for b in boxes(bytes, stbl.body, stbl.end, budget) {
match &b.kind {
// full box: version/flags, entry count, entries
b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
// version/flags, sample_size (0 = per-sample table), count, sizes
b"stsz" => {
size = match be32(bytes, b.body + 4)? {
0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12),
0 => None,
n => Some(n),
}
}
b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()),
b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize),
_ => {}
}
}
let data = match (offset, size) {
(Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)),
_ => None,
};
Some(Cr3Track { kind: kind?, data })
}
fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
if &e.kind != b"CRAW" {
return Cr3TrackKind::Other(e.kind);
}
let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else {
return Cr3TrackKind::Other(e.kind);
};
let mut cmp1 = None;
for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) {
match &c.kind {
b"JPEG" => return Cr3TrackKind::Jpeg,
b"CMP1" => cmp1 = Some((c.body, c.end - c.body)),
_ => {}
}
}
Cr3TrackKind::Raw { width, height, cmp1 }
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
fn be64(b: &[u8], at: usize) -> Option<u64> {
let s = b.get(at..at.checked_add(8)?)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
Some(u64::from_be_bytes(a))
}
/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3
/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block.
pub fn merged_exif(c: &Cr3<'_>) -> Option<Vec<u8>> {
use lightcraft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t};
let ifd0 = Tiff::parse(c.cmt[0]?).ok()?;
let order = ifd0.order;
let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next());
// pointers and offsets that would dangle once the IFDs move
const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514];
let build = |ifd: &lightcraft_tiff::Ifd| {
let mut b = IfdBuilder::new();
for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) {
b.set(e.tag, e.value.clone());
}
b
};
let mut root = build(ifd0.ifds.first()?);
if let Some(exif) = first(1) {
root.set_child(t::EXIF_IFD, build(&exif));
}
if let Some(gps) = first(3) {
root.set_child(t::GPS_IFD, build(&gps));
}
TiffWriter::new(order, false).write(&[root]).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn bx(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
v.extend_from_slice(kind);
v.extend_from_slice(body);
v
}
fn uuid(u: &[u8; 16], body: &[u8]) -> Vec<u8> {
let mut b = u.to_vec();
b.extend_from_slice(body);
bx(b"uuid", &b)
}
fn full(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; 4];
b.extend_from_slice(body);
bx(kind, &b)
}
fn craw(w: u16, h: u16, children: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; CRAW_CHILDREN_AT];
b[24..26].copy_from_slice(&w.to_be_bytes());
b[26..28].copy_from_slice(&h.to_be_bytes());
b.extend_from_slice(children);
bx(b"CRAW", &b)
}
fn trak(entry: Vec<u8>, offset: u64, size: u32) -> Vec<u8> {
let mut stsd = 1u32.to_be_bytes().to_vec();
stsd.extend(entry);
let mut stsz = 0u32.to_be_bytes().to_vec();
stsz.extend(1u32.to_be_bytes());
stsz.extend(size.to_be_bytes());
let mut co64 = 1u32.to_be_bytes().to_vec();
co64.extend(offset.to_be_bytes());
let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat();
bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))
}
fn tiff_with(tag: u16, v: lightcraft_tiff::Value) -> Vec<u8> {
let b = lightcraft_tiff::IfdBuilder::new().with(tag, v);
lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap()
}
/// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP.
pub(crate) fn sample() -> Vec<u8> {
use lightcraft_tiff::{Value, tags as t};
let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into()));
let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400]));
let cmt4 = tiff_with(1, Value::Ascii("N".into()));
let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat());
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
let payload_at = 4096u64;
let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16);
let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32);
file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat()));
file.extend(uuid(&XMP_UUID, b"<x:xmpmeta/>"));
file.resize(payload_at as usize + 64, 0);
file
}
#[test]
fn parses_canon_boxes_tracks_and_xmp() {
let f = sample();
assert!(is_cr3(&f));
let c = parse_cr3(&f).unwrap();
assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some());
assert_eq!(c.thumbnail, Some(b"thumb".as_slice()));
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".as_slice()));
assert_eq!(c.tracks.len(), 2);
assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) });
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
assert_eq!((width, height, len), (60, 40, 4));
assert_eq!(&f[at..at + 2], &[0xff, 0x10]);
assert_eq!(c.tracks[1].data, Some((4112, 32)));
}
#[test]
fn merged_exif_reads_like_a_tiff_raw() {
let f = sample();
let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap());
assert_eq!(m.model.as_deref(), Some("Canon EOS Test"));
assert_eq!(m.iso, Some(400));
}
#[test]
fn hostile_input_never_panics() {
let f = sample();
// every truncation and single-byte corruption parses to something or nothing
for n in 0..f.len().min(800) {
let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c));
}
for i in 0..f.len().min(800) {
for v in [0u8, 1, 0x7f, 0xff] {
let mut g = f.clone();
g[i] = v;
let _ = parse_cr3(&g).map(|c| merged_exif(&c));
}
}
// a sample offset past the end is not reported
let mut g = f.clone();
g.truncate(4100);
assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none()));
}
}
+37 -2
View File
@@ -7,10 +7,12 @@
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3).
//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks).
#![forbid(unsafe_code)]
mod container;
pub mod cr3;
mod datetime;
mod exif;
mod gpx;
@@ -24,7 +26,7 @@ pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use lightcraft_geom::{Orientation, Rect};
pub use tags::{TagRow, file_tag_rows, tag_rows};
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
pub use xmp_merge::{MergeRules, merge_xmp};
@@ -48,6 +50,31 @@ pub struct Flash {
pub raw: u16,
}
/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face,
/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write
/// regions yet (see `docs/xmp-interop.md`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on
/// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to.
pub rect: Rect,
pub kind: RegionKind,
/// `mwg-rs:Name` (e.g. the person's name).
pub name: Option<String>,
pub description: Option<String>,
}
/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RegionKind {
Face,
Pet,
Focus,
BarCode,
/// `mwg-rs:Type` was missing, empty, or a value we don't recognise.
Other(String),
}
/// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Metadata {
@@ -113,6 +140,8 @@ pub struct Metadata {
pub rating: Option<i8>,
/// Colour label name (e.g. "Red").
pub label: Option<String>,
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
pub regions: Vec<Region>,
}
macro_rules! fill {
@@ -173,6 +202,9 @@ impl Metadata {
if self.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if self.regions.is_empty() {
self.regions = other.regions.clone();
}
}
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
@@ -185,6 +217,9 @@ impl Metadata {
if !other.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if !other.regions.is_empty() {
self.regions = other.regions.clone();
}
}
/// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`.
+277 -1
View File
@@ -8,7 +8,8 @@
//! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and
//! LightCraft's full develop settings as an opaque JSON string in `lc:settings`.
use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number};
use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number};
use lightcraft_geom::{Point, Rect};
use quick_xml::escape::{escape, partial_escape};
use quick_xml::events::Event;
use std::collections::BTreeMap;
@@ -32,6 +33,11 @@ const NAMESPACES: &[(&str, &str)] = &[
("lc", LC_NS),
// Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md).
("crs", CRS_NS),
// Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region
// Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them.
("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"),
("stArea", "http://ns.adobe.com/xmp/sType/Area#"),
("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"),
];
/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only.
@@ -374,6 +380,72 @@ fn parse_gps_coord(s: &str) -> Option<f64> {
v.is_finite().then_some(v)
}
/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame.
/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region
/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest.
fn parse_regions(regions: &XmpValue) -> Vec<Region> {
// `AppliedToDimensions` documents the pixel size regions were authored against. It's only consulted
// for the rare region whose own Area is in pixel units instead of MWG's normalized default below.
let px_dims = regions.field("mwg-rs:AppliedToDimensions").and_then(|d| {
let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?;
let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?;
(w > 0.0 && h > 0.0).then_some((w, h))
});
let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() };
list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect()
}
fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option<Region> {
let area = item.field("mwg-rs:Area")?;
let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number);
// stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area).
let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?);
let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6);
if !finite_and_sane || w <= 0.0 || h <= 0.0 {
return None;
}
// `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and
// that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided
// down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than
// stored as a nonsense fraction.
let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel"));
let (cx, cy, w, h) = match (is_pixels, px_dims) {
(false, _) => (cx, cy, w, h),
(true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh),
(true, None) => return None,
};
// Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own
// frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for
// 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame.
// MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored
// twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md).
let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite());
let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let (cx, cy, w, h) = match rotation {
Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w),
Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w),
Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h),
_ => (cx, cy, w, h),
};
// Keep only the part of the box inside the photo: a region hanging off an edge (common for faces
// cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every
// stored rect is within 0..1 and non-empty.
let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT);
if rect.is_empty() {
return None;
}
let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") {
t if t.eq_ignore_ascii_case("face") => RegionKind::Face,
t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet,
t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus,
t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode,
t => RegionKind::Other(t.to_string()),
};
Some(Region { rect, kind, name, description })
}
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
let a = v.abs();
let d = a.trunc();
@@ -470,6 +542,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a });
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
}
m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default();
let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned();
Ok(XmpData { metadata: m, lc_settings, properties: props, values })
}
@@ -610,6 +683,9 @@ mod tests {
fn full() -> Metadata {
Metadata {
// write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the
// roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it.
regions: Vec::new(),
make: Some("Maker & Sons".into()),
model: Some("X <1>".into()),
serial_number: Some("SN1".into()),
@@ -764,4 +840,204 @@ mod tests {
assert_eq!(parse_gps_coord("é"), None);
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
}
/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how
/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one
/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas
/// (the default, and the only unit real files use) don't need it.
#[test]
fn mwg_regions_element_form() {
let x = 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:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource" stDim:w="4000" stDim:h="3000" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3" stArea:unit="normalized"/>
<mwg-rs:Name>Jane Doe</mwg-rs:Name>
<mwg-rs:Type>Face</mwg-rs:Type>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.1" stArea:y="0.1" stArea:w="0.05" stArea:h="0.05" stArea:unit="normalized"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.8" stArea:y="0.2" stArea:w="0.1" stArea:h="0.1" stArea:unit="normalized"/>
<mwg-rs:Type>Pet</mwg-rs:Type>
<mwg-rs:Name>Rex</mwg-rs:Name>
<mwg-rs:Description>Good boy</mwg-rs:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 3, "{regions:?}");
assert_eq!(regions[0].kind, RegionKind::Face);
assert_eq!(regions[0].name.as_deref(), Some("Jane Doe"));
assert_eq!(regions[0].description, None);
let r = regions[0].rect;
assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}");
assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}");
// No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`.
assert_eq!(regions[1].kind, RegionKind::Other(String::new()));
assert_eq!(regions[1].name, None);
assert_eq!(regions[2].kind, RegionKind::Pet);
assert_eq!(regions[2].name.as_deref(), Some("Rex"));
assert_eq!(regions[2].description.as_deref(), Some("Good boy"));
}
/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame
/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the
/// upright frame.
#[test]
fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() {
let x = 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:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="-1.57080" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="1.57080" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="0.00000" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="3.14159" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="-3.14159" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 5, "{regions:?}");
let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9);
assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect);
assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect);
assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect);
assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect);
assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect);
}
/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an
/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an
/// unrecognised `mwg-rs:Type` value kept verbatim.
#[test]
fn mwg_regions_attribute_form_and_unknown_type() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Type="BarCode">
<mwg-rs:Area stArea:x="0.3" stArea:y="0.3" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
<rdf:li>
<rdf:Description mwg-rs:Type="Sticker">
<mwg-rs:Area stArea:x="0.6" stArea:y="0.6" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 2, "{regions:?}");
assert_eq!(regions[0].kind, RegionKind::BarCode);
assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string()));
}
/// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of
/// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area
/// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction).
#[test]
fn mwg_regions_hostile_inputs_are_dropped_not_panicking() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><mwg-rs:Name>No Area</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="NaN" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1e300" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="-0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="100" stArea:y="100" stArea:w="50" stArea:h="50" stArea:unit="pixel"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Type>Face</mwg-rs:Type></rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}");
assert_eq!(regions[0].kind, RegionKind::Face);
}
/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a
/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view
/// would then draw or crop far outside the image.
#[test]
fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1e5" stArea:y="0.5" stArea:w="0.1" stArea:h="0.1"/><mwg-rs:Name>Far right</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="-3" stArea:y="-3" stArea:w="0.5" stArea:h="0.5"/><mwg-rs:Name>Far left</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1.2" stArea:y="0.5" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Name>Touching edge</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="1e5" stArea:h="1e5"/><mwg-rs:Name>Huge</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.95" stArea:y="0.05" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Name>Corner</mwg-rs:Name></rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect();
assert_eq!(names, ["Huge", "Corner"], "{regions:?}");
for r in &regions {
let q = r.rect;
assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}");
}
assert_eq!(regions[0].rect, Rect::UNIT);
let c = regions[1].rect;
assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}");
}
/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords).
#[test]
fn regions_merge_like_keywords() {
let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None };
let mut a = Metadata::default();
let b = Metadata { regions: vec![with_region.clone()], ..Default::default() };
a.fill_missing(&b);
assert_eq!(a.regions, vec![with_region.clone()]);
let mut a = Metadata::default();
a.overlay_user_fields(&b);
assert_eq!(a.regions, vec![with_region]);
}
}
+7 -1
View File
@@ -221,7 +221,9 @@ impl FinishParams {
FinishParams {
calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None },
shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 },
tone: if info.raw {
tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) {
ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks)
} else if info.raw {
ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks)
} else {
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
@@ -481,6 +483,10 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let yl = luminance_2020(c);
let o = tone.apply(yl);
let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] };
let k = tone.chroma_scale(o);
if k != 1.0 {
d = d.map(|v| o + (v - o) * k);
}
let mx = d[0].max(d[1]).max(d[2]);
if mx > 1.0 {
let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0);
+4 -1
View File
@@ -64,11 +64,14 @@ pub struct SourceInfo {
pub raw: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
pub relative_wb: bool,
pub camera_tone: Option<tone::CameraTone>,
}
impl Default for SourceInfo {
fn default() -> Self {
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, relative_wb: false, camera_tone: None }
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) {
use lightcraft_develop::WbMode;
match s.wb.mode {
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
m if info.raw => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
_ => (s.wb.temp, s.wb.tint),
}
}
+156 -1
View File
@@ -3,7 +3,7 @@
//! Shapes are defined in normalized oriented-image coordinates; sizes are in "long-edge units".
//! Components combine with Add (max), Subtract (a·(1−c)) and Intersect (a·c).
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape};
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask};
use lightcraft_geom::Point;
use lightcraft_raster::{Plane, Rgb32f};
@@ -153,6 +153,19 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
}
smooth_plane(&mut out, 0.01 * frame_px(frame, w));
}
MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => {
// Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to
// 2 % of the long edge, so previews and exports match)
let e = (*edge / 100.0).clamp(-1.0, 1.0) as f32;
let gain = if e < 0.0 { 1.0 - 7.0 * e } else { 1.0 };
sample_seg(seg.as_ref(), detail, gain, frame, w, h, &mut out);
if e > 0.0 {
smooth_plane(&mut out, e * 0.02 * frame_px(frame, w));
}
}
// not computed yet (no clicks, or a build without the model): nothing selected
MaskShape::Object { hint, .. } if hint.is_empty() => {}
MaskShape::Prompt { .. } => {}
MaskShape::Subject | MaskShape::Object { .. } | MaskShape::People { .. } => {
// Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12).
let m = frame.out_to_norm(w, h);
@@ -176,6 +189,61 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
out
}
/// A decoded [`SegMask`]: its logits and the normalized rectangle they cover.
struct SegGrid {
logits: Vec<f32>,
side: usize,
r: [f64; 4],
}
impl SegGrid {
fn new(seg: &SegMask) -> Option<SegGrid> {
Some(SegGrid { logits: seg.logits()?, side: seg.side as usize, r: seg.bounds()? })
}
fn contains(&self, n: Point) -> bool {
n.x >= self.r[0] && n.x <= self.r[2] && n.y >= self.r[1] && n.y <= self.r[3]
}
/// The bilinearly sampled logit at normalized image point `n` (cell centres at
/// `(i + 0.5) / side` of the rectangle).
fn logit(&self, n: Point) -> f32 {
let side = self.side;
let at = |x: usize, y: usize| self.logits.get(y.min(side - 1) * side + x.min(side - 1)).copied().unwrap_or(-16.0);
let u = (n.x - self.r[0]) / (self.r[2] - self.r[0]);
let v = (n.y - self.r[1]) / (self.r[3] - self.r[1]);
let (fx, fy) = ((u * side as f64 - 0.5) as f32, (v * side as f64 - 0.5) as f32);
let (fx, fy) = (fx.clamp(0.0, (side - 1) as f32), fy.clamp(0.0, (side - 1) as f32));
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx;
let bot = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx;
top * (1.0 - ty) + bot * ty
}
}
/// Stored model segmentations at each output pixel: the zoomed-in `detail` passes inside
/// their rectangles (the highest of them where they overlap), the whole-photo `seg`
/// elsewhere; the logit (times `gain`: > 1 is a harder edge) through a sigmoid. Damaged
/// data selects nothing.
fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, out: &mut Plane) {
let coarse = seg.and_then(SegGrid::new);
// (capped: a hostile document can hold many)
let patches: Vec<SegGrid> = detail.iter().take(lightcraft_develop::segmask::MAX_DETAIL).filter_map(SegGrid::new).collect();
if coarse.is_none() && patches.is_empty() {
return;
}
let m = frame.out_to_norm(w, h);
for_rows(&mut out.data, w, |y, row| {
for (x, v) in row.iter_mut().enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let fine = patches.iter().filter(|p| p.contains(n)).map(|p| p.logit(n)).reduce(f32::max);
let l = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0);
*v = 1.0 / (1.0 + (-l * gain).exp());
}
});
}
fn frame_px(frame: &Frame, w: usize) -> f32 {
frame.px_per_long(w) as f32
}
@@ -356,6 +424,93 @@ mod tests {
assert!((a.get(50, 25) - 0.5).abs() < 0.05);
}
#[test]
fn stored_segmentations_render_at_any_size_and_follow_the_crop() {
// the left half selected, on a 16² logit grid
let side = 16;
let logits: Vec<f32> = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect();
let seg = SegMask::from_logits(side, &logits);
for shape in [
MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
] {
for (w, h) in [(40, 20), (400, 200)] {
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}");
if w >= 400 {
let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count();
assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px");
}
}
}
// cropped to the right half: nothing left selected
let mut d = DevelopSettings::default();
d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0);
let f = Frame::new(40, 20, &d, true);
let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0);
assert!(a.data.iter().all(|v| *v < 0.05));
}
#[test]
fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() {
// coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75)
let coarse = SegMask::from_logits(4, &[-12.0; 16]);
let side = 16;
let l: Vec<f32> = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect();
let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]);
let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 };
let (w, h) = (200, 100);
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask");
assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half");
assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half");
}
#[test]
fn a_hostile_number_of_detail_patches_is_capped() {
// built in memory (a deserialized document is capped already): only the first
// MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB
let side = 1024;
let l: Vec<f32> = vec![12.0; side * side];
let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]);
let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 };
let (w, h) = (64, 32);
let t = std::time::Instant::now();
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01);
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
#[test]
fn the_edge_setting_hardens_or_feathers_the_transition() {
let side = 16;
let l: Vec<f32> = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp
let seg = SegMask::from_logits(side, &l);
let (w, h) = (400, 40);
let soft_px = |edge: f64| {
let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge };
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}");
(0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count()
};
let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0));
assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}");
}
#[test]
fn ai_shapes_without_a_segmentation_select_nothing() {
let f = frame(30, 30);
let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30));
for shape in [
MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 },
MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 },
MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 },
] {
assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0).data.iter().all(|v| *v == 0.0));
}
}
#[test]
fn radial_inside_and_invert() {
let f = frame(100, 100);
+162 -2
View File
@@ -13,13 +13,99 @@ pub const GREY: f32 = 0.18;
pub const LUT_MIN_EV: f32 = -14.0;
pub const LUT_MAX_EV: f32 = 10.0;
pub const LUT_N: usize = 4096;
/// Nodes of a camera chroma curve, evenly spaced over display luminance 0..=1.
pub const CHROMA_N: usize = 8;
const NO_CHROMA: [f32; CHROMA_N] = [1.0; CHROMA_N];
/// A file-local camera look, fitted independently of the scene-linear colour transform.
/// Knots are scene/display-linear luminance pairs. Keeping this in the finish stage preserves
/// RAW exposure and highlight headroom; it is never baked into the decoded sensor pixels.
/// `chroma` scales colourfulness by display luminance after the curve (a camera's per-channel
/// curve saturates shadows and bleaches highlights toward white, which a luminance curve can't).
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
pub struct CameraTone {
knots: [[f32; 2]; 32],
chroma: [f32; CHROMA_N],
}
impl<'de> serde::Deserialize<'de> for CameraTone {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Wire {
knots: [[f32; 2]; 32],
// absent in smart previews written before the chroma curve existed
#[serde(default)]
chroma: Option<[f32; CHROMA_N]>,
}
let w = Wire::deserialize(d)?;
let tone = Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))?;
match w.chroma {
Some(c) => tone.with_chroma(c).ok_or_else(|| serde::de::Error::custom("invalid camera chroma curve")),
None => Ok(tone),
}
}
}
impl CameraTone {
pub fn new(knots: [[f32; 2]; 32]) -> Option<Self> {
let mut previous = [0.0, 0.0];
for p in knots {
if !p.iter().all(|v| v.is_finite()) || p[0] <= previous[0] || p[1] < previous[1] || p[1] >= 1.0 {
return None;
}
previous = p;
}
Some(Self { knots, chroma: NO_CHROMA })
}
/// The curve with chroma scales at display luminance 0, 1/7 … 1 (each finite, 0..=4).
pub fn with_chroma(self, chroma: [f32; CHROMA_N]) -> Option<Self> {
chroma.iter().all(|k| k.is_finite() && (0.0..=4.0).contains(k)).then_some(Self { chroma, ..self })
}
pub fn chroma(&self) -> &[f32; CHROMA_N] {
&self.chroma
}
pub fn apply(&self, y: f32) -> f32 {
if !y.is_finite() || y <= 0.0 {
return 0.0;
}
let mut previous = [0.0, 0.0];
for p in self.knots {
if y <= p[0] {
let t = (y - previous[0]) / (p[0] - previous[0]);
return previous[1] + t * (p[1] - previous[1]);
}
previous = p;
}
let a = self.knots[30];
let b = self.knots[31];
// Extend beyond observed (unclipped) highlights with a continuous, bounded shoulder.
let slope = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0);
1.0 - (1.0 - b[1]) * (-(y - b[0]) * slope / (1.0 - b[1]).max(0.01)).exp()
}
}
#[derive(Clone, Debug)]
pub struct ToneMap {
lut: Vec<f32>,
chroma: [f32; CHROMA_N],
}
impl ToneMap {
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
let adjustment = Self::display(contrast, whites, blacks);
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = curve.apply(GREY * 2f32.powf(ev));
if neutral { y } else { adjustment.apply(y) }
})
.collect();
ToneMap { lut, chroma: curve.chroma }
}
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
let c = (contrast / 100.0) as f32;
@@ -50,7 +136,7 @@ impl ToneMap {
o.clamp(0.0, 1.0)
})
.collect();
ToneMap { lut }
ToneMap { lut, chroma: NO_CHROMA }
}
/// Tone map for display-referred sources: identity at neutral settings.
@@ -84,7 +170,7 @@ impl ToneMap {
o.clamp(0.0, 1.0)
})
.collect();
ToneMap { lut }
ToneMap { lut, chroma: NO_CHROMA }
}
/// The table (`LUT_N` entries, see [`ToneMap::apply`]).
@@ -92,6 +178,24 @@ impl ToneMap {
&self.lut
}
/// The chroma curve (`CHROMA_N` entries, see [`ToneMap::chroma_scale`]).
pub fn chroma_lut(&self) -> &[f32] {
&self.chroma
}
/// Chroma scale at display luminance `o` (exactly 1 everywhere unless a camera look sets it).
#[inline]
pub fn chroma_scale(&self, o: f32) -> f32 {
if !o.is_finite() {
return 1.0;
}
let f = o.clamp(0.0, 1.0) * (CHROMA_N - 1) as f32;
let i = (f as usize).min(CHROMA_N - 2);
let t = f - i as f32;
let (a, b) = (self.chroma.get(i).copied().unwrap_or(1.0), self.chroma.get(i + 1).copied().unwrap_or(1.0));
if a == b { a } else { a + (b - a) * t }
}
/// Scene luminance → display-linear luminance.
#[inline]
pub fn apply(&self, y: f32) -> f32 {
@@ -116,6 +220,62 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
mod tests {
use super::*;
fn knots() -> [[f32; 2]; 32] {
std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
})
}
#[test]
fn chroma_curve_is_identity_unless_set_and_survives_serde() {
let plain = CameraTone::new(knots()).unwrap();
let map = ToneMap::camera(&plain, 0.0, 0.0, 0.0);
assert!([0.0, 0.3, 0.77, 1.0, 2.0].iter().all(|o| map.chroma_scale(*o) == 1.0));
assert!([0.0, 0.5, 1.0].iter().all(|o| ToneMap::new(0.0, 0.0, 0.0).chroma_scale(*o) == 1.0));
// smart previews written before the chroma curve existed still load (identity)
let old = serde_json::json!({ "knots": knots() });
assert_eq!(serde_json::from_value::<CameraTone>(old).unwrap(), plain);
let tone = plain.with_chroma([1.4, 1.3, 1.1, 1.0, 0.7, 0.4, 0.25, 0.2]).unwrap();
let back: CameraTone = serde_json::from_value(serde_json::to_value(tone).unwrap()).unwrap();
assert_eq!(back, tone);
let map = ToneMap::camera(&tone, 0.0, 0.0, 0.0);
assert!((map.chroma_scale(0.0) - 1.4).abs() < 1e-6 && (map.chroma_scale(1.0) - 0.2).abs() < 1e-6);
assert!((map.chroma_scale(0.5 / 7.0) - 1.35).abs() < 1e-5, "interpolates between nodes");
assert_eq!(map.chroma_scale(f32::NAN), 1.0);
// hostile values are rejected, also when deserialized
assert!(plain.with_chroma([f32::NAN; CHROMA_N]).is_none());
assert!(plain.with_chroma([-1.0; CHROMA_N]).is_none());
let bad = serde_json::json!({ "knots": knots(), "chroma": [9.0, 1, 1, 1, 1, 1, 1, 1] });
assert!(serde_json::from_value::<CameraTone>(bad).is_err());
}
#[test]
fn camera_curve_preserves_black_and_extends_headroom() {
let knots = std::array::from_fn(|i| {
let x = 0.005 * 1.15f32.powi(i as i32);
[x, 1.0 - (-3.0 * x).exp()]
});
let curve = CameraTone::new(knots).unwrap();
let base = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
assert_eq!(base.apply(0.0), 0.0);
assert!(base.apply(0.1) < base.apply(0.2));
let mut previous = 0.0;
for i in 0..2000 {
let y = 1e-6 * 1.01f32.powi(i);
let v = base.apply(y);
assert!((0.0..=1.0).contains(&v));
assert!(v >= previous - 1e-6);
previous = v;
}
assert!((base.apply(0.1) - curve.apply(0.1)).abs() < 0.001);
assert!(ToneMap::camera(&curve, 50.0, 0.0, 0.0).apply(0.3) > base.apply(0.3));
let mut invalid = knots;
invalid[1][0] = invalid[0][0];
assert!(CameraTone::new(invalid).is_none());
assert!(serde_json::from_value::<CameraTone>(serde_json::json!({"knots": invalid})).is_err());
}
#[test]
fn monotone_and_bounded() {
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
+22
View File
@@ -1,6 +1,7 @@
//! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]),
//! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists.
use crate::profile::{HsvTable, ProfileLook, ToneCurve};
use crate::tiffraw::{Packing, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes};
use lightcraft_color::Xy;
@@ -45,6 +46,27 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData {
as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])),
baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0)
+ get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0),
profile: profile_look(ifd0, raw),
}
}
/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed
/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0.
pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook {
let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 };
let table = |dims: u16, data: u16, enc: u16| {
let ifd = pick(dims);
let dims = ifd.u64s(dims)?;
let data = ifd.f64s(data)?;
HsvTable::from_tags(&dims, &data, ifd.u64s(enc).and_then(|v| v.first().copied()).unwrap_or(0))
};
ProfileLook {
hue_sat_map: [
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_1, t::PROFILE_HUE_SAT_MAP_ENCODING),
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_2, t::PROFILE_HUE_SAT_MAP_ENCODING),
],
look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING),
tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)),
}
}
+32
View File
@@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set(t::AS_SHOT_WHITE_XY, rat_vec(&[xy.x, xy.y]));
}
ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)]));
// the source's own profile look travels with its data (conversions, smart previews, merges)
let p = &c.profile;
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
if table.srgb_value {
ifd.set(enc, Value::Long(vec![1]));
}
};
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
match &p.hue_sat_map {
[Some(a), b] => {
table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a));
if let Some(b) =
b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions))
{
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b));
}
}
[None, Some(b)] => {
table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b));
}
[None, None] => {}
}
if let Some(l) = &p.look_table {
table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l));
}
if let Some(curve) = &p.tone_curve {
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
}
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
if !list.is_empty() {
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
+7 -1
View File
@@ -8,7 +8,8 @@
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails).
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
//! and produces camera → linear Rec.2020 D65 matrices.
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
//! look tables and tone curve.
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
@@ -29,6 +30,7 @@ pub mod highlight;
pub mod ljpeg;
pub mod opcodes;
mod preview;
pub mod profile;
mod tiffraw;
mod unpack;
mod vendor;
@@ -393,6 +395,10 @@ pub struct ColorData {
pub as_shot_white_xy: Option<Xy>,
/// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`).
pub baseline_exposure: f64,
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
/// `ProfileToneCurve`), applied by [`color`]'s users at render time.
#[serde(default)]
pub profile: profile::ProfileLook,
}
/// A decoded raw image.
+39 -1
View File
@@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec())
}
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
let full = lightcraft_meta::cr3::parse_cr3(bytes)
.and_then(|c| c.tracks.iter().find(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Jpeg).and_then(|t| t.data));
if let Some(j) = full.and_then(|(at, len)| bytes.get(at..at.checked_add(len)?)).filter(|j| is_dct_jpeg(j)) {
return Some(j);
}
cr3_preview_boxes(bytes)
}
/// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0
/// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller
/// `THMB` box has the same shape. Returns the larger valid one.
fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
let mut best: Option<&[u8]> = None;
for tag in [b"PRVW", b"THMB"] {
let mut from = 0;
@@ -180,4 +190,32 @@ mod tests {
let _ = embedded_preview(&cr3[..n]);
}
}
/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box.
#[test]
fn cr3_prefers_the_full_size_jpeg_track() {
let bx = |kind: &[u8; 4], body: &[u8]| -> Vec<u8> { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() };
let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat());
let (small, big) = (fake_jpeg(300), fake_jpeg(3000));
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec();
prvw.extend_from_slice(&(small.len() as u32).to_be_bytes());
prvw.extend_from_slice(&small);
file.extend(bx(b"PRVW", &prvw));
// CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at`
let mut craw = vec![0u8; 82];
craw.extend(bx(b"JPEG", &[0; 4]));
let at = 2048u64;
let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat());
let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat());
let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat());
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat()))));
file.extend(bx(b"moov", &trak));
file.resize(at as usize, 0);
file.extend_from_slice(&big);
assert_eq!(embedded_preview(&file).unwrap(), big);
// a track pointing past the end falls back to PRVW
file.truncate(at as usize + 100);
assert_eq!(embedded_preview(&file).unwrap(), small);
}
}
+387
View File
@@ -0,0 +1,387 @@
//! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7,
//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags).
//!
//! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift
//! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor).
//! Hue samples are spread evenly around the circle with wrap-around, saturation and value samples
//! evenly over 0..=1; a dimension with one division is constant. Lookups are tri-linear.
//! - Tables are applied in HSV of linear ProPhoto RGB (D50). With encoding 1 ("sRGB") the value
//! axis is indexed through the sRGB transfer curve (3D tables only).
//! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
//!
//! The data is read from the user's own file at run time; nothing here ships profile data.
//! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever
//! lowers them, so a lookup can't clip what the scene-referred pipeline still needs.
use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear};
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford};
use serde::{Deserialize, Serialize};
/// Upper bound on table entries (Adobe profiles use ≤ 90 × 30 × 16; guards header-driven sizes).
const MAX_ENTRIES: usize = 1 << 20;
/// A DNG hue/saturation/value mapping table (`ProfileHueSatMapData*` / `ProfileLookTableData`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HsvTable {
pub hue_divisions: usize,
pub sat_divisions: usize,
pub val_divisions: usize,
/// `(hue shift °, saturation scale, value scale)`, value-major, hue-middle, saturation-minor.
pub data: Vec<[f32; 3]>,
/// The value axis is indexed through the sRGB encoding curve (`…Encoding` = 1).
pub srgb_value: bool,
}
impl HsvTable {
/// A table from its `…Dims` (hue, saturation, value divisions), data floats and encoding.
/// `None` when the dimensions are out of range, the data length doesn't match or a value is
/// not finite.
pub fn from_tags(dims: &[u64], data: &[f64], encoding: u64) -> Option<HsvTable> {
let [h, s, v] = <[u64; 3]>::try_from(dims).ok()?;
let (h, s, v) = (usize::try_from(h).ok()?, usize::try_from(s).ok()?, usize::try_from(v).ok()?);
if h < 1 || s < 2 || v < 1 || h > 4096 || s > 4096 || v > 4096 {
return None;
}
let n = h.checked_mul(s)?.checked_mul(v)?;
if n > MAX_ENTRIES || data.len() != n * 3 || data.iter().any(|x| !x.is_finite()) {
return None;
}
let data = data.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, (c[1] as f32).max(0.0), (c[2] as f32).max(0.0)]).collect();
Some(HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value: encoding == 1 && v > 1 })
}
/// The entry-wise blend `g · a + (1 − g) · b` of two tables of the same shape (`None` otherwise).
pub fn blend(a: &HsvTable, b: &HsvTable, g: f64) -> Option<HsvTable> {
if (a.hue_divisions, a.sat_divisions, a.val_divisions) != (b.hue_divisions, b.sat_divisions, b.val_divisions) {
return None;
}
let g = g.clamp(0.0, 1.0) as f32;
let data = a.data.iter().zip(&b.data).map(|(x, y)| std::array::from_fn(|i| g * x[i] + (1.0 - g) * y[i])).collect();
Some(HsvTable { data, ..a.clone() })
}
fn entry(&self, v: usize, h: usize, s: usize) -> [f32; 3] {
self.data.get((v * self.hue_divisions + h) * self.sat_divisions + s).copied().unwrap_or([0.0, 1.0, 1.0])
}
/// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates).
pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] {
let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32);
let h0 = (hf as usize).min(self.hue_divisions - 1);
let h1 = (h0 + 1) % self.hue_divisions;
let th = (hf - h0 as f32).clamp(0.0, 1.0);
let axis = |x: f32, n: usize| {
let f = x.clamp(0.0, 1.0) * (n - 1) as f32;
let i0 = (f as usize).min(n.saturating_sub(2));
let i1 = (i0 + 1).min(n - 1);
(i0, i1, (f - i0 as f32).clamp(0.0, 1.0))
};
let (s0, s1, ts) = axis(s, self.sat_divisions);
let (v0, v1, tv) = if self.val_divisions > 1 { axis(v, self.val_divisions) } else { (0, 0, 0.0) };
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) };
let plane = |vi: usize| {
let lo = lerp(self.entry(vi, h0, s0), self.entry(vi, h0, s1), ts);
let hi = lerp(self.entry(vi, h1, s0), self.entry(vi, h1, s1), ts);
lerp(lo, hi, th)
};
if v0 == v1 { plane(v0) } else { lerp(plane(v0), plane(v1), tv) }
}
/// Apply the table to one linear ProPhoto RGB colour.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let max = rgb[0].max(rgb[1]).max(rgb[2]);
if max.is_nan() || max <= 0.0 || !max.is_finite() {
return rgb;
}
let min = rgb[0].min(rgb[1]).min(rgb[2]);
let d = max - min;
let hue = hue_of(rgb, max, d);
let sat = d / max;
let v_index = if self.srgb_value { linear_to_srgb(max.min(1.0)) } else { max };
let [shift, ss, vs] = self.lookup(hue, sat, v_index);
// saturation and value: scaled and clipped to 1.0 (DNG chapter 6), except that inputs
// already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered.
let s2 = limited(sat, ss);
let v2 = if self.srgb_value && max <= 1.0 {
srgb_to_linear((linear_to_srgb(max) * vs).min(1.0))
} else if self.srgb_value {
max * srgb_to_linear(vs.min(1.0)).min(1.0)
} else {
limited(max, vs)
};
hsv_to_rgb(hue + shift, s2, v2)
}
}
/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1).
fn limited(x: f32, k: f32) -> f32 {
if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) }
}
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
if d <= 0.0 {
return 0.0;
}
let h = if max == c[0] {
((c[1] - c[2]) / d).rem_euclid(6.0)
} else if max == c[1] {
(c[2] - c[0]) / d + 2.0
} else {
(c[0] - c[1]) / d + 4.0
};
h * 60.0
}
/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of
/// [`hue_of`] for out-of-gamut inputs).
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let hp = h.rem_euclid(360.0) / 60.0;
let sector = (hp as u32).min(5);
let f = hp - sector as f32;
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
match sector {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
/// A DNG tone curve (`ProfileToneCurve`): linear-gamma (input, output) pairs, increasing input.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToneCurve {
pub points: Vec<[f32; 2]>,
}
impl ToneCurve {
/// From the tag's flat float pairs. `None` unless there are 2..=65536 finite pairs within
/// 0..=1 with strictly increasing inputs.
pub fn from_tag(v: &[f64]) -> Option<ToneCurve> {
if !v.len().is_multiple_of(2) || v.len() < 4 || v.len() > 131_072 {
return None;
}
let points: Vec<[f32; 2]> = v.as_chunks::<2>().0.iter().map(|p| [p[0] as f32, p[1] as f32]).collect();
let ok = points.iter().all(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))) && points.windows(2).all(|w| w[1][0] > w[0][0]);
ok.then_some(ToneCurve { points })
}
/// The curve at `x` (0..=1): monotone cubic (Fritsch–Carlson) through the samples.
pub fn eval(&self, x: f32) -> f32 {
let p = &self.points;
let n = p.len();
let x = x.clamp(p[0][0], p[n - 1][0]);
let i = p.partition_point(|q| q[0] <= x).clamp(1, n - 1) - 1;
let slope = |k: usize| (p[k + 1][1] - p[k][1]) / (p[k + 1][0] - p[k][0]);
let tangent = |k: usize| -> f32 {
if k == 0 {
return slope(0);
}
if k == n - 1 {
return slope(n - 2);
}
let (a, b) = (slope(k - 1), slope(k));
if a * b <= 0.0 { 0.0 } else { 2.0 / (1.0 / a + 1.0 / b) }
};
let (x0, x1) = (p[i][0], p[i + 1][0]);
let h = x1 - x0;
let t = (x - x0) / h;
let (t2, t3) = (t * t, t * t * t);
let y = (2.0 * t3 - 3.0 * t2 + 1.0) * p[i][1]
+ (t3 - 2.0 * t2 + t) * h * tangent(i)
+ (-2.0 * t3 + 3.0 * t2) * p[i + 1][1]
+ (t3 - t2) * h * tangent(i + 1);
y.clamp(0.0, 1.0)
}
}
/// The DNG profile look tags of a file (all optional).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProfileLook {
/// `ProfileHueSatMapData1/2` (same shape, `ProfileHueSatMapDims`).
pub hue_sat_map: [Option<HsvTable>; 2],
/// `ProfileLookTableData`.
pub look_table: Option<HsvTable>,
/// `ProfileToneCurve`.
pub tone_curve: Option<ToneCurve>,
}
impl ProfileLook {
pub fn is_empty(&self) -> bool {
self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none()
}
/// The hue/saturation map for calibration-1 weight `g` (one table: used for any white).
pub fn hue_sat_for(&self, g: f64) -> Option<HsvTable> {
match &self.hue_sat_map {
[Some(a), Some(b)] => HsvTable::blend(a, b, g).or_else(|| Some(a.clone())),
[Some(a), None] | [None, Some(a)] => Some(a.clone()),
[None, None] => None,
}
}
}
/// The tables resolved for one white balance, applied to linear Rec.2020 D65 pixels.
#[derive(Clone, Debug)]
pub struct ProfileTables {
hue_sat: Option<HsvTable>,
look: Option<HsvTable>,
to_prophoto: [[f32; 3]; 3],
from_prophoto: [[f32; 3]; 3],
}
impl ProfileTables {
/// `None` when the profile has no hue/saturation map and no look table.
pub fn new(look: &ProfileLook, illuminant_weight: f64) -> Option<ProfileTables> {
let hue_sat = look.hue_sat_for(illuminant_weight);
if hue_sat.is_none() && look.look_table.is_none() {
return None;
}
let to: Mat3 = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz());
let from = to.inverse()?;
Some(ProfileTables { hue_sat, look: look.look_table.clone(), to_prophoto: to.to_f32(), from_prophoto: from.to_f32() })
}
/// Hue/saturation map, then `gain` (exposure compensation), then the look table:
/// linear Rec.2020 D65 in, linear Rec.2020 D65 out.
#[inline]
pub fn apply(&self, rgb: [f32; 3], gain: f32) -> [f32; 3] {
let m = &self.to_prophoto;
let mut p: [f32; 3] = std::array::from_fn(|i| m[i][0] * rgb[0] + m[i][1] * rgb[1] + m[i][2] * rgb[2]);
if let Some(t) = &self.hue_sat {
p = t.apply(p);
}
p = p.map(|v| v * gain);
if let Some(t) = &self.look {
p = t.apply(p);
}
let m = &self.from_prophoto;
std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity(h: usize, s: usize, v: usize) -> HsvTable {
HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false }
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn identity_table_is_identity() {
let t = identity(6, 3, 1);
for c in [[0.2, 0.5, 0.1], [0.9, 0.9, 0.9], [0.0, 0.0, 0.4], [3.0, 1.0, 0.5], [0.3, -0.05, 0.2]] {
assert!(close(t.apply(c), c, 1e-5), "{c:?} → {:?}", t.apply(c));
}
assert_eq!(t.apply([0.0; 3]), [0.0; 3]);
assert_eq!(t.apply([-1.0, -2.0, -0.5]), [-1.0, -2.0, -0.5]);
}
#[test]
fn parses_tags_and_rejects_bad_shapes() {
let data: Vec<f64> = (0..4).flat_map(|_| [0.0, 1.0, 1.0]).collect();
assert!(HsvTable::from_tags(&[2, 2, 1], &data, 0).is_some());
assert!(HsvTable::from_tags(&[2, 2], &data, 0).is_none());
assert!(HsvTable::from_tags(&[2, 1, 2], &data, 0).is_none(), "saturation needs ≥ 2 divisions");
assert!(HsvTable::from_tags(&[2, 2, 2], &data, 0).is_none(), "length mismatch");
assert!(HsvTable::from_tags(&[u64::MAX, 2, 2], &data, 0).is_none());
let mut nan = data.clone();
nan[1] = f64::NAN;
assert!(HsvTable::from_tags(&[2, 2, 1], &nan, 0).is_none());
// the sRGB encoding only applies to 3D tables
assert!(!HsvTable::from_tags(&[2, 2, 1], &data, 1).unwrap().srgb_value);
}
#[test]
fn saturation_scale_follows_the_table_layout() {
// 4 hues (0°, 90°, 180°, 270°), 2 saturations; saturation-minor: entry (h, s) at h·2 + s.
let mut t = identity(4, 2, 1);
t.data[1] = [0.0, 1.5, 1.0]; // hue 0°, saturation 1 → ×1.5
let red = [0.5, 0.4, 0.4]; // hue 0°, saturation 0.2
let out = t.apply(red);
// half-way along the saturation axis is ×1.1 (0.2 → 0.22 sat), value kept
let sat = (out[0] - out[1].min(out[2])) / out[0];
assert!((sat - 0.2 * 1.1).abs() < 1e-4, "{out:?}");
assert!((out[0] - 0.5).abs() < 1e-6);
// other hues are untouched (hue 180°)
let cyan = [0.3, 0.4, 0.4];
assert!(close(t.apply(cyan), cyan, 1e-6));
// saturation clips to 1.0
assert!(t.apply([1.0, 0.1, 0.1]).iter().all(|v| *v >= -1e-6));
}
#[test]
fn hue_wraps_and_value_scales() {
// 2 hues: entries at 0° and 180°, hue shift +20° at 0°, value ×0.5 at 180°.
let mut t = identity(2, 2, 1);
t.data = vec![[20.0, 1.0, 1.0], [20.0, 1.0, 1.0], [0.0, 1.0, 0.5], [0.0, 1.0, 0.5]];
// 350° sits between the last sample (180°) and the first (0° = 360°), 10/180 from 360
assert!((t.lookup(350.0, 0.5, 0.5)[0] - 20.0 * (170.0 / 180.0)).abs() < 1e-3);
assert!((t.lookup(180.0, 0.5, 0.5)[2] - 0.5).abs() < 1e-6);
// headroom above 1 only goes down
let bright = [2.0, 2.4, 2.4];
let out = t.apply(bright);
assert!((out[1] - 1.2).abs() < 1e-5, "{out:?}");
let mut up = identity(1, 2, 1);
up.data = vec![[0.0, 1.0, 2.0]; 2];
assert!((up.apply([0.4, 0.4, 0.4])[0] - 0.8).abs() < 1e-6);
assert!((up.apply([0.8, 0.8, 0.8])[0] - 1.0).abs() < 1e-6, "clipped to 1");
assert!((up.apply([3.0, 3.0, 3.0])[0] - 3.0).abs() < 1e-6, "headroom kept");
}
#[test]
fn value_axis_and_srgb_encoding() {
// 1 hue, 2 sats, 2 values: saturation ×2 at value 1 only
let mut t = identity(1, 2, 2);
t.data[3] = [0.0, 2.0, 1.0];
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
let dark = [0.25, 0.2, 0.2]; // V 0.25, S 0.2
// linear: 1/4 of the way → S scale at s=0.2: lerp(1, lerp(1,2,0.2)=1.2, 0.25) = 1.05
assert!((sat(t.apply(dark)) - 0.2 * 1.05).abs() < 1e-4);
t.srgb_value = true;
let k = linear_to_srgb(0.25); // ≈ 0.537
let want = 0.2 * (1.0 + 0.2 * k);
assert!((sat(t.apply(dark)) - want).abs() < 1e-4);
}
#[test]
fn blend_and_tables_round_trip_through_prophoto() {
let a = identity(2, 2, 1);
let mut b = identity(2, 2, 1);
b.data[1] = [10.0, 2.0, 1.0];
let m = HsvTable::blend(&a, &b, 0.25).unwrap();
assert_eq!(m.data[1], [7.5, 1.75, 1.0]);
assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none());
let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None };
let t = ProfileTables::new(&look, 0.5).unwrap();
let c = [0.3, 0.2, 0.1];
assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0));
assert!(ProfileTables::new(&ProfileLook::default(), 0.5).is_none());
}
#[test]
fn tone_curve_interpolates_monotonically() {
assert!(ToneCurve::from_tag(&[0.0, 0.0]).is_none());
assert!(ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.6, 0.4, 0.7, 1.0, 1.0]).is_none(), "inputs must increase");
assert!(ToneCurve::from_tag(&[0.0, 0.0, 1.0, 1.5]).is_none());
let c = ToneCurve::from_tag(&[0.0, 0.0, 0.25, 0.4, 0.5, 0.7, 1.0, 1.0]).unwrap();
assert_eq!(c.eval(0.0), 0.0);
assert!((c.eval(0.25) - 0.4).abs() < 1e-6);
assert!((c.eval(1.0) - 1.0).abs() < 1e-6);
let mut last = 0.0;
for i in 0..=100 {
let y = c.eval(i as f32 / 100.0);
assert!(y >= last - 1e-6, "monotone at {i}");
last = y;
}
assert!(c.eval(0.1) > 0.1 && c.eval(0.1) < 0.4);
}
}
+282 -9
View File
@@ -14,6 +14,12 @@
//! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14
//! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits.
//!
//! White balance: the raw IFD's `0x7313` levels when present (bodies from about 2017 on); otherwise the
//! `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]). Without
//! either, an ARW opened with unit multipliers, i.e. a strong green cast (issue #148). Black level: the raw IFD's
//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]; 800 rather than the
//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit).
//!
//! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose
//! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go
//! through the generic TIFF path.
@@ -23,7 +29,7 @@ use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, Ra
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
use lightcraft_tiff::tags::{self as t, photometric};
use lightcraft_tiff::{Ifd, Tiff};
use lightcraft_tiff::{Ifd, Tiff, makernote};
use rayon::prelude::*;
const TONE_CURVE: u16 = 0x7010;
@@ -31,6 +37,137 @@ const BLACK_LEVEL: u16 = 0x7310;
const WB_RGGB: u16 = 0x7313;
const CROP_TOP_LEFT: u16 = 0x74c7;
const CROP_SIZE: u16 = 0x74c8;
/// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width).
const MN_TAG2010: u16 = 0x2010;
const MN_FULL_IMAGE_SIZE: u16 = 0xb02b;
/// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of
/// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was
/// consulted). The substitution, checked black-box on CC0 raw.pixls.us samples: every byte `p < 249` is stored as
/// `p³ mod 249` (a bijection on 0..249, as 3 is coprime to φ(249) = 164) and bytes 249–255 are stored unchanged.
/// Verified on 15 files from 13 bodies covering seven `Tag2010` layouts: the deciphered `SonyISO` field encodes
/// the Exif ISO (`100 · 2^(16 − v/256)`), the green level is 255–256 throughout, and on the ILCE-7M3 the deciphered
/// `WB_RGBLevels` equal the plain raw-IFD `0x7313` levels.
const DECIPHER: [u8; 256] = {
let mut inv = [0u8; 256];
let mut p = 0usize;
while p < 256 {
let c = if p < 249 { p * p % 249 * p % 249 } else { p };
inv[c] = p as u8;
p += 1;
}
inv
};
/// `Tag2010` layouts (ExifTool "Sony Tag2010a" … "Tag2010i" tables): the models each is documented for and the
/// byte offset of `WB_RGBLevels` (three `u16`, the as-shot white balance as R, G, B gains; Tag2010g and h share
/// the offset). Newer bodies (Tag2010i) also write the levels in plain form in the raw IFD (`0x7313`), which takes
/// precedence.
const TAG2010_WB: &[(&[&str], usize)] = &[
(&["NEX-5N"], 4476),
(&["SLT-A65", "SLT-A77", "NEX-7", "NEX-VG20E"], 4480),
(&["SLT-A37", "SLT-A57", "NEX-F3"], 4444),
(&["DSC-HX10V", "DSC-HX20V", "DSC-HX200V", "DSC-TX66", "DSC-TX200V", "DSC-TX300V", "DSC-WX50", "DSC-WX100", "DSC-WX150"], 4568),
(
&[
"SLT-A58",
"SLT-A99",
"ILCE-3000",
"ILCE-3500",
"NEX-3N",
"NEX-5R",
"NEX-5T",
"NEX-6",
"NEX-VG30E",
"NEX-VG900",
"DSC-RX100",
"DSC-RX1",
"DSC-RX1R",
"DSC-HX300",
"DSC-HX50V",
"DSC-TX30",
"DSC-WX60",
"DSC-WX200",
"DSC-WX300",
],
4532,
),
(&["DSC-RX100M2", "DSC-QX10", "DSC-QX100"], 4204),
(
&[
"DSC-HX60V",
"DSC-HX350",
"DSC-HX400V",
"DSC-QX30",
"DSC-RX10",
"DSC-RX100M3",
"DSC-WX220",
"DSC-WX350",
"ILCE-7",
"ILCE-7R",
"ILCE-7S",
"ILCE-7M2",
"ILCE-5000",
"ILCE-5100",
"ILCE-6000",
"ILCE-QX1",
"ILCA-68",
"ILCA-77M2",
"DSC-HX80",
"DSC-HX90V",
"DSC-RX0",
"DSC-RX1RM2",
"DSC-RX10M2",
"DSC-RX10M3",
"DSC-RX100M4",
"DSC-RX100M5",
"DSC-WX500",
"ILCE-6300",
"ILCE-6500",
"ILCE-7RM2",
"ILCE-7SM2",
"ILCA-99M2",
],
612,
),
(
&[
"ILCE-6100",
"ILCE-6400",
"ILCE-6600",
"ILCE-7C",
"ILCE-7M3",
"ILCE-7RM3",
"ILCE-7RM4",
"ILCE-9",
"ILCE-9M2",
"DSC-RX0M2",
"DSC-RX10M4",
"DSC-RX100M6",
"DSC-RX100M5A",
"DSC-RX100M7",
"DSC-HX99",
],
594,
),
];
/// As-shot white balance from the maker note's enciphered `Tag2010` block (bodies that do not write `0x7313` in
/// the raw IFD: most ARWs before 2017). `model` is the Exif model; Sony appends a regional "V" to some names
/// (SLT-A77V), which the documented lists omit.
fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> {
let model = model.trim();
let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?;
let bytes: Vec<u8> = block.get(offset..offset + 6)?.iter().map(|&b| DECIPHER[b as usize]).collect();
let level = |i: usize| order.read_u16(&bytes, 2 * i).map(f32::from);
let (r, g, b) = (level(0)?, level(1)?, level(2)?);
// levels are fixed-point gains (green 255–256 on every sample seen); reject implausible values from an unknown layout
if !(16.0..=16384.0).contains(&g) {
return None;
}
let (r, b) = (r / g, b / g);
((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b])
}
/// The inverse tone curve: 11-bit code → 14-bit sensor value.
pub(crate) fn code_curve(thresholds: &[u64]) -> Vec<u16> {
@@ -163,6 +300,73 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
Ok(out)
}
/// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`).
const SR2_SUBIFD_OFFSET: u16 = 0x7200;
const SR2_SUBIFD_LENGTH: u16 = 0x7201;
const SR2_SUBIFD_KEY: u16 = 0x7221;
/// Black level inside the encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD), recovered by
/// black-box known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher was used.
///
/// Every Sony ARW we examined (16 files, 15 bodies, 2012–2021) stores the same key (`11 22 33 44`), so its
/// keystream is the same in every file. The DSC-RX100M6 and ILCE-7M3 also write their black levels in plain form
/// (`0x7310`: 800 and 512); XOR-ing their `SR2SubIFD`s against each other matched `pack(512×4) ^ pack(800×4)` at
/// exactly one position, byte 2510, which gave the eight keystream bytes there. Applied to the other files at that
/// position they decode to four equal levels that agree with each sensor's dark-pixel floor: 800 on every 1″-sensor
/// body (RX10, RX100 … RX100M5), 512 on the APS-C/full-frame ones (NEX-5N/6, SLT-A77, ILCE-6000/7/7M2/7RM2), in
/// both `SR2SubIFD` sizes seen (29252 and 56958 bytes). Newer layouts (ILCE-7M4, 35398 bytes) put other data there
/// and are rejected by the plausibility checks; those bodies write `0x7310` anyway.
const SR2_BLACK_AT: usize = 2510;
const SR2_BLACK_KEYSTREAM: [u8; 8] = [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73];
const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44];
/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]), in 14-bit units; `None`
/// unless the file uses the known key and the four decoded levels are equal and plausible.
fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Option<f32> {
let private = ifd0.bytes(t::DNG_PRIVATE_DATA)?;
let at = order.read_u32(private, 0)? as u64;
let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() };
let (sr2, _) = lightcraft_tiff::parse_ifd_at(bytes, at, order, 0, false, &opts).ok()?;
if sr2.bytes(SR2_SUBIFD_KEY)? != SR2_KEY {
return None;
}
let (start, len) = (usize::try_from(sr2.u64(SR2_SUBIFD_OFFSET)?).ok()?, usize::try_from(sr2.u64(SR2_SUBIFD_LENGTH)?).ok()?);
if len < SR2_BLACK_AT + 8 {
return None;
}
sr2_black_levels(bytes.get(start.checked_add(SR2_BLACK_AT)?..start.checked_add(SR2_BLACK_AT + 8)?)?, order)
}
/// Decipher the eight `SR2SubIFD` bytes at [`SR2_BLACK_AT`] into one black level (the mean of four near-equal,
/// plausible per-channel levels).
fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<f32> {
let plain: Vec<u8> = cipher.iter().zip(SR2_BLACK_KEYSTREAM).map(|(c, k)| c ^ k).collect();
let levels: Vec<u16> = (0..4).filter_map(|i| order.read_u16(&plain, 2 * i)).collect();
let (&lo, &hi) = (levels.iter().min()?, levels.iter().max()?);
(levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::<f32>() / 4.0)
}
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
/// default crop when the raw IFD has one, else the maker note's `FullImageSize` (the camera JPEG's size) anchored
/// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant
/// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so
/// the anchored crop removes them while keeping the CFA phase.
fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
let full = Rect::new(0, 0, w, h);
if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref())
&& *cw > 0
&& *ch > 0
{
return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h);
}
match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() {
// only a plausible trim: never more than 64 pixels per side, never an enlargement
Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => {
Rect::new(0, 0, *fw as usize, *fh as usize)
}
_ => full,
}
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@@ -215,26 +419,43 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
_ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?, bits),
};
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float ARW".into())) };
// "12-bit uncompressed" files (e.g. ILCE-7RM2) say BitsPerSample 12 but store 16-bit words on the 14-bit scale
// of the other modes (black 512, peaks near 16383): black and white follow the data, `bits` keeps the tag
let scale_bits = if info.compression == 1 && out_bits < 14 && strip_len >= (w * h * 2) as u64 && samples.iter().any(|&v| v >> (out_bits + 1) != 0)
{
14
} else {
out_bits
};
let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer_static("RGGB"),
};
let default_black = if out_bits >= 14 { 512.0 } else { 128.0 };
let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 };
let black = match raw.f64s(BLACK_LEVEL).as_deref() {
Some([a, b, c, d]) => {
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() }
}
_ => BlackLevel::uniform(default_black),
_ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)),
};
let white = raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, out_bits));
let wb = raw.f64s(WB_RGGB).filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0).map(|v| {
let g = (v[1] + v[2]) / 2.0;
[(v[0] / g) as f32, 1.0, (v[3] / g) as f32]
});
let white = raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits));
let model = ifd0.string(t::MODEL).unwrap_or_default();
let mn = tiff
.exif()
.and_then(|e| e.get(t::MAKER_NOTE))
.and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default()));
let wb = raw
.f64s(WB_RGGB)
.filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0)
.map(|v| {
let g = (v[1] + v[2]) / 2.0;
[(v[0] / g) as f32, 1.0, (v[3] / g) as f32]
})
.or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order)));
let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) {
(Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h),
_ => Rect::new(0, 0, w, h),
_ => default_crop(raw, mn.as_ref(), w, h),
};
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
@@ -329,6 +550,58 @@ mod tests {
let _ = code_curve(&[60000, 1, 0, 0]);
}
#[test]
fn decipher_inverts_the_cube_substitution() {
let mut seen = [false; 256];
for p in 0..=255u8 {
let c = if p < 249 { (p as u32).pow(3) % 249 } else { p as u32 } as u8;
assert_eq!(DECIPHER[c as usize], p);
seen[DECIPHER[p as usize] as usize] = true;
}
assert!(seen.iter().all(|&s| s), "not a bijection");
assert_eq!((DECIPHER[8], DECIPHER[27], DECIPHER[94]), (2, 3, 7)); // 7³ = 343 ≡ 94
}
fn encipher(plain: &[u8]) -> Vec<u8> {
plain.iter().map(|&p| if p < 249 { ((p as u32).pow(3) % 249) as u8 } else { p }).collect()
}
#[test]
fn tag2010_white_balance_by_model() {
use lightcraft_tiff::ByteOrder::Little;
let mut plain = vec![0u8; 700];
for (i, v) in [669u16, 256, 441].iter().enumerate() {
plain[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes());
}
let block = encipher(&plain);
let wb = tag2010_wb("DSC-RX100M3", &block, Little).unwrap();
assert!((wb[0] - 669.0 / 256.0).abs() < 1e-6 && wb[1] == 1.0 && (wb[2] - 441.0 / 256.0).abs() < 1e-6, "{wb:?}");
// same layout (Tag2010h), regional "V" suffix, other layouts, unknown models, truncated or implausible data
assert!(tag2010_wb("ILCE-7RM2", &block, Little).is_some());
assert!(tag2010_wb("DSC-RX100M3V", &block, Little).is_some());
assert!(tag2010_wb("SLT-A77V", &block, Little).is_none()); // Tag2010b: offset 4480 is past the block
assert!(tag2010_wb("DSC-RX100M3X", &block, Little).is_none());
assert!(tag2010_wb("ILCE-1", &block, Little).is_none());
assert!(tag2010_wb("DSC-RX100M3", &block[..615], Little).is_none());
assert!(tag2010_wb("DSC-RX100M3", &encipher(&[0u8; 700]), Little).is_none());
let mut odd = plain.clone();
odd[612..614].copy_from_slice(&9000u16.to_le_bytes()); // R/G ≈ 35
assert!(tag2010_wb("DSC-RX100M3", &encipher(&odd), Little).is_none());
}
#[test]
fn sr2_black_level_decodes_and_rejects_garbage() {
use lightcraft_tiff::ByteOrder::Little;
let cipher =
|levels: [u16; 4]| -> Vec<u8> { levels.iter().flat_map(|v| v.to_le_bytes()).zip(SR2_BLACK_KEYSTREAM).map(|(p, k)| p ^ k).collect() };
assert_eq!(sr2_black_levels(&cipher([800; 4]), Little), Some(800.0));
assert_eq!(sr2_black_levels(&cipher([512, 512, 514, 512]), Little), Some(512.5));
assert_eq!(sr2_black_levels(&cipher([65320, 64928, 65260, 488]), Little), None); // ILCE-7M4's layout
assert_eq!(sr2_black_levels(&cipher([0; 4]), Little), None);
assert_eq!(sr2_black_levels(&cipher([800; 4])[..5], Little), None);
assert_eq!(sr2_black_levels(&[], Little), None);
}
#[test]
fn quad_tiles_place_cfa_cells() {
// 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells,
+36 -3
View File
@@ -56,10 +56,17 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
if info.compression == t::compression::NIKON { compressed(bytes, &info, mn.as_ref())? } else { uncompressed(bytes, &info, tiff.order)? };
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) };
// Maker note 0x003d is in 14-bit units whatever the sample depth: 12-bit files from the D750, D780, D850,
// D7500 and Z 50 store 600 / 1008 / 400 / 400 / 1008 while their darkest samples are 150 / ~252 / 99 / 99 / ~250
// (our measurements on CC0 raw.pixls.us samples; the same bodies' 14-bit files match the tag as is).
let black_scale = if bits == 12 { 0.25 } else { 1.0 };
let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_LEVEL)).as_deref() {
Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => {
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() }
}
Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => BlackLevel {
repeat_rows: 2,
repeat_cols: 2,
values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(),
..Default::default()
},
_ => BlackLevel::uniform(0.0),
};
let wb = mn
@@ -145,6 +152,11 @@ mod tests {
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
fn nef(compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32) -> Vec<u8> {
nef_with_note(compression, bits, strips, w, h, rps, None)
}
/// [`nef`] with an optional maker note (`Nikon\0` v2 header + embedded big-endian TIFF holding `note`).
fn nef_with_note(compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
@@ -158,10 +170,31 @@ mod tests {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into()));
if let Some(mn) = note {
let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec();
bytes.extend(TiffWriter::new(ByteOrder::Big, false).write(&[mn]).unwrap());
let mut exif = IfdBuilder::new();
exif.set(t::MAKER_NOTE, Value::Undefined(bytes));
ifd0.set_child(t::EXIF_IFD, exif);
}
ifd0.add_sub_ifd(raw);
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
#[test]
fn black_level_tag_is_in_14_bit_units() {
let (w, h) = (16usize, 4usize);
let words: Vec<u8> = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect();
let note = || {
let mut mn = IfdBuilder::new();
mn.set(BLACK_LEVEL, Value::Short(vec![400, 404, 408, 412]));
mn
};
let black = |bits| crate::decode(&nef_with_note(1, bits, vec![words.clone()], w as u32, h as u32, h as u32, Some(note()))).unwrap().black;
assert_eq!(black(12).values, vec![100.0, 101.0, 102.0, 103.0]);
assert_eq!(black(14).values, vec![400.0, 404.0, 408.0, 412.0]);
}
#[test]
fn uncompressed_word16_and_packed12() {
let (w, h) = (16usize, 6usize);
+96
View File
@@ -193,3 +193,99 @@ fn corpus_nef_compressed_matches_uncompressed() {
assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}");
}
}
/// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in
/// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree.
#[test]
fn corpus_nef_12_bit_black_level_matches_14_bit() {
let dir = corpus_root().join("raw");
let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef")))
else {
eprintln!("skip: D7500 NEF pair absent");
return;
};
let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap());
assert_eq!((a.bits, b.bits), (12, 14));
assert!((a.black.values[0] - 100.0).abs() < 1.0 && (b.black.values[0] - 400.0).abs() < 1.0, "{:?} {:?}", a.black, b.black);
let normalized = |img: &lightcraft_raw::RawImage| {
let (black, white) = (img.black.values[0] as f64, img.white[0] as f64);
block_means(img).into_iter().map(|v| (v - black) / (white - black)).collect::<Vec<_>>()
};
// the two shots aren't pixel-aligned (handheld): compare the distributions, not block by block
let (mut na, mut nb) = (normalized(&a), normalized(&b));
na.sort_by(f64::total_cmp);
nb.sort_by(f64::total_cmp);
for q in [0.1, 0.5, 0.9] {
let (x, y) = (na[(na.len() as f64 * q) as usize], nb[(nb.len() as f64 * q) as usize]);
eprintln!("D7500 12- vs 14-bit: normalized quantile {q}: {x:.4} vs {y:.4}");
// with the tag read as 12-bit units, the 12-bit values would sit below black (negative)
assert!(x > 0.0 && (0.8..1.25).contains(&(x / y)), "quantile {q}: {x} vs {y}");
}
}
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
/// look table; we read them (and render with them). Camera-written DNGs here carry none.
#[test]
fn corpus_adobe_dngs_carry_profile_looks() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut seen = 0;
for p in rd.flatten().map(|e| e.path()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
if !name.starts_with("dng-") || !name.ends_with(".dng") {
continue;
}
let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}"));
let look = &info.color.profile;
if name.starts_with("dng-adobe-") {
seen += 1;
let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map"));
assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}");
assert!(look.look_table.is_some(), "{name}: no look table");
// a profile applied to a mid grey keeps it (close to) neutral
let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap();
let g = t.apply([0.18; 3], 1.0);
assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}");
}
eprintln!(
"{name:44} profile look: hsm {} look {} tone {}",
look.hue_sat_map[0].is_some(),
look.look_table.is_some(),
look.tone_curve.is_some()
);
}
eprintln!("{seen} Adobe-converted DNGs checked");
}
/// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD.
/// White balance comes from the maker note's enciphered `Tag2010`, the black level from the encrypted `SR2SubIFD`
/// and the crop from `FullImageSize`; without them the RX100 III opened bright green. Expected values: the black
/// levels agree with each sensor's dark-pixel floor, the gains with the neutral sky of the camera JPEG.
#[test]
fn corpus_sony_pre2017_colour_metadata() {
let dir = corpus_root().join("raw");
// (file, black, approximate R and B gains, crop width × height)
let cases = [
("arw-sony-rx100m3.arw", 800.0, [2.61, 1.72], (5472, 3648)),
("arw-sony-rx100.arw", 800.0, [2.23, 2.00], (5472, 3648)),
("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.46], (7952, 5304)),
];
let mut seen = 0;
for (name, black, [r, b], (cw, ch)) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.black.mean(), black, "{name}: black level");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}");
assert_eq!((img.crop.width, img.crop.height), (cw, ch), "{name}: crop");
assert!(img.white_at(0) > 16000.0, "{name}: white {} (14-bit scale)", img.white_at(0));
seen += 1;
}
eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files");
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more