Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 320bcad19a Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
Add LICENSE-MIT and LICENSE-APACHE (Copyright (c) 2026 ArtCraft Team and the
LightCraft contributors) and docs/brand/LICENSE-brand.txt from the Crafting Apps
licensing standard. Rewrite NOTICE to the standard template and drop the stale
Source Sans 3 entry (only Inter is bundled). Attribute docs/brand/ in
assets/ATTRIBUTION.md so `cargo xtask assets` passes again. README gets the
standard "License and credits" section; AGENTS.md/CLAUDE.md note the brand
exception to the asset rule.

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

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

Fixes #14

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

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

Refs #10

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

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

Fixes #13

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

Fixes #9

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

Fixes #12

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

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

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

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

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

Fixes #8

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

Fixes #7

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
- **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).
- 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.
- 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.
+1
View File
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
- **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).
- 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.
- 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.
Generated
+25 -21
View File
@@ -2073,7 +2073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -2090,7 +2090,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -2103,7 +2103,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -2117,7 +2117,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"bytemuck",
"image",
@@ -2142,7 +2142,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -2151,7 +2151,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2162,7 +2162,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"ab_glyph",
"libc",
@@ -2180,6 +2180,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"log",
"miniz_oxide 0.8.9",
"rayon",
"serde",
"serde_json",
@@ -2188,7 +2189,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"proptest",
"serde",
@@ -2196,7 +2197,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -2212,7 +2213,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2223,7 +2224,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2239,7 +2240,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2251,7 +2252,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"image",
"lightcraft-color",
@@ -2267,7 +2268,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -2279,7 +2280,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -2291,8 +2292,9 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"jpeg-encoder",
"lightcraft-color",
"lightcraft-geom",
"lightcraft-meta",
@@ -2303,11 +2305,13 @@ dependencies = [
"rayon",
"serde",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "lightcraft-scenes"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"image",
"lightcraft-color",
@@ -2318,7 +2322,7 @@ dependencies = [
[[package]]
name = "lightcraft-tiff"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"proptest",
"serde",
@@ -2327,7 +2331,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -2347,7 +2351,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -5115,7 +5119,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"brotli",
"flate2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -7
View File
@@ -1,12 +1,16 @@
LightCraft
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
This product bundles third-party material under the following licences:
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Third-party material
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in assets/ATTRIBUTION.md.
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
+11 -2
View File
@@ -396,12 +396,21 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
## License and credits
LightCraft is licensed under **MIT OR Apache-2.0**.
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
Inter (SIL OFL 1.1). All icons are original.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
+24 -21
View File
@@ -26,53 +26,56 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
| 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 ⬜) |
## Parity estimate (2026-10-02)
## Parity estimate (2026-10-02, evening update)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
| Scope | Weighted completion | Rows |
|---|---:|---:|
| P0 (core) | **98.5%** | 198 |
| P1 (important parity) | **79.9%** | 144 |
| P2 (later / AI / niche, incl. Classic modules) | **11.7%** | 158 |
| **All in-scope rows** | **65.7%** | 500 |
| Scope | Weighted completion | Rows | Morning |
|---|---:|---:|---:|
| P0 (core) | **98.7%** | 198 | 98.5% |
| P1 (important parity) | **94.4%** | 144 | 79.9% |
| P2 (later / AI / niche, incl. Classic modules) | **29.4%** | 158 | 11.7% |
| **All in-scope rows** | **75.6%** | 500 | 65.7% |
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
UI/feature work in ≈ 5 h on 2026-10-01, and four to six parallel agents built M0–M13 in ≈ 25 active hours):
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (version hover previews, filter presets, quick develop, missing-file relink, metadata presets, keyword sets…) | ≈ 30 | 20–35 | low |
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
| Classic modules: Map, Book, Slideshow, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: watched folders, Enhance dialog, external-editor round trip, export to Photos, help / what's new, accessibility, localisation | ≈ 25 | 20–35 | low |
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
| **Total remaining** | | **≈ 295–540** | |
| **Total remaining** | | **≈ 270–505** | |
Spent so far ≈ 90–130 agent-hours, so **by effort the project is ≈ 20–30% of the way to complete Lightroom + Classic
parity**, and ≈ 40–55% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 135–235 h).
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
most of it the raw codecs, lens data, video and HDR).
**Wall clock:** ≈ 300–540 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 75–130 h of continuous work**. The AI package and the raw codecs carry most of the
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
## Totals
Remaining from 2026-10-02 (see *Parity estimate* above for the breakdown):
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|---|---:|---:|
| Cloud-Lightroom parity without AI or Classic modules | ≈ 135–235 h | ≈ 35–60 h |
| Full parity incl. AI, video and the Classic modules | ≈ 295–540 h | ≈ 75–130 h |
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
@@ -91,7 +94,7 @@ 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, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, 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.
+3
View File
@@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
save_preset_file: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
// downloads happen on the main thread: exports run in the foreground on the web
write_shared: None,
@@ -161,6 +163,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
+2
View File
@@ -56,6 +56,7 @@ impl 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::Smart { .. } => 2560,
};
WireJob {
level: job.level,
@@ -91,6 +92,7 @@ impl WireJob {
// workers render previews (exports run in-process)
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
}
}
+46 -1
View File
@@ -12,6 +12,11 @@
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `lightcraft_ui_egui::control` for the methods.
//!
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
mod alloc_release;
mod control_server;
@@ -95,6 +100,28 @@ fn save_prefs(app: &LightcraftApp) {
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()))),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
})),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
@@ -139,7 +166,7 @@ fn services() -> Services {
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets", &["lcpreset", "xmp"])
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -154,6 +181,24 @@ fn services() -> Services {
.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"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
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_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+6
View File
@@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"'" => Code::Quote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
"Enter" if !mods.is_empty() => Code::Enter,
"F1" => Code::F1,
"F2" => Code::F2,
"F3" => Code::F3,
@@ -243,6 +245,10 @@ impl NativeMenu {
.collect();
let nodes = tidy_separators(nodes);
self.append_nodes(&sub, &nodes);
if title == "File" {
// ⌘W, the system's own item
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
}
if title == "Window" {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
sub.set_as_windows_menu_for_nsapp();
+1
View File
@@ -17,6 +17,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+49 -27
View File
@@ -51,38 +51,60 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
let Some((first, rest)) = parts.split_first() else { return };
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
Some(i) => i,
None => {
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
nodes.len() - 1
}
};
if seen.insert((path.to_lowercase(), photo)) {
nodes[i].count += 1;
}
let p = nodes[i].path.clone();
insert(&mut nodes[i].children, rest, &p, photo, seen);
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent)
struct Level {
name: String,
path: String,
count: usize,
parent: Option<String>,
}
fn sort(nodes: &mut [KeywordNode]) {
nodes.sort_by_key(|n| n.name.to_lowercase());
for n in nodes {
sort(&mut n.children);
}
}
let mut roots = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
for p in self.photos().filter(|p| p.in_library()) {
this_photo.clear();
for k in &p.meta.keywords {
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
insert(&mut roots, &parts, "", p.id.0, &mut seen);
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
}
}
}
sort(&mut roots);
roots
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
})
.collect()
}
build(None, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
+51 -2
View File
@@ -12,6 +12,7 @@ pub mod journal;
pub mod keywords;
pub mod model;
pub mod query;
pub mod rules;
pub mod stacks;
pub mod store;
@@ -24,6 +25,7 @@ pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
pub use model::*;
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
@@ -147,6 +149,11 @@ pub enum Op {
id: PhotoId,
captured: Option<String>,
},
/// Assisted culling scores.
SetAnalysis {
id: PhotoId,
analysis: Option<crate::Analysis>,
},
/// Rename a photo: its file name and the source it points to. Applying the op never touches
/// the disk — the engine moves the file before it commits (and on undo/redo).
SetFile {
@@ -160,6 +167,22 @@ pub enum Op {
id: PhotoId,
file_name: String,
source: Source,
/// The file's format when it changes too (Convert to DNG).
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
/// What a photo's file is now (after it changed on disk: Reload).
SetContent {
id: PhotoId,
width: u32,
height: u32,
file_size: u64,
#[serde(default)]
content_hash: Option<String>,
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
@@ -233,6 +256,11 @@ impl Catalog {
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
/// The Quick Collection, once something was added to it.
pub fn quick_collection(&self) -> Option<AlbumId> {
self.albums.values().find(|a| a.quick).map(|a| a.id)
}
/// Albums (regular and smart) that contain the photo.
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
let Some(p) = self.photos.get(&id) else { return Vec::new() };
@@ -288,6 +316,11 @@ impl Catalog {
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
}
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -466,6 +499,10 @@ impl Catalog {
s.collapsed = collapsed;
old
}
Op::SetAnalysis { id, analysis } => {
let p = self.photo_mut(id)?;
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
}
Op::SetCaptured { id, captured } => {
if let Some(c) = &captured
&& stacks::iso_seconds(c).is_none()
@@ -475,10 +512,22 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
}
Op::Relink { id, file_name, source } => {
Op::Relink { id, file_name, source, format } => {
let p = self.photo_mut(id)?;
let old_name = std::mem::replace(&mut p.file_name, file_name);
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
width: std::mem::replace(&mut p.width, width),
height: std::mem::replace(&mut p.height, height),
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
+33 -1
View File
@@ -197,6 +197,28 @@ pub struct Photo {
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
/// Assisted culling scores (`None` until analysed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Analysis>,
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
/// picture style baked in — so it is treated as a rendered (non-raw) source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
}
/// What assisted culling measured on a photo.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Analysis {
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
pub sharpness: f32,
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
pub clipped: f32,
/// Similar-shot group (burst), when the photo has neighbours that look alike.
pub group: Option<u32>,
/// The best photo of its group.
pub best: bool,
}
impl Photo {
@@ -229,8 +251,15 @@ impl Photo {
copy_of: None,
copy_name: None,
import_look: None,
analysis: None,
preview_only: None,
}
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
/// its embedded preview ([`Photo::preview_only`]).
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The 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 {
@@ -283,12 +312,15 @@ pub struct Album {
/// live; `photos` stays empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart: Option<Box<crate::Filter>>,
/// The Quick Collection: a regular album that B adds to (one per library).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub quick: bool,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
+59 -1
View File
@@ -24,6 +24,12 @@ pub struct Filter {
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
/// Only these photos (Find Similar results…); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub only: Vec<PhotoId>,
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
@@ -40,11 +46,18 @@ pub struct Filter {
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
/// Import date prefix.
pub imported: Option<String>,
/// Imported at or after this time (ISO; Recently Added).
pub imported_from: Option<String>,
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
pub merged: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_set: Option<crate::RuleSet>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -130,6 +143,9 @@ impl Filter {
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if !self.labels.is_empty() {
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
@@ -152,6 +168,9 @@ impl Filter {
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(rs.describe());
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
@@ -186,6 +205,23 @@ impl Filter {
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(rs) = &self.rule_set
&& !rs.matches(p, cat)
{
return false;
}
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !self.only.contains(&p.id) {
return false;
}
if let Some(want) = &self.merged {
match merged_kind(&p.file_name) {
Some(k) if want == "any" || want == k => {}
_ => return false,
}
}
if let Some(a) = self.album
&& !cat.album_contains(a, p)
{
@@ -216,6 +252,11 @@ impl Filter {
{
return false;
}
if let Some(d) = &self.imported_from
&& p.imported.as_str() < d.as_str()
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
@@ -304,3 +345,20 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
if stem.ends_with("-hdr-pano") {
Some("hdrPanorama")
} else if stem.ends_with("-hdr") {
Some("hdr")
} else if stem.ends_with("-pano") {
Some("panorama")
} else {
None
}
}
+470
View File
@@ -0,0 +1,470 @@
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
//!
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Catalog, Photo};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Match {
#[default]
All,
Any,
None,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RuleSet {
#[serde(default, rename = "match")]
pub mode: Match,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Rule {
/// A nested group with its own match mode.
Group { group: RuleSet },
Field {
field: String,
op: String,
#[serde(default)]
value: Value,
},
}
/// How a field's value is compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Text,
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
Keywords,
Number,
Date,
/// One of a fixed set (`choices`).
Choice(&'static [&'static str]),
Bool,
}
/// The fields rules can test: (id, label, kind).
pub const FIELDS: &[(&str, &str, Kind)] = &[
("rating", "Rating", Kind::Number),
("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
("edited", "Has Edits", Kind::Bool),
("keywords", "Keywords", Kind::Keywords),
("text", "Any Searchable Text", Kind::Text),
("fileName", "Filename", Kind::Text),
("format", "File Format", Kind::Text),
("title", "Title", Kind::Text),
("caption", "Caption", Kind::Text),
("camera", "Camera", Kind::Text),
("lens", "Lens", Kind::Text),
("location", "Location", Kind::Text),
("creator", "Creator", Kind::Text),
("copyright", "Copyright", Kind::Text),
("captureDate", "Capture Date", Kind::Date),
("importDate", "Import Date", Kind::Date),
("editDate", "Edit Date", Kind::Date),
("iso", "ISO Speed", Kind::Number),
("aperture", "Aperture", Kind::Number),
("focalLength", "Focal Length", Kind::Number),
("megapixels", "Megapixels", Kind::Number),
("hasGps", "Has GPS", Kind::Bool),
("virtualCopy", "Virtual Copy", Kind::Bool),
("album", "Album", Kind::Number),
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
/// The operators for a field kind: (id, label).
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
match kind {
Kind::Text => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("isNot", "isn't"),
("startsWith", "starts with"),
("endsWith", "ends with"),
("isEmpty", "is empty"),
("isNotEmpty", "isn't empty"),
],
Kind::Keywords => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("startsWith", "starts with"),
("isEmpty", "are empty"),
("isNotEmpty", "aren't empty"),
],
Kind::Number => {
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
}
Kind::Date => &[
("is", "is"),
("after", "is after"),
("before", "is before"),
("between", "is between"),
("inLast", "is in the last"),
("notInLast", "isn't in the last"),
("isEmpty", "is unknown"),
],
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
Kind::Bool => &[("is", "is")],
}
}
pub fn field_kind(field: &str) -> Option<Kind> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
}
thread_local! {
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
pub fn set_now(iso: Option<String>) {
NOW.with(|n| *n.borrow_mut() = iso);
}
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
pub fn now() -> String {
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
return n;
}
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
crate::dates::civil(secs)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00".to_string()
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
Value::Null => String::new(),
other => other.to_string().to_lowercase(),
}
}
fn text_op(op: &str, have: &str, want: &str) -> bool {
let have = have.trim().to_lowercase();
match op {
"contains" => want.split_whitespace().all(|w| have.contains(w)),
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
"is" => have == want,
"isNot" => have != want,
"startsWith" => have.starts_with(want),
"endsWith" => have.ends_with(want),
"isEmpty" => have.is_empty(),
"isNotEmpty" => !have.is_empty(),
_ => false,
}
}
fn number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
_ => None,
}
}
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
if op == "between" {
let (a, b) = match value {
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
_ => (None, None),
};
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
}
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
match op {
"is" => (h - w).abs() < 1e-6,
"isNot" => (h - w).abs() >= 1e-6,
"gte" => h >= w - 1e-9,
"lte" => h <= w + 1e-9,
"gt" => h > w,
"lt" => h < w,
_ => false,
}
}
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
fn last_secs(value: &Value) -> Option<i64> {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
v => (number(v)?, "days"),
};
let day = 86_400.0;
let per = match unit {
"hours" => 3600.0,
"weeks" => 7.0 * day,
"months" => 30.4375 * day,
"years" => 365.25 * day,
_ => day,
};
Some((n * per) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
if op == "isEmpty" {
return have.is_none_or(str::is_empty);
}
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
match op {
// a prefix: 2026, 2026-04, 2026-04-12
"is" => {
let w = s(value);
!w.is_empty() && h.starts_with(&w)
}
"after" => {
let w = s(value);
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
}
"before" => {
let w = s(value);
!w.is_empty() && h < w.as_str()
}
"between" => match value {
Value::Array(v) if v.len() == 2 => {
let (a, b) = (s(&v[0]), s(&v[1]));
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
}
_ => false,
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
}
}
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = format!("{:?}", p.flag).to_lowercase();
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
// custom label names count too
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
"edited" => p.is_edited() == value.as_bool().unwrap_or(true),
"hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
"virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
"keywords" => match op {
"isEmpty" => m.keywords.is_empty(),
"isNotEmpty" => !m.keywords.is_empty(),
"notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
_ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
},
"text" => {
let all = [
p.file_name.as_str(),
&m.title,
&m.caption,
&m.camera,
&m.lens,
&m.location,
&m.city,
&m.country,
&p.format,
&m.keywords.join(" "),
]
.join(" ");
text(&all)
}
"fileName" => text(&p.file_name),
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
"camera" => text(&m.camera),
"lens" => text(&m.lens),
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
"creator" => text(&m.creator),
"copyright" => text(&m.copyright),
"captureDate" => date_op(op, p.captured.as_deref(), value),
"importDate" => date_op(op, Some(&p.imported), value),
"editDate" => date_op(op, p.edited.as_deref(), value),
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
"album" => {
let id = number(value).map(|v| crate::AlbumId(v as u64));
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
}
_ => false,
}
}
}
impl RuleSet {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if self.rules.is_empty() {
return self.mode != Match::Any;
}
match self.mode {
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
}
}
/// Unknown fields or operators (for command validation), as readable messages.
pub fn problems(&self) -> Vec<String> {
let mut out = Vec::new();
for r in &self.rules {
match r {
Rule::Group { group } => out.extend(group.problems()),
Rule::Field { field, op, .. } => match field_kind(field) {
None => out.push(format!("unknown field `{field}`")),
Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
_ => {}
},
}
}
out
}
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
pub fn describe(&self) -> String {
let join = match self.mode {
Match::All => " and ",
Match::Any => " or ",
Match::None => " nor ",
};
let parts: Vec<String> = self
.rules
.iter()
.map(|r| match r {
Rule::Group { group } => format!("({})", group.describe()),
Rule::Field { field, op, value } => {
let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
let v = match value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
o.get("unit").and_then(Value::as_str).unwrap_or("days")
),
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
other => other.to_string(),
};
format!("{label} {op} {v}").trim().to_string()
}
})
.collect();
let s = parts.join(join);
if self.mode == Match::None { format!("none of: {s}") } else { s }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
);
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
// nested: rating ≥ 4 and (label is blue or keywords contain food)
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
}
+36
View File
@@ -93,6 +93,42 @@ impl Catalog {
self.group_ops(result, sources, true)
}
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
/// top). `None` when it isn't stacked or can't move.
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
let st = self.stack_of(photo)?;
let i = st.photos.iter().position(|p| *p == photo)?;
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
if to == i {
return None;
}
let mut photos = st.photos.clone();
let p = photos.remove(i);
photos.insert(to, p);
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
}
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
/// new stack (topped by it). A part with a single photo is no longer stacked.
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
let st = self.stack_of(photo)?.clone();
let i = st.photos.iter().position(|p| *p == photo)?;
if i == 0 {
return None;
}
let (head, tail) = st.photos.split_at(i);
let mut ops = vec![if head.len() >= 2 {
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
} else {
Op::RemoveStack { id: st.id }
}];
if tail.len() >= 2 {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
}
Some(Op::Batch { ops })
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
+52 -4
View File
@@ -57,11 +57,15 @@ fn albums_and_folders() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let f = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
})
.unwrap();
let al = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
.unwrap();
c.apply(Op::AddAlbum {
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
})
.unwrap();
assert_eq!(c.albums_of(a), vec![al]);
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
@@ -216,3 +220,47 @@ fn folder_paths() {
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
}
#[test]
fn merge_results_are_recognised() {
use crate::query::merged_kind;
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
assert_eq!(merged_kind("hdr.jpg"), None);
assert_eq!(merged_kind("panorama.jpg"), None);
}
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
/// with an exact inverse that survives serialisation (the journal).
#[test]
fn preview_only_survives_serde_and_set_content_undo() {
let mut c = Catalog::new();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
assert!(!p.develops_raw());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
// the catalog round-trips it
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
// an older catalog (no field) loads as decodable
let mut v = serde_json::to_value(&c).unwrap();
let photos = v["photos"].as_object_mut().unwrap();
let ph = photos.values_mut().next().unwrap();
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
let old: Catalog = serde_json::from_value(v).unwrap();
assert_eq!(old.photo(a).unwrap().preview_only, None);
assert!(old.photo(a).unwrap().develops_raw());
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
let inv = c.apply(op).unwrap();
assert!(c.photo(a).unwrap().develops_raw());
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().width, 6000);
}
+10 -1
View File
@@ -50,7 +50,16 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
5 => {
let id = c.alloc_album_id();
Op::AddAlbum {
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
album: Album {
id,
name: format!("A{b}"),
parent: None,
folder: b.is_multiple_of(4),
photos: vec![],
cover: None,
smart: None,
quick: false,
},
}
}
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
+1
View File
@@ -105,6 +105,7 @@ mod tests {
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial {
+22 -1
View File
@@ -208,8 +208,29 @@ impl Preset {
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
}
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
/// already has are skipped), their effect scaled by the amount.
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
apply_partial(s, &self.settings, amount)
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
return apply_partial(s, &self.settings, amount);
};
let mut rest = self.settings.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
out
}
}
+11 -1
View File
@@ -786,6 +786,9 @@ pub enum MaskShape {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskComponent {
/// A name given in the Masking panel (Rename); `None` shows the shape's kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub op: MaskOp,
#[serde(default)]
@@ -802,11 +805,18 @@ pub struct Mask {
pub invert: bool,
pub components: Vec<MaskComponent>,
pub adjust: LocalAdjustments,
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
#[serde(skip_serializing_if = "is_zero")]
pub refine: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl Default for Mask {
fn default() -> Self {
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
}
}
+1
View File
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
+90
View File
@@ -55,6 +55,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"folder.rename",
"Rename Folder",
[],
None,
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?;
let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.move",
"Move Folder",
[],
None,
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
let to = Path::new(into).join(name).to_string_lossy().to_string();
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?;
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"library.browse",
"Browse Folder",
@@ -67,3 +101,59 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
]
}
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
/// point the photos back at a folder that no longer exists.
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
use std::path::Path;
let (src, dst) = (Path::new(from), Path::new(to));
if !src.is_dir() {
return Err(format!("{from} is not a folder"));
}
if dst.exists() {
return Err(format!("{to} already exists"));
}
if dst.starts_with(src) {
return Err("a folder can't move into itself".into());
}
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
return Err("the library's own folders can't be moved here".into());
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
let mut n = 0;
let ops: Vec<lightcraft_catalog::Op> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
_ => None,
})
.map(|(id, file_name, np)| {
n += 1;
lightcraft_catalog::Op::Relink {
id,
file_name,
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
format: None,
}
})
.collect();
for op in ops {
if s.catalog.apply(op.clone()).is_ok() {
s.pending_log.push(op);
}
}
// a browsed folder at or below it follows
if let Some(b) = &mut s.browse
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
{
b.path = dst.join(rel).to_string_lossy().to_string();
let f = b.path.clone();
s.filter.folder = Some(f);
}
Ok(n)
}
+279
View File
@@ -0,0 +1,279 @@
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
//! disk; undo relinks the photo to it.
use std::path::Path;
use lightcraft_catalog::{MediaKind, Op, Source};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_active, has_selection};
use crate::{Result, Session};
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
fn dng_path(path: &str) -> String {
let p = Path::new(path);
let dir = p.parent().unwrap_or(Path::new(""));
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let mut out = dir.join(format!("{stem}.dng"));
let mut n = 2;
while out.exists() {
out = dir.join(format!("{stem}-{n}.dng"));
n += 1;
}
out.to_string_lossy().to_string()
}
/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path.
/// An empty `packet` writes no XMP.
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
let bytes = match &s.media.file_bytes {
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
let out = dng_path(path);
std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?;
Ok(out)
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.convertToDng";
let ids = s.targets(p);
let mut ops = Vec::new();
let mut converted = Vec::new();
let mut skipped = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
let Source::File { path } = &ph.source else {
skipped.push(json!([id.0, "not a file"]));
continue;
};
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
skipped.push(json!([
id.0,
if ph.kind != MediaKind::Raw {
"not a raw photo"
} else if ph.copy_of.is_some() {
"a virtual copy"
} else {
"already a DNG"
}
]));
continue;
}
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
match write_dng_for(s, path, packet) {
Ok(out) => {
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
// virtual copies of this photo follow it to the DNG
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.push(Op::Relink {
id: c.id,
file_name: file_name.clone(),
source: Source::File { path: out.clone() },
format: Some("DNG".into()),
});
}
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
converted.push(json!({"id": id.0, "path": out, "original": path}));
}
Err(e) => skipped.push(json!([id.0, e])),
}
}
if !ops.is_empty() {
let n = converted.len();
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
}
Ok(json!({"converted": converted, "skipped": skipped}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"photo.convertToDng",
"Convert to DNG",
["Photo"],
None,
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
has_selection,
convert
)]
}
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
/// stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
written.push(path.to_string());
Ok(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
Ok(json!({"path": out, "id": new, "original": id.0}))
}
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
(!same).then_some(Op::SetContent {
id,
width: info.width,
height: info.height,
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
})
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p);
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
Some(Source::File { path }) => Some((*id, path)),
_ => None,
})
.collect();
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
let Some(op) = content_op(id, ph, info.clone()) else { continue };
ops.push(op);
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.extend(content_op(c.id, c, info.clone()));
reloaded.push(c.id);
}
reloaded.push(id);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
/// same settings, metadata, rating, flag, label and albums. → {ids}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.duplicate";
let mut made = Vec::new();
for id in s.targets(p) {
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
let src = Path::new(path);
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
let dir = src.parent().unwrap_or(Path::new(""));
let mut dst = dir.join(format!("{stem}-copy{ext}"));
let mut n = 2;
while dst.exists() {
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
n += 1;
}
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
let new = s.catalog.alloc_photo_id();
let mut q = ph.clone();
q.id = new;
q.source = Source::File { path: dst.to_string_lossy().to_string() };
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
// the same bytes, but its own photo: no duplicate-content clash, no shared history
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
q.copy_of = None;
q.copy_name = None;
q.history.clear();
q.versions.retain(|v| !v.auto);
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
let mut photos = a.photos.clone();
photos.push(new);
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
}
s.commit("Duplicate", Op::Batch { ops })?;
made.push(new);
}
s.merge_undo(made.len(), "Duplicate");
if let Some(last) = made.last() {
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
}
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.duplicate",
"Duplicate",
["Photo"],
None,
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
has_selection,
duplicate
),
cmd!(
"photo.reload",
"Reload from Disk",
[],
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}",
has_selection,
reload
),
cmd!(
"photo.editExternal",
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
has_active,
edit_external
),
]
}
+141
View File
@@ -0,0 +1,141 @@
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
//! `bestOfGroup`).
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, cmd};
use crate::{Result, Session};
/// Seconds between shots of the same burst at most.
const BURST_GAP: i64 = 10;
/// Signature similarity for "the same scene".
const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
let mut failed = Vec::new();
for id in ids {
match s.source_now(id, crate::media::SourceLevel::Thumb) {
Ok(src) => {
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
use lightcraft_pipeline::cull;
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
}
Err(e) => failed.push(json!([id.0, e])),
}
}
// bursts: consecutive (by capture time) look-alike shots
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
let mut next = 1u32;
for i in 1..rows.len() {
let (a, b) = (&rows[i - 1], &rows[i]);
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
let g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
});
group_of[i] = Some(g);
}
}
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
for (i, g) in group_of.iter().enumerate() {
if let Some(g) = g {
let e = best.entry(*g).or_insert((i, -1.0));
if rows[i].2 > e.1 {
*e = (i, rows[i].2);
}
}
}
let mut ops = Vec::new();
let (mut rejected, mut picked) = (0, 0);
let mut out = Vec::new();
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
let g = group_of[i];
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
rejected += 1;
} else if pick_best && is_best && flag == Flag::None {
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
picked += 1;
}
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
}
if !ops.is_empty() {
s.commit("Assisted Culling", Op::Batch { ops })?;
}
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.findSimilar";
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
if let Some(v) = s.signatures.get(&key) {
return Some(*v);
}
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let v = lightcraft_pipeline::cull::signature(&src);
s.signatures.insert(key, v);
Some(v)
};
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
for q in all {
if let Some(v) = sig(s, q) {
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
if sim >= min {
hits.push((q, sim));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
s.filter.only = hits.iter().map(|h| h.0).collect();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"library.findSimilar",
"Find Similar Photos",
["Photo"],
None,
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
always,
find_similar
),
cmd!(
"photo.analyze",
"Assisted Culling",
[],
None,
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
always,
analyze
),
]
}
+357
View File
@@ -0,0 +1,357 @@
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
//! exported as small JSON files).
use lightcraft_develop::ToneCurve;
use lightcraft_geom::Point;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
use crate::{Result, Session};
/// Channels `curve.reset` accepts.
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
let d = ToneCurve::default();
match channel {
"all" => *c = d,
"point" => {
c.master.clear();
c.red.clear();
c.green.clear();
c.blue.clear();
}
"parametric" => {
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
}
"master" | "rgb" | "luma" => c.master.clear(),
"red" => c.red.clear(),
"green" => c.green.clear(),
"blue" => c.blue.clear(),
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
}
Ok(())
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.reset";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let channel = str_param(p, "channel").unwrap_or("all");
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
s.set_develop(id, d, &label)?;
Ok(json!({"channel": channel}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"curve.reset",
"Reset Tone Curve",
[],
None,
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
has_active,
reset
),
cmd!(
query "curve.presets",
"Point Curve Presets",
[],
None,
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
always,
|s, _| Ok(list(s))
),
cmd!(
"curve.applyPreset",
"Apply Point Curve Preset",
[],
None,
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
has_active,
apply_preset
),
cmd!(
"curve.savePreset",
"Save Point Curve Preset",
[],
None,
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
has_active,
save_preset
),
cmd!(
"curve.deletePreset",
"Delete Point Curve Preset",
[],
None,
"{name} — a user preset (built-ins can't be deleted)",
always,
delete_preset
),
cmd!(
"curve.importPresets",
"Import Point Curve Presets",
[],
None,
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
always,
import_presets
),
cmd!(
"curve.exportPresets",
"Export Point Curve Presets",
[],
None,
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
always,
export_presets
),
]
}
// ---------------------------------------------------------------- point-curve presets
/// File extension of exported curve presets (JSON).
pub const CURVE_PRESET_EXT: &str = "lccurve";
/// `format` tag of an exported curve-preset file.
const FILE_FORMAT: &str = "lightcraft.curvePresets";
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
/// Built-ins ship with the app; the user's are saved with the library preferences.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CurvePreset {
pub name: String,
pub master: Vec<[f64; 2]>,
pub red: Vec<[f64; 2]>,
pub green: Vec<[f64; 2]>,
pub blue: Vec<[f64; 2]>,
/// Built-in presets can't be deleted or overwritten (never stored).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub builtin: bool,
}
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
let mut out: Vec<[f64; 2]> =
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
out.clear();
}
out
}
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
}
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
}
impl CurvePreset {
/// The point curves of `c` under `name`.
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
CurvePreset {
name: name.into(),
master: from_points(&c.master),
red: from_points(&c.red),
green: from_points(&c.green),
blue: from_points(&c.blue),
builtin: false,
}
}
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
pub fn apply(&self, c: &mut ToneCurve) {
c.master = to_points(&self.master);
c.red = to_points(&self.red);
c.green = to_points(&self.green);
c.blue = to_points(&self.blue);
}
/// Does `c` carry exactly this preset's point curves?
pub fn matches(&self, c: &ToneCurve) -> bool {
let same = |a: &[[f64; 2]], b: &[Point]| {
let (a, b) = (normalize(a), from_points(b));
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
};
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
}
fn is_linear(&self) -> bool {
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
}
}
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
pub fn builtin_presets() -> Vec<CurvePreset> {
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
vec![
s("Linear", Vec::new()),
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
]
}
/// Built-in presets, then the user's.
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
let mut v = builtin_presets();
v.extend(s.curve_presets.iter().cloned());
v
}
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
}
fn is_builtin_name(name: &str) -> bool {
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
}
/// Add or replace (same name, ignoring case) a user preset.
fn upsert(s: &mut Session, p: CurvePreset) {
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
Some(x) => *x = p,
None => s.curve_presets.push(p),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
}
fn list(s: &Session) -> Value {
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
json!({"presets": all_presets(s), "current": current})
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.applyPreset";
let name = name_param(p, C)?;
let preset =
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
preset.apply(&mut d.curve);
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
Ok(json!({"name": preset.name}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.savePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
}
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
if preset.is_linear() {
return Err(bad(C, "the point curve is linear: shape it first"));
}
upsert(s, preset);
s.save_prefs()?;
Ok(list(s))
}
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.deletePreset";
let name = name_param(p, C)?;
if is_builtin_name(&name) {
return Err(bad(C, "built-in presets can't be deleted"));
}
let n = s.curve_presets.len();
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if n == s.curve_presets.len() {
return Err(bad(C, format!("no curve preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
let items = match &v {
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
Value::Array(_) => v.clone(),
Value::Object(_) => Value::Array(vec![v.clone()]),
_ => return Err("not a curve preset file".into()),
};
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
if list.is_empty() {
return Err("no curve presets in the file".into());
}
Ok(list)
}
/// The JSON of a curve-preset file holding `presets`.
pub fn to_file(presets: &[CurvePreset]) -> String {
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
}
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.importPresets";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for f in &paths {
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
Ok(list) => {
for mut preset in list {
preset.name = preset.name.trim().to_string();
preset.builtin = false;
if is_builtin_name(&preset.name) {
preset.name = format!("{} (imported)", preset.name);
}
imported.push(preset.name.clone());
upsert(s, preset);
}
}
Err(e) => failed.push(json!([f, e])),
}
}
if !imported.is_empty() {
s.save_prefs()?;
}
Ok(json!({"imported": imported, "failed": failed}))
}
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "curve.exportPresets";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(CURVE_PRESET_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let chosen: Vec<CurvePreset> = match &names {
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
None => s.curve_presets.clone(),
};
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+110 -2
View File
@@ -193,6 +193,28 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"develop.autoBwMix",
"Auto B&W Mix",
[],
None,
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
has_active,
|s, _| {
let id = active(s, "develop.autoBwMix")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
d.treatment = Treatment::Bw;
let b = &mut d.bw_mix;
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
Ok(())
})?;
Ok(json!({"mix": m}))
}
),
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
let id = active(s, "develop.auto")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
@@ -288,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
}
edit(s, "develop.profile", "Profile", |d| {
@@ -301,7 +323,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
if crate::presets::profile(&id).is_none() {
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
}
let on = s.profile_favorites.contains(&id);
@@ -616,6 +638,92 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.quickAdjust",
"Quick Develop",
[],
None,
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
has_selection,
|s, p| {
let c = "develop.quickAdjust";
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
let delta = f64_req(p, "delta", c)?;
let label = format!("Quick Develop: {}", spec.label);
let ops: Vec<Op> = s
.targets(p)
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
if ctl == "wb.temp" || ctl == "wb.tint" {
nd.wb.mode = WbMode::Custom;
}
s.develop_op(id, nd, &label)
})
.collect();
let n = ops.len();
if n > 0 {
s.commit(&label, Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.matchExposure",
"Match Total Exposures",
["Photo"],
None,
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
has_selection,
|s, p| {
let c = "develop.matchExposure";
let reference = active(s, c)?;
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
let m = &s.catalog.photo(id)?.meta;
let t = match m.shutter.split_once('/') {
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
};
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
};
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
for id in ids {
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
skipped.push(id.0);
continue;
};
let mut nd = (*d).clone();
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
}
let n = ops.len();
if n > 0 {
s.commit("Match Total Exposures", Op::Batch { ops })?;
}
Ok(json!({"changed": n, "skipped": skipped}))
}
),
cmd!(
"develop.autoSync",
"Auto Sync",
["Edit"],
Some("Cmd+Alt+Shift+A"),
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
always,
|s, p| {
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
Ok(json!({"on": s.auto_sync}))
}
),
// ---- presets
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
+71
View File
@@ -0,0 +1,71 @@
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
//! and applied in one step. Saved with the library.
use lightcraft_catalog::Filter;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilterPreset {
pub name: String,
pub filter: Filter,
}
fn list(s: &Session) -> Value {
json!(s.filter_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
if s.filter == Filter::default() {
return Err(bad(C, "the filter is empty: set some filters first"));
}
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
Some(f) => *f = preset,
None => s.filter_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
s.filter = f.filter.clone();
Ok(json!({"photos": s.visible().len()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.filter_presets.len();
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
if n == s.filter_presets.len() {
return Err(bad(C, format!("no filter preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
cmd!(
"filter.savePreset",
"Save Filter Preset",
[],
None,
"{name} — the current filter (library.filter), saved with the library",
always,
save
),
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
]
}
+133 -1
View File
@@ -1,4 +1,5 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under};
use serde_json::{Value, json};
@@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
Ok(json!({"changed": n}))
}
/// A named set of up to nine keywords.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct KeywordSet {
pub name: String,
pub keywords: Vec<String>,
}
/// The set name meaning "the nine most recently added keywords".
pub const RECENT: &str = "Recent Keywords";
/// Remember `added` as the most recent keywords (newest first, nine kept).
pub fn note_recent(s: &mut Session, added: &[String]) {
for k in added.iter().rev() {
let k = clean(k);
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
pub fn current_keywords(s: &Session) -> Vec<String> {
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
v.truncate(9);
v
}
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
pub fn keyword_sets_json(s: &Session) -> Value {
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
)
};
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.saveSet",
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
always,
|s, p| {
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
} else {
current_keywords(s)
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
}
s.keyword_set = Some(name);
s.save_prefs()?;
Ok(keyword_sets_json(s))
}
),
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
let before = s.keyword_sets.len();
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.keyword_sets.len() == before {
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
}
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
s.keyword_set = None;
}
s.save_prefs()?;
Ok(keyword_sets_json(s))
}),
cmd!(
"keyword.toggleFromSet",
"Toggle Keyword from Set",
[],
None,
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
super::has_selection,
|s, p| {
let i = p
.get("index")
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let all = !ids.is_empty()
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
let key = if all { "removeKeywords" } else { "addKeywords" };
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
let recent = s.recent_keywords.clone();
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
if s.keyword_set.is_none() {
s.recent_keywords = recent;
}
r["keyword"] = json!(k);
r["added"] = json!(!all);
Ok(r)
}
),
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
}),
+292 -27
View File
@@ -128,34 +128,43 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---- view source / filter / sort
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
cmd!(
"library.source",
"Show Source",
[],
None,
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
always,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"missing" => LibrarySource::Missing,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
}
LibrarySource::Album(a)
}
LibrarySource::Album(a)
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(json!({"count": vis.len()}))
}
Ok(json!({"count": vis.len()}))
}),
),
cmd!(
"library.filter",
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
@@ -361,16 +370,33 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Info",
[],
None,
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
has_selection,
|s, p| {
let strs =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
Some(Value::String(t)) => Some(Some(
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
)),
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
},
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
};
let mut ops = Vec::new();
for id in targets {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut m = ph.meta.clone();
if let Some(g) = gps {
m.gps = g;
}
for (k, field) in [
("title", &mut m.title),
("caption", &mut m.caption),
@@ -402,6 +428,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
let n = ops.len();
s.commit("Edit Info", Op::Batch { ops })?;
// keywords just added become the Recent Keywords set
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
if n > 0 && !added.is_empty() {
crate::cmd::keywords::note_recent(s, &added);
}
Ok(json!({"changed": n}))
}
),
@@ -418,7 +449,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cover = photos.first().copied();
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
)?;
Ok(json!({"id": id.0}))
}),
@@ -427,7 +458,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Smart Album…",
[],
None,
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
@@ -445,6 +476,27 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
Ok(json!(FIELDS
.iter()
.map(|(id, label, kind)| {
let k = match kind {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Date => "date",
Kind::Choice(_) => "choice",
Kind::Bool => "bool",
};
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
}
v
})
.collect::<Vec<_>>()))
}),
cmd!(
"album.setRules",
"Edit Smart Album",
@@ -504,6 +556,145 @@ pub fn specs() -> Vec<CommandSpec> {
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added}))
}),
cmd!(
"album.toggleTarget",
"Add to Target Album",
[],
None,
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
has_selection,
|s, p| {
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
Some(id) => id,
None => {
let id = s.catalog.alloc_album_id();
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
s.commit("New Quick Collection", Op::AddAlbum { album })?;
id
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
let photos: Vec<PhotoId> = if all_in {
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
s.commit(
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
)?;
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
}
),
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
s.target_album = match p.get("id").and_then(Value::as_u64) {
Some(a) => {
let id = AlbumId(a);
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
}
Some(id)
}
None => None,
};
Ok(json!({"target": s.target_album.map(|a| a.0)}))
}),
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
let Some(id) = s.catalog.quick_collection() else { return ok() };
s.commit(
"Clear Quick Collection",
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
)?;
ok()
}),
cmd!(
"library.autoImport",
"Auto Import Settings",
[],
None,
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
always,
|s, p| {
if let Some(f) = p.get("folder") {
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
None => None,
};
}
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
s.import_defaults.auto_copy = c;
}
if let Some(a) = p.get("album") {
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
}
s.save_prefs()?;
let d = &s.import_defaults;
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
}
),
cmd!(
"library.autoImportScan",
"Auto Import Now",
[],
None,
"{} — 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}",
always,
|s, _| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
// only files that stopped growing: a file still being written is left for later
let known: std::collections::HashSet<String> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => Some(path.clone()),
_ => None,
})
.collect();
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('.')) {
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);
}
}
if fresh.is_empty() {
return Ok(json!({"imported": [], "folder": folder}));
}
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
let mut params = json!({"paths": fresh, "mode": mode});
if let Some(a) = s.import_defaults.auto_album.clone() {
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
Some(al) => params["album"] = json!(al.id.0),
None => params["albumName"] = json!(a),
}
}
for f in &fresh {
s.auto_import_seen.insert(f.clone(), u64::MAX);
}
let sel = s.selection.clone();
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
s.selection = sel;
Ok(json!({"imported": r["imported"], "folder": folder}))
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
@@ -527,7 +718,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Review Import",
[],
None,
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -544,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Photos…",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -568,7 +759,30 @@ pub fn specs() -> Vec<CommandSpec> {
{
return Err(bad("library.import", "album must be a regular album"));
}
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
let 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)")))?
}
None => Default::default(),
};
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),
..Default::default()
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -582,6 +796,8 @@ pub fn specs() -> Vec<CommandSpec> {
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);
}
@@ -653,7 +869,11 @@ pub fn specs() -> Vec<CommandSpec> {
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
return Err(bad(c, problem));
}
Ok(f)
}
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
@@ -697,3 +917,48 @@ impl Session {
fn has_library(s: &Session) -> std::result::Result<(), String> {
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
}
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
let up = s.to_ascii_uppercase();
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let nums: Vec<f64> =
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
let v = match nums.as_slice() {
[d] => *d,
[d, m] => d + m / 60.0,
[d, m, s] => d + m / 60.0 + s / 3600.0,
_ => return None,
};
let _ = pos;
Some(sign * v)
};
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
}
#[cfg(test)]
mod gps_tests {
#[test]
fn coordinates_people_type() {
let p = super::parse_gps;
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the
//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the
//! list lives in the library's prefs and the LUTs are registered with the pipeline when the
//! library opens.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// One imported LUT profile.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LutProfile {
/// `lut:<slug>`.
pub id: String,
pub name: String,
pub group: String,
/// The `.cube` file (the library's copy when there is a library on disk).
pub file: String,
}
fn profiles_dir(s: &Session) -> Option<PathBuf> {
s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles"))
}
/// Register every imported profile's LUT with the pipeline (missing files are skipped).
pub fn register_all(s: &Session) {
for p in &s.lut_profiles {
if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) {
lightcraft_pipeline::lut::register(&p.id, l);
}
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
// (name, text, group)
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let mut stack = vec![path.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let ep = e.path();
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if ep.is_dir() {
stack.push(ep);
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
&& let Ok(t) = std::fs::read_to_string(&ep)
{
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
}
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
if inner.to_ascii_lowercase().ends_with(".cube") {
let ip = Path::new(&inner);
let group = ip
.parent()
.and_then(|d| d.file_name())
.map(|n| n.to_string_lossy().to_string())
.or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string()));
out.push((
ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
String::from_utf8_lossy(&data).to_string(),
group,
));
}
}
} else if let Ok(t) = std::fs::read_to_string(path) {
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
}
}
out
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "profile.import";
let paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if paths.is_empty() {
return Err(bad(C, "no paths"));
}
let dir = profiles_dir(s);
if let Some(d) = &dir {
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for (name, text, group) in cube_files(&paths) {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
failed.push(json!([name, e]));
continue;
}
};
let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone());
let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into());
let slug = crate::presets::slug(&format!("{group}-{display}"));
let mut id = format!("lut:{slug}");
let mut n = 2;
while s.lut_profiles.iter().any(|x| x.id == id) {
id = format!("lut:{slug}-{n}");
n += 1;
}
let file = match &dir {
Some(d) => {
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
f.to_string_lossy().to_string()
}
None => {
// no library on disk: remember the original (the in-memory session ends with the app)
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
f.to_string_lossy().to_string()
}
};
lightcraft_pipeline::lut::register(&id, lut);
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
Ok(json!({"imported": imported, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"profile.import",
"Import Profiles",
[],
None,
"{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}",
always,
import
),
cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string();
let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else {
return Err(bad("profile.deleteImported", format!("no imported profile `{id}`")));
};
let gone = s.lut_profiles.remove(i);
if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) {
let _ = std::fs::remove_file(&gone.file);
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
Ok(json!({"deleted": id}))
}),
]
}
+93 -1
View File
@@ -1,4 +1,4 @@
//! Photo management commands: batch rename, capture time, colour label names.
//! Photo management commands: batch rename, capture time, colour label names and sets.
use serde_json::{Value, json};
@@ -13,6 +13,52 @@ fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft
Ok((s.targets(p), template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LabelSet {
pub name: String,
pub names: [String; 5],
}
/// Our built-in sets.
pub fn builtin_label_sets() -> Vec<LabelSet> {
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
}
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
}
/// The set whose names are the catalog's current ones.
fn current_label_set(s: &crate::Session) -> Option<String> {
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
all_label_sets(s)
.into_iter()
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
.map(|x| x.name)
}
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
pub fn label_sets_json(s: &crate::Session) -> Value {
let builtin = builtin_label_sets().len();
let sets: Vec<Value> =
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
json!({"sets": sets, "current": current_label_set(s)})
}
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
ColorLabel::ALL
.iter()
.zip(names)
.filter_map(|(l, n)| {
let n = n.trim();
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
})
.collect()
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
@@ -112,5 +158,51 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": n}))
}
),
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
let set = all_label_sets(s)
.into_iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
let ops = set_names_ops(s, &set.names);
let n = ops.len();
if n > 0 {
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}),
cmd!(
"label.saveSet",
"Save Color Label Set",
[],
None,
"{name} — save the current label names as a set (replaces a user set of that name)",
always,
|s, p| {
let name =
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
}
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.label_sets.push(set),
}
s.save_prefs()?;
Ok(json!({"name": name}))
}
),
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
let before = s.label_sets.len();
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
if s.label_sets.len() == before {
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
}
s.save_prefs()?;
Ok(json!({"deleted": name}))
}),
]
}
+151 -4
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
..Default::default()
});
*active = Some(next);
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mid = mask_id(p, active, "mask.addComponent")?;
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.addComponent")?;
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
Ok(())
})
}
@@ -144,12 +144,17 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let m = &mut masks[i];
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
} else {
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
m.components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Brush { strokes: vec![stroke] },
});
}
Ok(())
})
@@ -167,6 +172,64 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!(
"mask.sampleColor",
"Sample Mask Color",
[],
None,
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
has_active,
|s, p| {
let c = "mask.sampleColor";
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
let k = comp
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
return Err(bad(c, "not a colour range component"));
};
if !add {
samples.clear();
}
if samples.len() < 5 {
samples.push(lab);
}
out = samples.clone();
Ok(())
})?;
Ok(json!({"samples": out}))
}
),
cmd!(
"mask.refine",
"Refine Mask Edges",
[],
None,
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
has_active,
|s, p| {
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
let mid = mask_id(p, s.active_mask, "mask.refine")?;
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
let i = find(masks, mid, "mask.refine")?;
masks[i].refine = v;
Ok(())
})
}
),
cmd!(
"mask.adjust",
"Set Mask Adjustments",
@@ -258,6 +321,54 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"mask.component",
"Edit Mask Component",
[],
None,
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
has_active,
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option<MaskOp> = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
let label = match action.as_str() {
"invert" => "Invert Component",
"duplicate" => "Duplicate Component",
"delete" => "Delete Component",
"rename" => "Rename Component",
"op" => "Change Component Mode",
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
};
masks_edit(s, c, label, |masks, active| {
let i = find(masks, mid, c)?;
let comps = &mut masks[i].components;
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
match action.as_str() {
"invert" => comp.invert = !comp.invert,
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
"duplicate" => {
let copy = comp.clone();
comps.insert(k + 1, copy);
}
_ => {
comps.remove(k);
if comps.is_empty() {
masks.remove(i);
*active = masks.last().map(|m| m.id);
}
}
}
Ok(())
})
}
),
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.move")?;
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
@@ -279,6 +390,42 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
// ---- Remove tool (spots)
cmd!(
"spot.findDust",
"Find Dust Spots",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
has_active,
|s, p| {
let c = "spot.findDust";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for d in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(d.x, d.y)],
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Dust Spots")?;
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
+154
View File
@@ -0,0 +1,154 @@
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
use lightcraft_catalog::Meta;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// The fields a metadata preset can hold (photo.setMeta names).
pub const FIELDS: &[&str] = &["title", "caption", "copyright", "creator", "location", "city", "state", "country", "keywords"];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetadataPreset {
pub name: String,
/// photo.setMeta fields; `keywords` are added, not replaced.
pub fields: Value,
}
fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
Some(match k {
"title" => &mut m.title,
"caption" => &mut m.caption,
"copyright" => &mut m.copyright,
"creator" => &mut m.creator,
"location" => &mut m.location,
"city" => &mut m.city,
"state" => &mut m.state,
"country" => &mut m.country,
_ => return None,
})
}
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
pub fn apply_to(m: &mut Meta, fields: &Value) {
for (k, v) in fields.as_object().into_iter().flatten() {
if k == "keywords" {
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
m.keywords.push(kw.to_string());
}
}
} else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) {
*f = t.to_string();
}
}
}
fn list(s: &Session) -> Value {
json!(s.metadata_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let fields = match p.get("fields") {
Some(Value::Object(o)) => {
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
}
Value::Object(o.clone())
}
Some(_) => return Err(bad(C, "`fields` must be an object")),
// from the active photo: its non-empty fields among `only` (default: copyright, creator, place)
None => {
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
let only: Vec<String> = p
.get("only")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_else(|| ["copyright", "creator", "location", "city", "state", "country"].map(String::from).to_vec());
let mut o = Map::new();
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
if k == "keywords" {
if !m.keywords.is_empty() {
o.insert(k.clone(), json!(m.keywords));
}
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
o.insert(k.clone(), json!(v.clone()));
}
}
Value::Object(o)
}
};
let preset = MetadataPreset { name: name.clone(), fields };
match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) {
Some(m) => *m = preset,
None => s.metadata_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.metadata_presets.len();
s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name));
if s.metadata_presets.len() == n {
return Err(bad(C, format!("no metadata preset `{name}`")));
}
if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) {
s.import_defaults.metadata_preset = None;
}
s.save_prefs()?;
Ok(list(s))
}
/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let preset = s
.metadata_presets
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.cloned()
.ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?;
let mut params = Map::new();
for (k, v) in preset.fields.as_object().into_iter().flatten() {
params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone());
}
if let Some(ids) = p.get("ids") {
params.insert("ids".into(), ids.clone());
}
s.execute("photo.setMeta", &Value::Object(params))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))),
cmd!(
"metadata.savePreset",
"Save Metadata Preset",
[],
None,
"{name, fields?: {copyright?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default copyright, creator, place)} — adds or replaces",
always,
save
),
cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete),
cmd!(
"metadata.applyPreset",
"Apply Metadata Preset",
[],
None,
"{name, ids?} — text fields replace, keywords are added; one undo step",
always,
apply
),
]
}
+10 -2
View File
@@ -27,7 +27,7 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
fn relink_op(id: PhotoId, path: &str) -> Op {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
@@ -42,7 +42,15 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "only photos from files can be relinked"));
}
let abs = abs.to_string_lossy().to_string();
s.commit("Relink Photo", relink_op(id, &abs))?;
// the file may differ from the one imported (another export, a raw that decodes now or not):
// its size, dimensions and preview-only state follow it
let mut ops = vec![relink_op(id, &abs)];
if s.media.file_probe.is_some()
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
{
ops.extend(crate::cmd::convert::content_op(id, ph, info));
}
s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?;
s.media.forget(id);
Ok(json!({"id": id.0, "path": abs}))
}
+18 -3
View File
@@ -7,18 +7,25 @@
mod before;
mod browse;
mod color;
pub(crate) mod convert;
mod cull;
pub mod curves;
mod develop;
mod edit;
mod export;
mod keywords;
pub mod filters;
pub mod keywords;
mod library;
mod manage;
pub mod lut_profiles;
pub mod manage;
mod masks;
mod merge;
mod missing;
pub mod metadata;
pub mod missing;
mod organize;
mod prefs;
mod preset_files;
pub mod previews;
mod query;
mod xmp;
@@ -112,10 +119,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(library::specs());
v.extend(develop::specs());
v.extend(color::specs());
v.extend(curves::specs());
v.extend(masks::specs());
v.extend(organize::specs());
v.extend(keywords::specs());
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());
@@ -125,6 +138,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v
})
}
+30 -6
View File
@@ -4,7 +4,7 @@
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
use crate::{Result, Selection, Session};
/// Stacks containing any of `targets`, in first-seen order.
@@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 {
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
/// copy is visible).
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
/// copy is visible). `name` replaces the default "Copy N".
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
let master = orig.copy_of.unwrap_or(src);
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
@@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
let mut c = (*orig).clone();
c.id = id;
c.copy_of = Some(master);
c.copy_name = Some(format!("Copy {n}"));
c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string));
c.versions.clear();
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
c.deleted = false;
@@ -108,13 +108,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Virtual Copy",
["Photo"],
Some("Cmd+'"),
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let mut made = Vec::new();
for t in targets {
if let Some(id) = virtual_copy(s, t)? {
if let Some(id) = virtual_copy(s, t, name.as_deref())? {
made.push(id);
}
}
@@ -164,6 +165,29 @@ pub fn specs() -> Vec<CommandSpec> {
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, -1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
let op = s.catalog.move_in_stack_ops(id, 1);
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
}),
cmd!(
"stack.split",
"Split Stack",
["Photo", "Stack"],
None,
"{id?} — this photo and the ones after it become their own stack",
in_stack,
|s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
}
),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p));
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
+11
View File
@@ -48,6 +48,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = i.get("creator").and_then(Value::as_str) {
d.creator = v.trim().to_string();
}
match i.get("metadataPreset") {
Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None,
Some(Value::String(n)) => {
if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) {
return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)")));
}
d.metadata_preset = Some(n.clone());
}
Some(Value::Null) => d.metadata_preset = None,
_ => {}
}
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
d.cameras.clear();
for c in a {
+28 -14
View File
@@ -1,9 +1,10 @@
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
use crate::{Result, Session};
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
@@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let mut imported = Vec::new();
let mut failed = Vec::new();
let mut skipped = 0usize;
for f in expand_preset_paths(&paths) {
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
match parsed {
Ok(list) => {
let n = list.len();
let ids = s.add_presets(list);
skipped += n - ids.len();
for id in ids {
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
for top in &paths {
let from_dir = std::path::Path::new(top).is_dir();
for f in expand_preset_paths(std::slice::from_ref(top)) {
// presets in a folder go to a group named after it (unless the file names its own)
// the path from the imported folder (inclusive) to the file's folder
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
match parsed {
Ok(list) => {
for mut it in list {
if let Some(g) = &group {
it.preset.group = g.clone();
}
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
match id {
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
None => skipped += 1,
}
}
}
Err(e) => failed.push(json!([f, e])),
}
Err(e) => failed.push(json!([f, e])),
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
@@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], skipped, failed}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
always,
import
),
+184
View File
@@ -0,0 +1,184 @@
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
//! with `wait` (CLI, MCP, tests).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::media::{RenderJob, THUMB_SIZES};
use crate::{Result, Session};
/// The long edge of a standard-sized preview.
pub const STANDARD_EDGE: usize = 2048;
/// A preview build in progress.
#[derive(Debug, Default)]
pub struct PreviewBuild {
pub total: usize,
pub done: AtomicUsize,
pub failed: AtomicUsize,
pub cancel: AtomicBool,
pub finished: AtomicBool,
}
impl PreviewBuild {
pub fn json(&self) -> Value {
json!({
"total": self.total,
"done": self.done.load(Ordering::Relaxed),
"failed": self.failed.load(Ordering::Relaxed),
"running": !self.finished.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
})
}
}
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
/// `edge` (`None` = 1:1, the photo's full size).
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
let full = p.width.max(p.height) as usize;
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
let mut v = Vec::new();
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
v.extend(s.loupe_job(id, e, e, true));
v
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
for photo_jobs in jobs {
if state.cancel.load(Ordering::Relaxed) {
break;
}
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
}
state.done.fetch_add(1, Ordering::Relaxed);
}
state.finished.store(true, Ordering::Relaxed);
}
fn build(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.buildPreviews";
let edge = match str_param(p, "size").unwrap_or("standard") {
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
"full" | "1:1" => None,
other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
};
if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
// explicit ids, else the selection, else everything in view
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
if wait {
run_all(jobs, &state);
return Ok(state.json());
}
#[cfg(not(target_arch = "wasm32"))]
{
let st = state.clone();
std::thread::Builder::new()
.name("lc-build-previews".into())
.spawn(move || run_all(jobs, &st))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(state.json())
}
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
/// photos editable while their originals are offline.
#[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 removed, mut failed) = (0usize, 0usize, Vec::new());
if !discard {
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
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;
}
if path.exists() {
built += 1;
continue;
}
let r = s
.source_now(id, crate::media::SourceLevel::Preview)
.and_then(|src| crate::smart::encode(&src))
.and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string()));
match r {
Ok(()) => built += 1,
Err(e) => failed.push(json!([id.0, e])),
}
}
Ok(json!({"built": built, "removed": removed, "failed": failed}))
}
/// Whether photo `id` has a smart preview.
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
match (&s.media.smart_dir, s.catalog.photo(id)) {
(Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(),
_ => false,
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.smartPreviews",
"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 → {built, removed, failed}",
always,
smart
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
_ => true,
};
Ok(json!({"smartPreview": has_smart_preview(s, id), "originalOnline": online}))
}),
cmd!(
"library.buildPreviews",
"Build Previews",
[],
None,
"{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}",
always,
build
),
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
if let Some(b) = &s.preview_build {
b.cancel.store(true, Ordering::Relaxed);
}
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
}),
]
}
+25
View File
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
"deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name,
// an undecodable raw variant, shown and edited from its embedded JPEG: why
"previewOnly": p.preview_only,
})
}
@@ -95,6 +97,29 @@ pub fn specs() -> Vec<CommandSpec> {
"clipboard": s.clipboard.is_some(),
}))
}),
cmd!(query "photo.allMetadata", "All Metadata", [], None, "{id?} → {exif: [{group, tag, name, value}], xmp: [{name, value}]} — every EXIF / TIFF / GPS tag of the file and its XMP properties", always, |s, p| {
let id = photo_arg(s, p, "photo.allMetadata")?;
let ph = s.catalog.photo(id).ok_or_else(|| super::bad("photo.allMetadata", "no such photo"))?.clone();
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Ok(json!({"exif": [], "xmp": [], "note": "a generated demo photo has no file"}));
};
let bytes = match &s.media.file_bytes {
Some(r) => r(path),
None => std::fs::read(path).map_err(|e| format!("{path}: {e}")),
}
.map_err(|e| super::bad("photo.allMetadata", e))?;
let exif: Vec<Value> = lightcraft_meta::file_tag_rows(&bytes)
.into_iter()
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
.collect();
// XMP: the sidecar if there is one, else the file's own packet
let packet = crate::sidecar::read_packet(path, ph.kind, s.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
let xmp: Vec<Value> = packet
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
.unwrap_or_default();
Ok(json!({"exif": exif, "xmp": xmp}))
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
+78
View File
@@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
}
thread_local! {
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
}
fn note(k: &str) {
READ.with(|r| {
if let Some(s) = r.borrow_mut().as_mut() {
s.insert(k.to_string());
}
});
}
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
note(k);
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
}
@@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
fn curve(props: &Props, k: &str) -> Option<Value> {
note(k);
let items = props.get(k)?;
let mut pts = Vec::new();
for it in items {
@@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "Shadows2012", "light.shadows");
n(o, "Whites2012", "light.whites");
n(o, "Blacks2012", "light.blacks");
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
// presets written by newer versions keep the old fields at their defaults: ignore them there
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
let v = num(props, &format!("crs:{crs}"));
if !pv2012
&& !has(o, path)
&& let Some(v) = v
{
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
}
};
old(o, "Exposure", "light.exposure", &|v| v);
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
old(o, "FillLight", "light.shadows", &|v| v);
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
// ---- Presence
n(o, "Texture", "effects.texture");
n(o, "Clarity2012", "effects.clarity");
old(o, "Clarity", "effects.clarity", &|v| v);
n(o, "Dehaze", "effects.dehaze");
n(o, "Vibrance", "color.vibrance");
n(o, "Saturation", "color.saturation");
@@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
n(o, "ParametricMidtoneSplit", "curve.split_mid");
n(o, "ParametricHighlightSplit", "curve.split_highlights");
n(o, "CurveRefineSaturation", "curve.refine_saturation");
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
// the older single curve, used when no 2012 curve is present
if !props.contains_key("crs:ToneCurvePV2012") {
put(o, "curve.master", c);
}
}
for (crs, ch) in
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
{
@@ -310,6 +352,42 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
out
}
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
///
/// With `values` (the packet's structured properties), local corrections become masks too
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
/// can't be carried over are reported as `Mask: <kind>`.
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
let mut out = to_partial(props, raw);
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut mask_skips = Vec::new();
if let Some(values) = values {
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
if !masks.is_empty() {
put(&mut out, "masks", Value::Array(masks));
}
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
let mut unmapped: Vec<String> = props
.keys()
.filter(|k| k.starts_with("crs:"))
.map(|k| k.split('/').next().unwrap_or(k))
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
.map(|k| k.trim_start_matches("crs:").to_string())
.filter(|k| !quiet(k))
.collect();
unmapped.dedup();
unmapped.extend(mask_skips);
(out, unmapped)
}
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
/// Returns `None` when the packet has no adjustments we understand.
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
+379
View File
@@ -0,0 +1,379 @@
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
//!
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
//!
//! Implemented from the public XMP data model and black-box observation; best effort — the
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
//! fields evidently mean. Unsupported components are reported, never guessed.
use std::collections::BTreeMap;
use lightcraft_meta::XmpValue;
use serde_json::{Value, json};
/// Top-level properties keyed `prefix:name`, with structure.
pub type Values = BTreeMap<String, XmpValue>;
/// The containers this module reads.
pub const CONTAINERS: [&str; 4] =
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
}
fn num(v: &XmpValue, k: &str) -> Option<f64> {
let s = text(v, k)?;
let s = s.strip_prefix('+').unwrap_or(s);
s.parse::<f64>().ok().filter(|x| x.is_finite())
}
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
}
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
const LOCAL: &[(&str, &str, f64)] = &[
("LocalExposure2012", "exposure", 4.0),
("LocalContrast2012", "contrast", 100.0),
("LocalHighlights2012", "highlights", 100.0),
("LocalShadows2012", "shadows", 100.0),
("LocalWhites2012", "whites", 100.0),
("LocalBlacks2012", "blacks", 100.0),
("LocalClarity2012", "clarity", 100.0),
("LocalTexture", "texture", 100.0),
("LocalDehaze", "dehaze", 100.0),
("LocalTemperature", "temp", 100.0),
("LocalTint", "tint", 100.0),
("LocalSaturation", "saturation", 100.0),
("LocalHue", "hue", 100.0),
("LocalSharpness", "sharpness", 100.0),
("LocalLuminanceNoise", "noise", 100.0),
("LocalMoire", "moire", 100.0),
("LocalDefringe", "defringe", 100.0),
("LocalToningSaturation", "color_sat", 100.0),
("LocalToningHue", "color_hue", 1.0),
// older process versions
("LocalExposure", "exposure", 4.0),
("LocalContrast", "contrast", 100.0),
("LocalClarity", "clarity", 100.0),
];
fn adjust(c: &XmpValue) -> Value {
let mut a = serde_json::Map::new();
for (crs, ours, k) in LOCAL {
if a.contains_key(*ours) {
continue;
}
if let Some(v) = num(c, &format!("crs:{crs}")) {
let lim = if *ours == "exposure" {
4.0
} else if *ours == "color_hue" {
360.0
} else {
100.0
};
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
a.insert((*ours).into(), json!(v));
}
}
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
Value::Object(a)
}
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
let y = l.clamp(1e-4, 1.0).powf(2.2);
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
}
fn pt(x: f64, y: f64) -> Value {
json!({"x": x, "y": y})
}
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
let what = text(m, "crs:What").unwrap_or("");
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
1 => "subtract",
2 => "intersect",
_ => "add",
};
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
// positions are fractions of the image's width / height; our radii are in long-edge units
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
let shape = match what {
"Mask/Gradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
}
"Mask/CircularGradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
json!({
"kind": "radial",
"center": pt((l + r) / 2.0, (t + b) / 2.0),
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
"angle": g("Angle", 0.0),
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
"invert": flag(m, "crs:Flipped").unwrap_or(false),
})
}
"Mask/Paint" => {
let pts: Vec<Value> = m
.field("crs:Dabs")
.map(XmpValue::items)
.unwrap_or_default()
.iter()
.filter_map(XmpValue::text)
.filter_map(|d| {
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
Some(pt(it.next()?, it.next()?))
})
.collect();
if pts.is_empty() {
return Err("empty brush".into());
}
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
json!({"kind": "brush", "strokes": [{
"points": pts,
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
"erase": erase,
}]})
}
"Mask/Image" => match text(m, "crs:MaskSubType") {
Some("1") => json!({"kind": "subject"}),
Some("2") => json!({"kind": "sky"}),
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
},
"Mask/RangeMask" => {
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
match text(r, "crs:Type") {
Some("2") => {
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
}
Some("3") => {
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
}
_ => return Err("colour range".into()),
}
}
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
};
Ok(json!({"op": op, "invert": invert, "shape": shape}))
}
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut skipped = Vec::new();
for container in CONTAINERS {
let Some(list) = values.get(container) else { continue };
for c in list.items() {
if flag(c, "crs:CorrectionActive") == Some(false) {
continue;
}
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
let mut parts = Vec::new();
for m in comps {
match component(m, aspect) {
Ok(v) => parts.push(v),
Err(kind) => skipped.push(kind),
}
}
if parts.is_empty() {
continue;
}
// brush dabs of one correction are one brush component
let mut merged: Vec<Value> = Vec::new();
for p in parts {
let is_brush = p["shape"]["kind"] == "brush";
if is_brush
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
{
a.extend(b.iter().cloned());
continue;
}
merged.push(p);
}
let n = out.len() + 1;
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
}
}
skipped.sort();
skipped.dedup();
(out, skipped)
}
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
use crate::preset_import::Lua;
match v {
Lua::Nil => XmpValue::Text(String::new()),
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
Lua::Num(n) => XmpValue::Text(format!("{n}")),
Lua::Str(s) => XmpValue::Text(s.clone()),
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
}
}
/// The aspect radial masks are computed for when the target's shape isn't known yet.
pub const DEFAULT_ASPECT: f64 = 1.5;
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
for m in masks {
for c in m["components"].as_array_mut().into_iter().flatten() {
let s = &mut c["shape"];
if s["kind"] == "radial" {
if let Some(rx) = s["rx"].as_f64() {
s["rx"] = json!(rx / fx * tx);
}
if let Some(ry) = s["ry"].as_f64() {
s["ry"] = json!(ry / fy * ty);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
}
+2 -2
View File
@@ -62,7 +62,7 @@ pub fn load(s: &mut Session) {
}
let folder = s.catalog.alloc_album_id();
ops.push(Op::AddAlbum {
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None },
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
});
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
for (name, parent, photos) in [
@@ -75,7 +75,7 @@ pub fn load(s: &mut Session) {
] {
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } });
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } });
}
for op in ops {
let _ = s.catalog.apply(op);
+145 -10
View File
@@ -213,11 +213,29 @@ pub struct Watermark {
pub color: [u8; 3],
/// Soft dark drop shadow for legibility on bright areas.
pub shadow: bool,
/// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text).
pub image: String,
/// The graphic's width as a fraction of the photo's width.
pub image_width: f32,
/// The colour space the photo is in (set by the encoder; the graphic is converted into it).
#[serde(skip)]
pub target: Option<OutputSpace>,
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
Self {
text: String::new(),
size: 0.035,
opacity: 0.7,
anchor: Anchor::BottomRight,
inset: 0.025,
color: [255; 3],
shadow: true,
image: String::new(),
image_width: 0.2,
target: None,
}
}
}
@@ -261,6 +279,10 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
/// 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};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
}
let text = wm.text.trim();
if text.is_empty() || width == 0 || height == 0 {
return;
@@ -315,6 +337,74 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
}
}
/// A watermark graphic, decoded once per file (and again when the file changes).
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let map = c.get_or_insert_with(HashMap::new);
if let Some((m, img)) = map.get(path)
&& *m == modified
{
return Some(img.clone());
}
let bytes = std::fs::read(path).ok()?;
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
map.insert(path.to_string(), (modified, img.clone()));
Some(img)
}
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
/// `image_width` of the photo, placed like the text, blended with its alpha × opacity.
fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
let Some(logo) = watermark_logo(wm.image.trim()) else {
log::warn!("watermark: can't read {}", wm.image);
return;
};
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
return;
}
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
let p = logo.get(x, y);
let a = p[3] as f32 / 255.0;
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
});
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32;
let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
Top | Center | Bottom => (w - lwf) / 2.0,
TopRight | Right | BottomRight => w - inset - lwf,
}
.max(0.0) as usize;
let oy = match wm.anchor {
TopLeft | Top | TopRight => inset,
Left | Center | Right => (h - lhf) / 2.0,
BottomLeft | Bottom | BottomRight => h - inset - lhf,
}
.max(0.0) as usize;
let space = wm.target.unwrap_or(OutputSpace::Srgb);
let op = wm.opacity.clamp(0.0, 1.0);
for y in 0..lh {
for x in 0..lw {
let [r, g, b, a] = scaled.get(x, y);
let a = a.clamp(0.0, 1.0);
let (px, py) = (ox + x, oy + y);
if a <= 1e-4 || px >= width || py >= height {
continue;
}
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
blend_px(px, py, a * op, srgb8_in(space, c));
}
}
}
/// Which metadata is embedded in exported files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -355,6 +445,8 @@ pub struct ExportOptions {
pub conflict: Conflict,
/// TIFF compression.
pub tiff_compression: TiffCompression,
/// DNG export: how the raw data is stored.
pub dng_compression: DngCompression,
pub metadata: MetadataPolicy,
/// Strip GPS / location even when the policy would include it.
pub remove_location: bool,
@@ -382,6 +474,7 @@ impl Default for ExportOptions {
subfolder: String::new(),
conflict: Conflict::Unique,
tiff_compression: TiffCompression::Deflate,
dng_compression: DngCompression::Lossless,
metadata: MetadataPolicy::All,
remove_location: false,
watermark: None,
@@ -423,6 +516,7 @@ impl ExportOptions {
Some("zip" | "deflate") => TiffCompression::Deflate,
_ => d.tiff_compression,
},
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
metadata: enm(p, "metadata").unwrap_or(d.metadata),
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
watermark: match p.get("watermark") {
@@ -430,7 +524,7 @@ impl ExportOptions {
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
}
@@ -648,7 +742,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(named_space(space));
@@ -732,7 +826,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
let mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
let profile = match img.samples {
@@ -854,7 +948,7 @@ enum Work {
path: String,
read: Option<crate::merge::ByteReader>,
packet: String,
dng: bool,
dng: Option<DngCompression>,
label: String,
size: (usize, usize),
},
@@ -881,8 +975,8 @@ pub fn prepare_export(
Work::File {
path: path.clone(),
read: session.media.file_bytes.clone(),
packet: crate::sidecar::sidecar_packet(p),
dng: o.format == ExportFormat::Dng,
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
label: p.file_name.clone(),
size: (p.width as usize, p.height as usize),
}
@@ -905,15 +999,20 @@ impl PreparedExport {
Some(r) => r(&path)?,
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
};
if !dng {
let Some(dng) = dng else {
return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] });
}
};
if lightcraft_raw::probe(&bytes).is_none() {
return Err(format!("{label}: DNG export needs a raw photo"));
}
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() })
let compression = match dng {
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
};
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
.map_err(|e| e.to_string())?;
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() })
}
@@ -921,6 +1020,18 @@ impl PreparedExport {
}
}
/// How an exported DNG stores its raw data.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DngCompression {
/// Lossless JPEG (the usual DNG compression).
#[default]
Lossless,
/// Deflate (zip) tiles with prediction.
Deflate,
Uncompressed,
}
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
/// applied), or one exact file path (single photo; overwritten).
#[derive(Clone, Debug, Default)]
@@ -1108,6 +1219,30 @@ mod tests {
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[test]
fn graphic_watermark_is_placed_scaled_and_blended() {
let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logo_path = dir.join("logo.png");
// 40×20: left half opaque red, right half fully transparent
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] });
let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap();
std::fs::write(&logo_path, png).unwrap();
let mut img = Rgba8::new(400, 300);
img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]);
let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() };
draw_watermark(&mut img, &wm);
// 100 px wide × 50 px high in the bottom-right corner
assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280));
assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo");
assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo");
assert_eq!(img.get(10, 10), [0, 0, 0, 255]);
// an image-only watermark survives the params (no text needed)
let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}}));
assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn naming_and_params() {
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
+59
View File
@@ -114,6 +114,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb: None,
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: Some(why),
..Default::default()
});
}
@@ -141,6 +142,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb,
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: None,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@@ -170,6 +172,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
content_hash,
embedded_lens: None,
xmp: None,
preview_only: None,
})
}
@@ -396,6 +399,62 @@ mod tests {
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
}
/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's
/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a
/// library reopen, and Reload / Relink clear it once the file decodes (undoably).
#[test]
fn unsupported_raw_imports_as_preview_only_and_survives_reopen() {
use serde_json::json;
let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (lib, files) = (dir.join("lib"), dir.join("files"));
std::fs::create_dir_all(&files).unwrap();
let path = files.join("DSC_0001.cr3");
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// the import review already says so
let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap();
assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}");
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
let why = p.preview_only.clone().expect("marked preview only");
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
assert!(!p.develops_raw());
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
assert_eq!(crate::media::source_info(&p), SourceInfo::default());
assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default());
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
// agents see it
let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap();
assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}");
let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap();
assert_eq!(i["preview_only"], json!(why), "{i}");
// a library reopen keeps it
s.persist().unwrap();
drop(s);
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
assert!(s.catalog.photo(id).unwrap().develops_raw());
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
// relinking to a file that decodes clears it too
let dng = files.join("DSC_0001.dng");
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only, None);
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+214 -43
View File
@@ -52,6 +52,41 @@ pub struct ImportOptions {
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
/// same content as one already known is still listed.
pub local: bool,
/// Copy: where the copies go (default: the library's `Originals/`).
pub destination: Option<String>,
/// Copy: the folders inside the destination.
pub organize: Organize,
/// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`,
/// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`.
pub rename: Option<String>,
pub rename_start: usize,
/// A metadata preset (by name) applied to every imported photo.
pub metadata_preset: Option<String>,
/// Copy: raws are copied as DNG (Copy as DNG).
pub convert_dng: bool,
}
/// How copies are filed in the destination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Organize {
/// `YYYY/YYYY-MM-DD/` by capture date.
#[default]
ByDay,
/// `YYYY/YYYY-MM/`.
ByMonth,
/// All in the destination itself.
Flat,
}
impl Organize {
pub fn parse(s: &str) -> Option<Organize> {
match s {
"date" | "day" | "byDay" => Some(Organize::ByDay),
"month" | "byMonth" => Some(Organize::ByMonth),
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
_ => None,
}
}
}
/// A file found by [`scan`], for the import review.
@@ -73,6 +108,9 @@ pub struct ImportCandidate {
pub existing: Option<u64>,
/// Not readable.
pub error: Option<String>,
/// A raw variant that can't be decoded yet (why): it imports as preview only.
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -127,6 +165,14 @@ pub struct ImportDefaults {
pub copyright: String,
/// Creator given to imported photos that don't name one (empty = none).
pub creator: String,
/// Metadata preset applied to every imported photo (`metadata.*`; `None` = none).
pub metadata_preset: Option<String>,
/// Auto import: a watched folder whose new photos are added as they arrive (`None` = off),
/// copied into the library's Originals instead of added in place when `auto_copy`, and an
/// album they go to.
pub auto_folder: Option<String>,
pub auto_copy: bool,
pub auto_album: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -164,8 +210,12 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() {
p.meta.creator = d.creator.trim().to_string();
}
if let Some(mp) = d.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);
}
let base = p.camera_defaults();
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
// a raw shown from its embedded JPEG gets the rendered-file default, not the raw one
let raw = p.develops_raw();
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
match preset {
Some(pr) => {
@@ -213,8 +263,14 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
out
}
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let Some(probe) = s.media.file_probe.clone() else {
/// Probe files (headers + content hash) as an import would.
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
}
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
use std::sync::atomic::Ordering::Relaxed;
let Some(probe) = probe.cloned() else {
return paths
.iter()
.map(|p| {
@@ -240,7 +296,11 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
if i >= paths.len() {
break;
}
if progress.cancel.load(Relaxed) {
break;
}
let r = probe(&paths[i]);
progress.done.fetch_add(1, Relaxed);
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
}
});
@@ -249,16 +309,33 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
}
}
paths.iter().map(|p| probe(p)).collect()
paths
.iter()
.map(|p| {
if progress.cancel.load(Relaxed) {
return Err("cancelled".into());
}
let r = probe(p);
progress.done.fetch_add(1, Relaxed);
r
})
.collect()
}
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
/// made unique with -1, -2…; returns the new path.
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = lib.join("Originals").join(year).join(day);
let dir = match organize {
Organize::ByDay => root.join(year).join(day),
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
Organize::Flat => root.to_path_buf(),
};
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
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()));
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
None => (name.clone(), String::new()),
@@ -273,57 +350,112 @@ fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String
Ok(dst.to_string_lossy().to_string())
}
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
/// share can take minutes to read).
pub struct ScanInput {
probe: Option<crate::media::FileProbe>,
skip: Option<PathBuf>,
/// path → (photo, its summary) for files already in the library.
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
by_hash: HashMap<String, PhotoId>,
cache: HashMap<String, ProbeInfo>,
}
/// Progress and cancellation of a running [`scan_with`].
#[derive(Default)]
pub struct ScanProgress {
/// Files to probe, set once the folders are expanded.
pub total: std::sync::atomic::AtomicUsize,
pub done: std::sync::atomic::AtomicUsize,
pub cancel: std::sync::atomic::AtomicBool,
}
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
pub struct ScanOutput {
pub candidates: Vec<ImportCandidate>,
pub probes: HashMap<String, ProbeInfo>,
}
impl ScanInput {
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
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 {
let c = ImportCandidate {
path: path.clone(),
format: p.format.clone(),
kind: p.kind,
width: p.width,
height: p.height,
file_size: p.file_size,
captured: p.captured.clone(),
duplicate: Some("path".into()),
existing: Some(p.id.0),
..Default::default()
};
by_path.insert(path.clone(), (p.id, c));
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
(input, paths.to_vec())
}
}
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
/// against the library and earlier candidates). Nothing is added; the probes are kept for the
/// import that follows.
pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
let files = expand(paths, lib_dir.as_deref());
let mut by_path: HashMap<String, 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.clone(), p.id);
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(f.as_str())).cloned().collect();
let (input, paths) = ScanInput::new(s, paths);
let out = scan_with(input, &paths, &ScanProgress::default());
s.import_probes = out.probes;
out.candidates
}
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
/// has) when `progress.cancel` is set.
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let files = expand(paths, input.skip.as_deref());
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
progress.total.store(todo.len(), Relaxed);
// probes from a preceding `scan` are reused when the file is unchanged (same size)
let cached: Vec<Option<ProbeInfo>> = todo
.iter()
.map(|f| {
let info = s.import_probes.remove(f)?;
let info = input.cache.remove(f)?;
let size = std::fs::metadata(f).map(|m| m.len()).ok();
(size.is_none() || size == Some(info.file_size)).then_some(info)
})
.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, &missing).into_iter();
progress.done.store(todo.len() - missing.len(), Relaxed);
let mut fresh = probe_all(input.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();
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
let mut seen_hash: HashMap<String, Option<u64>> = by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut out = Vec::with_capacity(files.len());
let mut kept = HashMap::new();
for f in files {
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
if let Some(id) = by_path.get(&f) {
c.duplicate = Some("path".into());
c.existing = Some(id.0);
if let Some(p) = s.catalog.photo(*id) {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(p.format.clone(), p.kind, p.width, p.height, p.file_size, p.captured.clone());
}
if let Some((_, c)) = input.by_path.get(&f) {
let mut c = c.clone();
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
out.push(c);
continue;
}
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
match probes.remove(&f) {
Some(Ok(info)) => {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
c.preview_only = info.preview_only.clone();
if let Some(h) = &info.content_hash {
match seen_hash.get(h) {
Some(existing) => {
@@ -335,14 +467,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
}
}
}
s.import_probes.insert(f, info);
kept.insert(f, info);
}
Some(Err(e)) => c.error = Some(e),
None => c.error = Some("not probed".into()),
}
out.push(c);
}
out
ScanOutput { candidates: out, probes: kept }
}
/// Import files/folders. See the module docs.
@@ -357,9 +489,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
// added file in its own storage already, so "copy" there means "add".
let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
if mode == ImportMode::Copy && lib_dir.is_none() {
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
// 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 mode == ImportMode::Copy && copy_root.is_none() {
return Err(crate::EngineError::Other("copying 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() };
@@ -388,7 +528,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
}
let probed = probe_all(s, &todo);
let probed = probe_all(s.media.file_probe.as_ref(), &todo, &ScanProgress::default());
crate::memory::release();
let now = (s.clock)();
let mut ops = promote;
@@ -408,9 +548,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
let stored = match (mode, &lib_dir) {
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
let stored = match (mode, &copy_root) {
(ImportMode::Copy, Some(root))
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
{
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
// the template sees the photo as it will be catalogued
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 n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
@@ -424,7 +576,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
let mut stored = stored;
let mut info = info;
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();
}
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
}
}
// 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;
@@ -433,13 +599,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
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);
let raw = p.kind == lightcraft_catalog::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));
if let Some(x) = packet {
match crate::sidecar::parse_sidecar(&x, raw) {
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
crate::sidecar::merge_into(&mut p, &sc, &now);
report.sidecars += 1;
@@ -448,6 +615,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
Err(e) => log::warn!("import {path}: XMP: {e}"),
}
}
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)) {
@@ -503,6 +673,7 @@ impl Session {
if let Some(info) = self.import_probes.get(&c.path) {
p.as_shot_wb = info.as_shot_wb;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
}
p.develop = std::sync::Arc::new(p.import_defaults());
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
+139 -1
View File
@@ -12,6 +12,7 @@
pub mod cmd;
pub mod crs;
pub mod crs_masks;
pub mod demo;
pub mod devices;
pub mod export;
@@ -21,9 +22,12 @@ pub mod library;
pub mod media;
pub mod memory;
pub mod merge;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod rename;
pub mod sidecar;
pub mod smart;
mod view;
use std::sync::Arc;
@@ -68,6 +72,9 @@ pub struct Interaction {
}
pub struct Session {
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
/// that changed, nothing else).
pub auto_sync: bool,
pub catalog: Catalog,
pub source: LibrarySource,
pub filter: Filter,
@@ -114,11 +121,36 @@ pub struct Session {
pub last_export: Option<serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
/// persisted in prefs.json.
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// Look-alike signatures by content key (Find Similar).
pub signatures: std::collections::HashMap<String, [f32; 64]>,
/// Imported `.cube` LUT profiles.
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
/// The target album B adds to (`None` = the Quick Collection).
pub target_album: Option<lightcraft_catalog::AlbumId>,
/// Auto import: files seen in the watched folder and their size then (a file is imported once
/// its size held between two scans).
pub auto_import_seen: std::collections::HashMap<String, u64>,
/// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent
/// Keywords) and the recently added keywords, newest first.
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
pub keyword_set: Option<String>,
pub recent_keywords: Vec<String>,
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
/// import state). See `cmd/before.rs`.
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
/// File probes from the last import review (`library.importPreview`), reused by the import.
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// Develop defaults applied on import (persisted in prefs.json).
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
@@ -134,6 +166,7 @@ impl Default for Session {
impl Session {
pub fn new() -> Session {
Session {
auto_sync: false,
catalog: Catalog::new(),
source: LibrarySource::All,
filter: Filter::default(),
@@ -163,8 +196,20 @@ impl Session {
xmp: sidecar::XmpPrefs::default(),
last_export: None,
export_presets: Vec::new(),
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
curve_presets: Vec::new(),
label_sets: Vec::new(),
signatures: Default::default(),
lut_profiles: Vec::new(),
target_album: None,
auto_import_seen: Default::default(),
keyword_sets: Vec::new(),
keyword_set: None,
recent_keywords: Vec::new(),
before: Default::default(),
import_probes: Default::default(),
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
}
@@ -190,6 +235,9 @@ impl Session {
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
if let Some(left) = was_active {
self.auto_version(left);
}
}
if r.is_ok() && spec.journal && self.depth == 0 {
self.journal.push((id.to_string(), params.clone()));
@@ -227,6 +275,30 @@ impl Session {
Ok(())
}
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
/// Saved with the library but not an undo step.
pub fn auto_version(&mut self, id: PhotoId) {
let Some(p) = self.catalog.photo(id) else { return };
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
return;
}
let mut versions = p.versions.clone();
let created = (self.clock)();
let name = lightcraft_catalog::dates::display_time(&created);
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
let autos = versions.iter().filter(|v| v.auto).count();
if autos > AUTO_VERSIONS
&& let Some(i) = versions.iter().position(|v| v.auto)
{
versions.remove(i);
}
let op = Op::SetVersions { id, versions };
if self.catalog.apply(op.clone()).is_ok() {
self.pending_log.push(op);
}
}
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
pub fn merge_undo(&mut self, n: usize, label: &str) {
@@ -312,10 +384,46 @@ impl Session {
{
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let op = self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
ops.extend(self.auto_sync_ops(id, &settings, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor history / snapshot restores.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
|| self.selection.ids.len() < 2
|| label.starts_with("History:")
|| label.starts_with("Restore ")
{
return Vec::new();
}
let Some(old) = self.develop_of(id) else { return Vec::new() };
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version"] {
o.remove(k);
}
if o.is_empty() {
return Vec::new();
}
}
self.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
}
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
self.catalog.photo(id)?;
@@ -361,6 +469,8 @@ impl Session {
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
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)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
@@ -382,6 +492,18 @@ impl Session {
self.visible.reverse();
}
}
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort (the grid groups by import day)
let cat = &self.catalog;
self.visible.sort_by(|a, b| {
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
key(b).cmp(&key(a)).then(a.cmp(b))
});
}
if self.source == LibrarySource::Missing {
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
self.visible.retain(|id| missing.contains(id));
}
if self.source != LibrarySource::RecentlyDeleted {
self.visible = self.catalog.arrange_stacks(&self.visible);
}
@@ -406,6 +528,22 @@ impl Session {
}
}
/// Automatic versions kept per photo.
pub const AUTO_VERSIONS: usize = 20;
/// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole).
pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
match (old, new) {
(Value::Object(a), Value::Object(b)) => {
let m: serde_json::Map<String, Value> =
b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect();
(!m.is_empty()).then_some(Value::Object(m))
}
(a, b) if a == b => None,
(_, b) => Some(b.clone()),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+32
View File
@@ -108,6 +108,20 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Metadata presets.
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// Filter presets.
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Point-curve presets.
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// Imported LUT profiles.
lut_profiles: Vec<crate::cmd::lut_profiles::LutProfile>,
/// Keyword sets, the one in use, recent keywords.
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
keyword_set: Option<String>,
recent_keywords: Vec<String>,
/// Develop defaults for imported photos.
import: crate::import::ImportDefaults,
/// Thumbnail disk cache budget (MB, 0 = default).
@@ -143,6 +157,7 @@ impl Session {
/// this session's catalog.
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
let (mut journal, catalog, report) = Journal::open(catalog)?;
self.catalog = catalog;
self.undo.clear();
@@ -174,6 +189,15 @@ impl Session {
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
self.export_presets = prefs.export_presets;
self.metadata_presets = prefs.metadata_presets;
self.filter_presets = prefs.filter_presets;
self.curve_presets = prefs.curve_presets;
self.label_sets = prefs.label_sets;
self.lut_profiles = prefs.lut_profiles;
crate::cmd::lut_profiles::register_all(self);
self.keyword_sets = prefs.keyword_sets;
self.keyword_set = prefs.keyword_set;
self.recent_keywords = prefs.recent_keywords;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
// view state
@@ -286,6 +310,14 @@ impl Session {
xmp: self.xmp,
last_export: self.last_export.clone(),
export_presets: self.export_presets.clone(),
metadata_presets: self.metadata_presets.clone(),
filter_presets: self.filter_presets.clone(),
curve_presets: self.curve_presets.clone(),
label_sets: self.label_sets.clone(),
lut_profiles: self.lut_profiles.clone(),
keyword_sets: self.keyword_sets.clone(),
keyword_set: self.keyword_set.clone(),
recent_keywords: self.recent_keywords.clone(),
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
})
+62 -10
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 = 3;
pub const RENDER_CACHE_VERSION: u64 = 4;
/// 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];
@@ -74,8 +74,19 @@ pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
Demo { scene: Box<lightcraft_scenes::Scene>, max_edge: usize },
File { path: String, max_edge: usize, loader: Option<FileLoader> },
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
},
File {
path: String,
max_edge: usize,
loader: Option<FileLoader>,
},
/// A smart preview standing in for a missing original.
Smart {
path: std::path::PathBuf,
},
}
impl SourceRef {
@@ -87,6 +98,10 @@ impl SourceRef {
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
None => Err(format!("no decoder available for {path}")),
},
#[cfg(not(target_arch = "wasm32"))]
SourceRef::Smart { path } => crate::smart::load(path),
#[cfg(target_arch = "wasm32")]
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
}
}
}
@@ -108,6 +123,8 @@ pub struct MediaCache {
pub preview_loader: Option<PreviewLoader>,
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
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>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -126,6 +143,7 @@ impl Default for MediaCache {
file_probe: None,
preview_loader: None,
file_bytes: None,
smart_dir: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
}
@@ -266,6 +284,16 @@ impl MediaCache {
(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
#[cfg(not(target_arch = "wasm32"))]
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
&& !std::path::Path::new(path).exists()
{
let sp = dir.join(crate::smart::file_name(p));
if sp.exists() {
return SourceRef::Smart { path: sp };
}
}
self.origin_ref(&p.source, max_edge)
}
@@ -346,6 +374,20 @@ impl RenderJob {
self
}
/// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is
/// never kept as the photo's cached preview.
pub fn with_proof(mut self, proof: Option<lightcraft_pipeline::Proof>) -> Self {
if self.request.proof != proof {
let k = |p: Option<lightcraft_pipeline::Proof>| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f);
self.key ^= k(self.request.proof) ^ k(proof);
self.request.proof = proof;
}
if proof.is_some() {
self.view_cache = None;
}
self
}
/// Reuse `stages` across this view's renders.
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
self.stages = Some(stages);
@@ -400,7 +442,13 @@ impl RenderJob {
/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see
/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU.
pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
if gpu && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) {
// LUT profiles have no GPU stage: they render on the CPU
if gpu
&& !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)
&& !s.masks.iter().any(|m| m.visible && m.refine > 0.0)
&& req.proof.is_none()
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
{
return r;
}
match stages {
@@ -475,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo {
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
}
if p.kind == MediaKind::Raw {
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
} else {
SourceInfo::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() }
}
impl crate::Session {
@@ -511,12 +557,15 @@ impl crate::Session {
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
// the photo id is part of the key: two photos with the same settings and size must not
// share a result (a view slot showing photo A would otherwise look current for photo B)
// …and so is the file's content: a file changed on disk (Reload) renders afresh
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
let key = settings.hash64()
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ (level as u64) << 60
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15);
^ 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();
(self.media.rendered.clone(), k)
@@ -700,6 +749,9 @@ pub struct ProbeInfo {
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
pub xmp: Option<String>,
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
pub preview_only: Option<String>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
+744
View File
@@ -0,0 +1,744 @@
//! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table
//! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps,
//! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties
//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by
//! [`crate::preset_luminar`].
//!
//! Written from the formats' public structure (Lua's table syntax, the ZIP application note,
//! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used.
use std::path::Path;
use lightcraft_develop::Preset;
use serde_json::{Map, Value};
use crate::crs::Props;
/// One preset read from a file, with the settings we could not carry over.
#[derive(Clone, Debug)]
pub struct Imported {
pub preset: Preset,
/// Names of the other editor's settings that have no counterpart here (local masks, its own
/// profiles…).
pub unmapped: Vec<String>,
}
/// Extensions of files read as presets (beyond our own `.lcpreset`).
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
// ------------------------------------------------------------------------------------- zip
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
/// directories, macOS resource forks and hidden files are skipped.
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
let tail = bytes.len().saturating_sub(65_557);
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
let count = u16_at(eocd + 10).ok_or("bad zip")?;
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
let mut out = Vec::new();
for _ in 0..count {
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
return Err("bad zip central directory".into());
}
let method = u16_at(at + 10).ok_or("bad zip")?;
let csize = u32_at(at + 20).ok_or("bad zip")?;
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
let local = u32_at(at + 42).ok_or("bad zip")?;
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
at += 46 + nlen + xlen + clen;
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
if skip {
continue;
}
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
return Err(format!("{name}: bad local header"));
}
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
let content = match method {
0 => data.to_vec(),
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
m => return Err(format!("{name}: unsupported compression method {m}")),
};
out.push((name, content));
}
Ok(out)
}
// ------------------------------------------------------------------------------- Lua tables
/// A value of a Lua table literal (what `.lrtemplate` files contain).
#[derive(Clone, Debug, PartialEq)]
pub enum Lua {
Nil,
Bool(bool),
Num(f64),
Str(String),
/// Positional items and named fields, in file order.
Table(Vec<Lua>, Vec<(String, Lua)>),
}
impl Lua {
pub fn get(&self, key: &str) -> Option<&Lua> {
match self {
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Lua::Str(s) => Some(s),
_ => None,
}
}
}
struct LuaParser<'a> {
s: &'a [u8],
i: usize,
}
impl LuaParser<'_> {
fn err<T>(&self, what: &str) -> Result<T, String> {
Err(format!("{what} at byte {}", self.i))
}
fn skip_ws(&mut self) {
loop {
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.s[self.i..].starts_with(b"--") {
self.i += 2;
if let Some(level) = self.long_bracket_level() {
let _ = self.long_string(level);
} else {
while self.i < self.s.len() && self.s[self.i] != b'\n' {
self.i += 1;
}
}
continue;
}
break;
}
}
/// `[[` / `[==[` at the cursor: its level (number of `=`).
fn long_bracket_level(&self) -> Option<usize> {
let r = &self.s[self.i..];
if r.first() != Some(&b'[') {
return None;
}
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
}
fn long_string(&mut self, level: usize) -> Result<String, String> {
self.i += level + 2;
let close = format!("]{}]", "=".repeat(level));
let rest = &self.s[self.i..];
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
if text.starts_with('\n') {
text.remove(0);
}
self.i += end + close.len();
Ok(text)
}
fn string(&mut self) -> Result<String, String> {
let q = self.s[self.i];
self.i += 1;
let mut out = Vec::new();
while self.i < self.s.len() {
let c = self.s[self.i];
self.i += 1;
match c {
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
b'\\' => {
let e = *self.s.get(self.i).ok_or("bad escape")?;
self.i += 1;
match e {
b'n' => out.push(b'\n'),
b't' => out.push(b'\t'),
b'r' => out.push(b'\r'),
b'0'..=b'9' => {
let mut v = (e - b'0') as u32;
for _ in 0..2 {
match self.s.get(self.i) {
Some(d @ b'0'..=b'9') => {
v = v * 10 + (d - b'0') as u32;
self.i += 1;
}
_ => break,
}
}
out.push(v.min(255) as u8);
}
b'\n' => out.push(b'\n'),
other => out.push(other),
}
}
c => out.push(c),
}
}
self.err("unterminated string")
}
fn ident(&mut self) -> Option<String> {
let start = self.i;
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
self.i += 1;
}
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
}
fn number(&mut self) -> Result<f64, String> {
let start = self.i;
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
self.i += 2;
let h = self.i;
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
self.i += 1;
}
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
}
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
break;
}
self.i += 1;
}
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
}
fn value(&mut self) -> Result<Lua, String> {
self.skip_ws();
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
match c {
b'{' => self.table(),
b'"' | b'\'' => self.string().map(Lua::Str),
b'[' if self.long_bracket_level().is_some() => {
let level = self.long_bracket_level().unwrap_or(0);
self.long_string(level).map(Lua::Str)
}
b'-' => {
self.i += 1;
self.skip_ws();
match self.value()? {
Lua::Num(n) => Ok(Lua::Num(-n)),
_ => self.err("bad negation"),
}
}
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
_ => match self.ident().as_deref() {
Some("true") => Ok(Lua::Bool(true)),
Some("false") => Ok(Lua::Bool(false)),
Some("nil") => Ok(Lua::Nil),
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
Some(_) => {
self.skip_ws();
match self.s.get(self.i) {
Some(b'"' | b'\'' | b'{') => self.value(),
Some(b'(') => {
self.i += 1;
let v = self.value()?;
self.skip_ws();
if self.s.get(self.i) == Some(&b')') {
self.i += 1;
}
Ok(v)
}
_ => Ok(Lua::Nil),
}
}
None => self.err("unexpected character"),
},
}
}
fn table(&mut self) -> Result<Lua, String> {
self.i += 1; // {
let (mut arr, mut map) = (Vec::new(), Vec::new());
loop {
self.skip_ws();
match self.s.get(self.i) {
None => return self.err("unterminated table"),
Some(b'}') => {
self.i += 1;
return Ok(Lua::Table(arr, map));
}
Some(b',' | b';') => {
self.i += 1;
continue;
}
Some(b'[') if self.long_bracket_level().is_none() => {
self.i += 1;
let k = self.value()?;
self.skip_ws();
if self.s.get(self.i) != Some(&b']') {
return self.err("expected ]");
}
self.i += 1;
self.expect_eq()?;
let v = self.value()?;
match k {
Lua::Str(s) => map.push((s, v)),
Lua::Num(n) => map.push((format!("{n}"), v)),
_ => {}
}
}
_ => {
// `name = value`, or a positional value
let save = self.i;
if let Some(name) = self.ident() {
self.skip_ws();
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
self.i += 1;
let v = self.value()?;
map.push((name, v));
continue;
}
}
self.i = save;
arr.push(self.value()?);
}
}
}
}
fn expect_eq(&mut self) -> Result<(), String> {
self.skip_ws();
if self.s.get(self.i) == Some(&b'=') {
self.i += 1;
Ok(())
} else {
self.err("expected =")
}
}
}
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
pub fn parse_lua(text: &str) -> Result<Lua, String> {
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
p.skip_ws();
let save = p.i;
match p.ident().as_deref() {
Some("return") => {}
Some(_) => {
p.skip_ws();
if p.s.get(p.i) == Some(&b'=') {
p.i += 1;
} else {
p.i = save;
}
}
None => {}
}
p.value()
}
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
fn delocalize(s: &str) -> String {
match s.strip_prefix("$$$/") {
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
None => s.to_string(),
}
}
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
/// corrections), plus its title.
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
let root = parse_lua(text)?;
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
let mut props = Props::new();
let mut values = crate::crs_masks::Values::new();
for (k, v) in fields {
let key = format!("crs:{k}");
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
values.insert(key.clone(), crate::crs_masks::from_lua(v));
}
let vals: Vec<String> = match v {
Lua::Num(n) => vec![format!("{n}")],
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
Lua::Str(s) => vec![delocalize(s)],
// point curves are flat lists x1, y1, x2, y2, …
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
.chunks(2)
.filter_map(|c| match c {
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
_ => None,
})
.collect(),
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
// anything else (local corrections, retouch…) is only reported
Lua::Table(..) => vec!["(table)".into()],
Lua::Nil => continue,
};
props.insert(key, vals);
}
Ok((title, props, values))
}
// ------------------------------------------------------------------------------- XMP in files
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
/// `<x:xmpmeta` to `</x:xmpmeta>`.
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
}
// ----------------------------------------------------------------------------------- files
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
/// `Mobile (DNG)`, `Develop Presets`…).
pub fn group_from_dir(dir: &str) -> Option<String> {
const FILLER: &[&str] = &[
"lightroom",
"lr",
"lrc",
"classic",
"cc",
"camera",
"raw",
"acr",
"photoshop",
"desktop",
"mobile",
"xmp",
"lrtemplate",
"lrtemplates",
"dng",
"dngs",
"preset",
"presets",
"settings",
"lcpreset",
"lightcraft",
"user",
"develop",
"files",
"for",
"and",
"version",
"versions",
"format",
"formats",
"new",
"old",
"adobe",
"luminar",
"skylum",
"looks",
"lmp",
"mplumpack",
"contents",
"resources",
];
let generic = |name: &str| {
let lower = name.to_ascii_lowercase();
lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit()))
};
// a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group
let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp");
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string)
}
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
if from_photo && let Some(o) = settings.as_object_mut() {
// a photo's own framing and absolute white balance don't belong in a look
o.remove("crop");
o.remove("geometry");
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
o.remove("wb");
}
}
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
let group = props
.get("crs:Group")
.and_then(|v| v.first())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or(group)
.unwrap_or_else(|| "Imported Presets".into());
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
}
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
/// (its folder). Zip bundles are read recursively.
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
let path = Path::new(name);
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"zip" => {
let mut out = Vec::new();
let mut errors = Vec::new();
for (inner, data) in read_zip(bytes)? {
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
continue;
}
let dir_group =
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
match read_presets(&inner, &data, dir_group) {
Ok(v) => out.extend(v),
Err(e) => errors.push(format!("{inner}: {e}")),
}
}
if out.is_empty() {
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
}
Ok(out)
}
"lmp" => crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]),
"mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes),
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
build(&props, &values, title.unwrap_or(stem), group, false)
.map(|i| vec![i])
.ok_or_else(|| "no develop settings we can use in this template".into())
}
"xmp" => {
let text = String::from_utf8_lossy(bytes);
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let props = &d.properties;
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
}
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
}
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
v.into_iter()
.map(|mut p| {
if p.group == "Imported Presets"
&& let Some(g) = &group
{
p.group = g.clone();
}
Imported { preset: p, unmapped: Vec::new() }
})
.collect()
}),
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
let off = out.len() as u32;
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
out.extend_from_slice(sig);
if central {
out.extend_from_slice(&20u16.to_le_bytes());
}
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&method.to_le_bytes());
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
};
head(b"PK\x03\x04", &mut out, false);
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&body);
head(b"PK\x01\x02", &mut central, true);
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
central.extend_from_slice(&off.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd = out.len() as u32;
out.extend_from_slice(&central);
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
/// A template in the classic Lua-table form (written for this test).
pub(crate) const TEMPLATE: &str = r#"s = {
id = "0D2F9C1E-TEST",
internalName = "Warm Fade",
title = "$$$/Test/Warm=Warm Fade",
type = "Develop",
value = {
settings = {
Exposure2012 = 0.35,
Contrast2012 = -20,
ConvertToGrayscale = false,
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
SplitToningShadowHue = 210,
SplitToningShadowSaturation = 15,
HueAdjustmentOrange = -8,
CameraProfile = "Some Profile",
RetouchInfo = {},
ProcessVersion = "11.0",
-- a comment
EnableColorAdjustments = true,
},
uuid = "0D2F9C1E-TEST",
},
version = 0,
}
"#;
#[test]
fn lua_tables() {
let v = parse_lua(
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
text]], z = ZSTR "loc" }"#,
)
.unwrap();
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
let Lua::Table(arr, _) = &v else { panic!() };
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
assert!(parse_lua("s = { a = ").is_err());
}
#[test]
fn lrtemplate_maps_settings_and_reports_the_rest() {
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
assert_eq!(v.len(), 1);
let p = &v[0].preset;
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
assert_eq!(p.settings["treatment"], json!("color"));
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
let c = p.settings["curve"]["master"].as_array().unwrap();
assert_eq!(c.len(), 3);
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
// it parses into real develop settings
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
assert_eq!(d.light.exposure, 0.35);
}
#[test]
fn old_process_version_fields_are_approximated() {
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
// newer presets carry the old fields at their defaults: those must not shift anything
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
}
const XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:Exposure2012="+0.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
</rdf:RDF></x:xmpmeta>"#;
#[test]
fn photos_carrying_edits_become_looks() {
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
let mut file = b"II*\0 binary junk ".to_vec();
file.extend_from_slice(XMP.as_bytes());
file.extend_from_slice(b" more junk");
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
let p = &v[0].preset;
assert_eq!(p.name, "Moody");
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
}
#[test]
fn zip_bundles_with_folders_as_groups() {
for deflate in [false, true] {
let z = zip(
&[
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
("Pack/Film/Bright.xmp", XMP.as_bytes()),
("__MACOSX/Pack/._Bright.xmp", b"junk"),
("Pack/readme.txt", b"thanks for downloading"),
],
deflate,
);
let v = read_presets("pack.zip", &z, None).unwrap();
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
}
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
}
#[test]
fn generic_folder_names_are_not_groups() {
assert_eq!(group_from_dir("Settings"), None);
assert_eq!(group_from_dir("Develop Presets/"), None);
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
// commercial packs nest by format: the pack's name wins
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
}
#[test]
fn import_command_reads_folders_bundles_and_applies() {
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
let mut s = crate::Session::with_demo();
let before = s.presets.len();
let paths = json!([dir.join("Looks").to_string_lossy()]);
// a dry run reports without adding
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(s.presets.len(), before);
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
let got: Vec<(String, String)> =
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
assert_eq!(s.presets.len(), before + 2);
// importing again adds nothing
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
// and the imported look applies
let id = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
s.execute("preset.apply", &json!({"id": pid})).unwrap();
let d = s.develop_of(id).unwrap();
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
let _ = std::fs::remove_dir_all(&dir);
}
}
+688
View File
@@ -0,0 +1,688 @@
//! Luminar looks: `.lmp` files and `.mplumpack` collections.
//!
//! An `.lmp` look is an Apple XML property list: a dict whose `AdjustmentLayers` array holds layers
//! (`Identifier`, `Enabled`, `Amount` = opacity 0..1, `BlendModeIdentifier`, optional nested
//! `Sublayers.AdjustmentLayers`), each with `Effects` (`Identifier` such as
//! `MIPLDevelopCommonEffectID`, and `Parameters`: name → `{Value: number}`; parameters at their
//! default are left out). Newer looks may be a macOS bundle directory `Name.lmp/Contents/preset.lmp`
//! (plus `Info.plist` and LUT resources). An `.mplumpack` is a zip of `.lmp` files with a
//! `PresetsInfo.plist` (`GroupName`) and icons.
//!
//! The look's sliders with a clear counterpart become `crs:` fields (mapped by [`crate::crs`]);
//! everything else (AI tools, Orton, glow, LUTs…) is reported as unmapped. Sliders are on a
//! −100..100 scale; Exposure is read as ±100 ↔ ±4 EV. Layer opacity scales additive sliders.
//!
//! Written from black-box inspection of freely distributed looks and the public property-list
//! format; no Skylum code or look content was used.
use std::collections::BTreeMap;
use std::path::Path;
use crate::crs::Props;
use crate::preset_import::{Imported, build, group_from_dir, read_zip};
// ----------------------------------------------------------------------------- property lists
/// A value of an XML property list.
#[derive(Clone, Debug, PartialEq)]
pub enum Plist {
Dict(Vec<(String, Plist)>),
Array(Vec<Plist>),
Str(String),
Num(f64),
Bool(bool),
/// `<data>` (base64; not decoded)
Data,
}
impl Plist {
pub fn get(&self, key: &str) -> Option<&Plist> {
match self {
Plist::Dict(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Plist::Str(s) => Some(s),
_ => None,
}
}
pub fn num(&self) -> Option<f64> {
match self {
Plist::Num(n) => Some(*n),
_ => None,
}
}
pub fn array(&self) -> &[Plist] {
match self {
Plist::Array(a) => a,
_ => &[],
}
}
}
struct Parser<'a> {
s: &'a str,
i: usize,
/// Nesting of the element being read (bounded: a hostile file must not overflow the stack).
depth: usize,
}
/// Deepest `<dict>` / `<array>` nesting read (real looks nest a handful of levels).
const MAX_DEPTH: usize = 64;
fn unescape(t: &str) -> String {
let mut out = String::with_capacity(t.len());
let mut rest = t;
while let Some(p) = rest.find('&') {
out.push_str(&rest[..p]);
rest = &rest[p..];
let Some(end) = rest.find(';') else { break };
let ent = &rest[1..end];
let c = match ent {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => ent
.strip_prefix("#x")
.or_else(|| ent.strip_prefix("#X"))
.and_then(|h| u32::from_str_radix(h, 16).ok())
.or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()))
.and_then(char::from_u32),
};
match c {
Some(c) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
impl Parser<'_> {
fn err<T>(&self, what: &str) -> Result<T, String> {
Err(format!("property list: {what} at byte {}", self.i))
}
/// Skip whitespace, the XML declaration, the doctype and comments.
fn skip(&mut self) {
loop {
let r = &self.s[self.i..];
let t = r.trim_start();
self.i += r.len() - t.len();
let close = if t.starts_with("<?") {
"?>"
} else if t.starts_with("<!--") {
"-->"
} else if t.starts_with("<!") {
">"
} else {
return;
};
match t.find(close) {
Some(e) => self.i += e + close.len(),
None => {
self.i = self.s.len();
return;
}
}
}
}
/// The next tag: (name, closing, self-closing).
fn tag(&mut self) -> Result<(String, bool, bool), String> {
self.skip();
let r = &self.s[self.i..];
if !r.starts_with('<') {
return self.err("expected a tag");
}
let Some(end) = r.find('>') else { return self.err("unterminated tag") };
let inner = &r[1..end];
self.i += end + 1;
let closing = inner.starts_with('/');
let selfc = inner.ends_with('/');
let name = inner.trim_start_matches('/').trim_end_matches('/').split_whitespace().next().unwrap_or("").to_string();
Ok((name, closing, selfc))
}
fn peek_close(&mut self, name: &str) -> bool {
self.skip();
let r = &self.s[self.i..];
let t = format!("</{name}>");
if r.starts_with(&t) {
self.i += t.len();
true
} else {
false
}
}
fn text(&mut self, name: &str) -> Result<String, String> {
let r = &self.s[self.i..];
let t = format!("</{name}>");
let Some(end) = r.find(&t) else { return self.err(&format!("missing {t}")) };
self.i += end + t.len();
Ok(unescape(&r[..end]))
}
fn value(&mut self) -> Result<Plist, String> {
if self.depth >= MAX_DEPTH {
return self.err("nested too deeply");
}
self.depth += 1;
let v = self.value_inner();
self.depth -= 1;
v
}
fn value_inner(&mut self) -> Result<Plist, String> {
let (name, closing, selfc) = self.tag()?;
if closing {
return self.err(&format!("unexpected </{name}>"));
}
let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result<Plist, String>| if selfc { f(String::new()) } else { p.text(&name).and_then(f) };
match name.as_str() {
"plist" => {
if selfc {
return Ok(Plist::Dict(Vec::new()));
}
let v = self.value()?;
self.peek_close("plist");
Ok(v)
}
"dict" => {
let mut m = Vec::new();
if !selfc {
while !self.peek_close("dict") {
let (k, c, sc) = self.tag()?;
if k != "key" || c {
return self.err("expected <key>");
}
let key = if sc { String::new() } else { self.text("key")? };
m.push((key, self.value()?));
}
}
Ok(Plist::Dict(m))
}
"array" => {
let mut a = Vec::new();
if !selfc {
while !self.peek_close("array") {
a.push(self.value()?);
}
}
Ok(Plist::Array(a))
}
"string" | "date" => leaf(self, &|t| Ok(Plist::Str(t))),
"real" | "integer" => {
leaf(self, &|t| t.trim().parse::<f64>().map(Plist::Num).map_err(|_| format!("property list: bad number {:?}", t.trim())))
}
"data" => leaf(self, &|_| Ok(Plist::Data)),
"true" | "false" => {
if !selfc {
self.text(&name)?;
}
Ok(Plist::Bool(name == "true"))
}
_ => self.err(&format!("unknown element <{name}>")),
}
}
}
/// Parse an XML property list.
pub fn parse_plist(bytes: &[u8]) -> Result<Plist, String> {
if bytes.starts_with(b"bplist") {
return Err("binary property lists are not supported".into());
}
let text = String::from_utf8_lossy(bytes);
let mut p = Parser { s: text.trim_start_matches('\u{feff}'), i: 0, depth: 0 };
p.value()
}
// ------------------------------------------------------------------------------------ mapping
/// Luminar's colour bands (`h`/`s`/`l` + band in the HSL tool) — the same eight as `crs:`.
const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
/// Effects whose parameters carry the basic tone / colour sliders under their own names.
const BASIC: &[&str] = &[
"MIPLDevelopCommonEffectID",
"MIPLExposureEffect",
"MIPLContrastEffect",
"MIPLHighlightsEffect",
"MIPLBlackWhiteEffect",
"MIPLWhiteBalanceEffect",
"MIPLSaturationEffect",
"MIPLVibranceEffect",
"MIPLClarityEffect",
];
/// How one Luminar slider maps: the `crs:` field, a scale, whether it adds up across layers
/// (and fades with layer opacity), and its clamp range.
struct Target {
crs: String,
scale: f64,
additive: bool,
range: (f64, f64),
}
fn target(effect: &str, param: &str, value: f64) -> Option<Target> {
let t = |crs: &str, scale: f64, additive: bool, range: (f64, f64)| Some(Target { crs: crs.to_string(), scale, additive, range });
let pm = (-100.0, 100.0);
if BASIC.contains(&effect) {
return match param {
"Exposure" => t("Exposure2012", 4.0 / 100.0, true, (-5.0, 5.0)),
"Contrast" => t("Contrast2012", 1.0, true, pm),
"Highlights" => t("Highlights2012", 1.0, true, pm),
"Shadows" => t("Shadows2012", 1.0, true, pm),
"Whites" => t("Whites2012", 1.0, true, pm),
"Blacks" => t("Blacks2012", 1.0, true, pm),
// an absolute colour temperature (raw develop) vs. a relative shift
"Temperature" if value.abs() > 1000.0 => t("Temperature", 1.0, false, (2000.0, 50000.0)),
"Temperature" => t("IncrementalTemperature", 1.0, true, pm),
"Tint" => t("IncrementalTint", 1.0, true, pm),
"Saturation" => t("Saturation", 1.0, true, pm),
"Vibrance" => t("Vibrance", 1.0, true, pm),
"Clarity" => t("Clarity2012", 1.0, true, pm),
"Dehaze" => t("Dehaze", 1.0, true, pm),
_ => None,
};
}
match (effect, param) {
("MIPLAIStructureEffect" | "MIPLStructureEffect", "Amount") => t("Clarity2012", 1.0, true, pm),
("MIPLDehazeEffect", "Amount") => t("Dehaze", 1.0, true, pm),
("MIPLVignetteEffect", "Amount") => t("PostCropVignetteAmount", 1.0, true, pm),
("MIPLVignetteEffect", "Vignette Size" | "Size") => t("PostCropVignetteMidpoint", 1.0, false, (0.0, 100.0)),
("MIPLGrainNewEffect" | "MIPLGrainEffect", "Amount") => t("GrainAmount", 1.0, true, (0.0, 100.0)),
("MIPLChannelsEffect" | "MIPLHSLEffect", _) => {
let (kind, band) = param.split_at(param.char_indices().nth(1).map_or(param.len(), |(i, _)| i));
let field = match kind {
"h" => "HueAdjustment",
"s" => "SaturationAdjustment",
"l" => "LuminanceAdjustment",
_ => return None,
};
BANDS.contains(&band).then(|| Target { crs: format!("{field}{band}"), scale: 1.0, additive: true, range: pm })
}
_ => None,
}
}
/// A point curve stored as `OptionalData`: a leading value, then x, y pairs in 0..1 with x
/// increasing. Anything else isn't read.
fn curve_points(param: &Plist) -> Option<Vec<(f64, f64)>> {
let data: Vec<f64> = param.get("OptionalData")?.array().iter().map(Plist::num).collect::<Option<_>>()?;
if data.len() < 5 || data.len().is_multiple_of(2) {
return None;
}
let pts: Vec<(f64, f64)> = data[1..].chunks(2).map(|c| (c[0], c[1])).collect();
let ok = pts.iter().all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)) && pts.windows(2).all(|w| w[1].0 > w[0].0);
ok.then_some(pts)
}
/// `MIPLSmartContrastEffect` → `SmartContrast`.
fn short(effect: &str) -> &str {
let e = effect.strip_prefix("MIPL").unwrap_or(effect);
["_v1_Effect", "EffectID", "Effect"].iter().find_map(|s| e.strip_suffix(s)).unwrap_or(e)
}
#[derive(Default)]
struct Acc {
sums: BTreeMap<String, (f64, (f64, f64))>,
curves: BTreeMap<String, Vec<(f64, f64)>>,
unmapped: Vec<String>,
}
impl Acc {
fn skip(&mut self, what: String) {
if !self.unmapped.contains(&what) {
self.unmapped.push(what);
}
}
fn layers(&mut self, layers: &[Plist], weight: f64) {
for layer in layers {
if matches!(layer.get("Enabled"), Some(Plist::Bool(false))) {
continue;
}
let opacity = weight * layer.get("Amount").and_then(Plist::num).unwrap_or(1.0).clamp(0.0, 1.0);
if opacity <= 0.0 {
continue;
}
let blend = layer.get("BlendModeIdentifier").and_then(Plist::str).unwrap_or("Normal");
// a layer with a mask applies locally: not a global look
let empty = |v: &Plist| {
matches!(v, Plist::Bool(false)) || matches!(v, Plist::Dict(d) if d.is_empty()) || matches!(v, Plist::Array(a) if a.is_empty())
};
let masked = matches!(layer, Plist::Dict(m) if m.iter().any(|(k, v)| k.to_ascii_lowercase().contains("mask") && !empty(v)));
let literal = blend.eq_ignore_ascii_case("normal") && !masked;
let mut reported = false;
for effect in layer.get("Effects").map(Plist::array).unwrap_or_default() {
let id = effect.get("Identifier").and_then(Plist::str).unwrap_or("?");
let Some(Plist::Dict(params)) = effect.get("Parameters") else { continue };
for (name, pv) in params {
let value = pv.get("Value").and_then(Plist::num).or(pv.num());
if literal && id == "MIPLCurveEffect" {
let crs = match name.as_str() {
"RGB" => Some("ToneCurvePV2012"),
"Red" => Some("ToneCurvePV2012Red"),
"Green" => Some("ToneCurvePV2012Green"),
"Blue" => Some("ToneCurvePV2012Blue"),
_ => None,
};
if let (Some(crs), Some(pts)) = (crs, curve_points(pv))
&& !self.curves.contains_key(crs)
{
// a curve at partial opacity: the points fade toward the identity
self.curves.insert(crs.to_string(), pts.into_iter().map(|(x, y)| (x, x + (y - x) * opacity)).collect());
continue;
}
if pv.get("OptionalData").is_some() {
self.skip(format!("{}.{name}", short(id)));
}
continue;
}
let Some(v) = value.filter(|v| *v != 0.0) else { continue };
match target(id, name, v).filter(|_| literal) {
Some(t) => {
let x = v * t.scale * if t.additive { opacity } else { 1.0 };
let e = self.sums.entry(t.crs).or_insert((0.0, t.range));
e.0 = if t.additive { e.0 + x } else { x };
}
None => {
reported = true;
self.skip(format!("{}.{name}", short(id)));
}
}
}
}
if !literal && reported {
let lid = layer.get("Identifier").and_then(Plist::str).unwrap_or("layer");
self.skip(if masked { format!("{lid}: mask") } else { format!("{lid}: blend mode {blend}") });
}
if let Some(sub) = layer.get("Sublayers").and_then(|s| s.get("AdjustmentLayers")) {
self.layers(sub.array(), opacity);
}
}
}
}
/// The name of a look stored at `path`: its file name, or the bundle's for
/// `Name.lmp/Contents/preset.lmp`.
fn look_name(path: &str) -> String {
let p = Path::new(path);
let stem = |p: &Path| p.file_stem().map(|s| s.to_string_lossy().to_string());
let bundle = p.parent().filter(|d| d.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Contents"))).and_then(Path::parent);
match bundle {
Some(b) if b.extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) => stem(b),
_ => stem(p),
}
.unwrap_or_else(|| "Look".into())
}
/// Read one Luminar look (`.lmp`) into a preset in `group` (default "Imported Presets").
pub fn read_lmp(path: &str, bytes: &[u8], group: Option<String>) -> Result<Imported, String> {
let root = parse_plist(bytes)?;
let layers = root.get("AdjustmentLayers").ok_or("not a Luminar look (no adjustment layers)")?;
let mut acc = Acc::default();
acc.layers(layers.array(), 1.0);
let mut props = Props::new();
for (k, (v, (lo, hi))) in &acc.sums {
props.insert(format!("crs:{k}"), vec![format!("{}", v.clamp(*lo, *hi))]);
}
for (k, pts) in &acc.curves {
props.insert(format!("crs:{k}"), pts.iter().map(|(x, y)| format!("{}, {}", x * 255.0, y * 255.0)).collect());
}
if let Some(u) = root.get("uuid").and_then(Plist::str).filter(|u| !u.trim().is_empty()) {
props.insert("crs:UUID".into(), vec![u.trim().to_string()]);
}
let name = root.get("Name").and_then(Plist::str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| look_name(path));
match build(&props, &Default::default(), name, group, false) {
Some(mut i) => {
i.preset.id = i.preset.id.replacen("user.xmp.", "user.lmp.", 1);
i.unmapped.extend(acc.unmapped);
Ok(i)
}
None if acc.unmapped.is_empty() => Err("this look has no adjustments".into()),
None => Err(format!("none of this look's settings have a counterpart here ({})", acc.unmapped.join(", "))),
}
}
/// Read a Luminar looks collection (`.mplumpack`, a zip): every look, grouped by the pack's
/// `GroupName` (else the pack's file name).
pub fn read_mplumpack(path: &str, bytes: &[u8]) -> Result<Vec<Imported>, String> {
let entries = read_zip(bytes)?;
let info = entries
.iter()
.find(|(n, _)| n.rsplit('/').next().is_some_and(|b| b.eq_ignore_ascii_case("PresetsInfo.plist")))
.and_then(|(_, b)| parse_plist(b).ok())
.and_then(|p| p.get("GroupName").and_then(Plist::str).map(str::trim).filter(|g| !g.is_empty()).map(str::to_string));
let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string());
let pack = info.or_else(|| stem.as_deref().and_then(group_from_dir)).unwrap_or_else(|| "Luminar Looks".into());
let mut out = Vec::new();
let mut errors = Vec::new();
for (inner, data) in &entries {
if !Path::new(inner).extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) {
continue;
}
match read_lmp(inner, data, Some(pack.clone())) {
Ok(i) => out.push(i),
Err(e) => errors.push(format!("{inner}: {e}")),
}
}
if out.is_empty() {
return Err(if errors.is_empty() { "no looks in this collection".into() } else { errors.join("; ") });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preset_import::read_presets;
use serde_json::json;
/// One effect's parameter as Luminar writes it.
fn param(name: &str, v: f64) -> String {
format!("<key>{name}</key><dict><key>OptionalDataType</key><integer>0</integer><key>Value</key><real>{v}</real></dict>")
}
fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String {
let fx: String = effects
.iter()
.map(|(e, p)| format!("<dict><key>Identifier</key><string>{e}</string><key>Parameters</key><dict>{p}</dict></dict>"))
.collect();
let sub =
if sub.is_empty() { String::new() } else { format!("<key>Sublayers</key><dict><key>AdjustmentLayers</key><array>{sub}</array></dict>") };
format!(
"<dict><key>Amount</key><real>{amount}</real><key>BlendModeIdentifier</key><string>{blend}</string><key>Effects</key><array>{fx}</array>\
<key>Enabled</key><true/><key>Identifier</key><string>{id}</string>{sub}</dict>"
)
}
fn look(layers: &str, extra: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\"><dict><key>AdjustmentLayers</key><array>{layers}</array>{extra}<key>group_identifier</key><string>Custom</string></dict></plist>"
)
}
/// A newer-style look (develop sliders nested in sublayers) written for this test.
fn modern() -> String {
let curve = "<key>RGB</key><dict><key>OptionalData</key><array><real>0.5</real><real>0</real><real>0.1</real><real>0.5</real><real>0.5</real><real>1</real><real>0.9</real></array><key>OptionalDataType</key><integer>1</integer><key>Value</key><real>50</real></dict>";
let sub = [
layer(
"DevelopAdjustmentSubLayer",
1.0,
"Normal",
&[(
"MIPLDevelopCommonEffectID",
param("Exposure", 25.0) + &param("Contrast", 12.0) + &param("Highlights", -30.0) + &param("Temperature", 15.0),
)],
&layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""),
),
layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("<true/>", "<false/>"),
]
.concat();
let layers = [
layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub),
layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + &param("Boost", 10.0))], ""),
layer(
"Half",
0.5,
"Normal",
&[("MIPLVibranceEffect", param("Vibrance", 30.0)), ("MIPLSaturationEffect", param("Saturation", -10.0))],
"",
),
layer("Hsl", 1.0, "Normal", &[("MIPLChannelsEffect", param("hOrange", -8.0) + &param("sBlue", 12.0) + &param("lGreen", 4.0))], ""),
layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + &param("Vignette Size", 40.0))], ""),
layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""),
layer("Screened", 1.0, "Screen", &[("MIPLContrastEffect", param("Contrast", 40.0))], ""),
layer("Untouched", 1.0, "Normal", &[("MIPLSkyEnhancerEffect", String::new())], ""),
]
.concat();
look(&layers, "<key>uuid</key><string>AB12-CD34</string><key>kMPPresetIdentifierKey</key><string>x 2.lmp</string>")
}
#[test]
fn plists_parse() {
let p = parse_plist(b"<?xml version=\"1.0\"?><!-- c --><plist><dict><key>a&amp;b</key><string>x &lt;y&#x41;</string><key>n</key><integer>-3</integer><key>e</key><dict/><key>t</key><true/><key>d</key><data>AAEC</data><key>s</key><string/></dict></plist>").unwrap();
assert_eq!(p.get("a&b").and_then(Plist::str), Some("x <yA"));
assert_eq!(p.get("n").and_then(Plist::num), Some(-3.0));
assert_eq!(p.get("e"), Some(&Plist::Dict(vec![])));
assert_eq!(p.get("t"), Some(&Plist::Bool(true)));
assert_eq!(p.get("d"), Some(&Plist::Data));
assert_eq!(p.get("s").and_then(Plist::str), Some(""));
assert!(parse_plist(b"<plist><dict><key>a</key>").is_err());
assert!(parse_plist(b"bplist00\x01\x02").is_err());
}
#[test]
fn hostile_nesting_is_an_error_not_a_crash() {
let deep = format!("<plist>{}{}</plist>", "<array>".repeat(200_000), "</array>".repeat(200_000));
assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply"));
let ok = format!("<plist>{}<true/>{}</plist>", "<array>".repeat(20), "</array>".repeat(20));
assert!(parse_plist(ok.as_bytes()).is_ok());
}
#[test]
fn look_sliders_map_and_the_rest_is_reported() {
let i = read_presets("/x/Soft Contrast.lmp", modern().as_bytes(), None).unwrap().remove(0);
let p = &i.preset;
assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Soft Contrast", "Imported Presets", "user.lmp.ab12-cd34"));
let s = &p.settings;
assert_eq!(s["light"]["exposure"], json!(1.0), "±100 ↔ ±4 EV");
assert_eq!(s["light"]["contrast"], json!(12.0));
assert_eq!(s["light"]["highlights"], json!(-30.0));
assert!(s["light"].get("shadows").is_none(), "disabled layers are ignored");
assert_eq!(s["effects"]["clarity"], json!(20.0), "Structure → clarity");
assert_eq!(s["color"]["vibrance"], json!(15.0), "layer opacity scales the slider");
assert_eq!(s["color"]["saturation"], json!(-5.0));
assert_eq!(s["mixer"]["orange"]["hue"], json!(-8.0));
assert_eq!(s["mixer"]["blue"]["sat"], json!(12.0));
assert_eq!(s["mixer"]["green"]["lum"], json!(4.0));
assert_eq!(s["vignette"]["amount"], json!(-20.0));
assert_eq!(s["vignette"]["midpoint"], json!(40.0));
assert!(s["wb"]["temp"].as_f64().unwrap() > 6500.0, "a warm shift: {}", s["wb"]);
let c = s["curve"]["master"].as_array().unwrap();
assert_eq!(c.len(), 3);
assert!((c[0]["y"].as_f64().unwrap() - 0.1).abs() < 1e-9 && (c[2]["y"].as_f64().unwrap() - 0.9).abs() < 1e-9);
assert_eq!(i.unmapped, ["AIStructure.Boost", "OrtonFilter.Amount", "Contrast.Contrast", "Screened: blend mode Screen"]);
assert!(s["light"]["contrast"] == json!(12.0), "the screen-blended layer isn't folded in");
lightcraft_develop::DevelopSettings::default().merged(s).expect("valid develop settings");
}
#[test]
fn classic_looks_and_bundles() {
// the older flat layout: one layer per tool, the look's name inside
let layers = [
layer(
"ToneLayer",
1.0,
"Normal",
&[("MIPLExposureEffect", param("Exposure", 0.0)), ("MIPLHighlightsEffect", param("Shadows", 10.0))],
"",
),
layer("ClarityLayer", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""),
layer("Wb", 1.0, "Normal", &[("MIPLWhiteBalanceEffect", param("Temperature", -15.0) + &param("Tint", 10.0))], ""),
]
.concat();
let lmp = look(&layers, "<key>Name</key><string>Blue Hour</string>");
let i = read_presets("whatever.lmp", lmp.as_bytes(), Some("Mine".into())).unwrap().remove(0);
assert_eq!((i.preset.name.as_str(), i.preset.group.as_str()), ("Blue Hour", "Mine"));
assert_eq!(i.preset.settings["light"], json!({"shadows": 10.0}));
assert_eq!(i.preset.settings["effects"]["clarity"], json!(20.0));
assert_eq!(i.preset.settings["wb"]["tint"], json!(10.0));
assert!(i.preset.settings["wb"]["temp"].as_f64().unwrap() < 6500.0);
assert!(i.unmapped.is_empty(), "{:?}", i.unmapped);
// a bundle's inner file is named after the bundle
let b = read_presets("/p/Vintage.lmp/Contents/preset.lmp", modern().as_bytes(), None).unwrap();
assert_eq!(b[0].preset.name, "Vintage");
// nothing we can carry over: an error that says what was there
let only = look(&layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), "");
let e = read_presets("Glow.lmp", only.as_bytes(), None).unwrap_err();
assert!(e.contains("OrtonFilter.Amount"), "{e}");
assert!(read_presets("x.lmp", b"<plist><dict/></plist>", None).is_err());
}
#[test]
fn packs_group_by_their_name() {
use crate::preset_import::tests::zip;
let a = look(&layer("C", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), "<key>Name</key><string>Pop 1</string>");
let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), "");
let info = b"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>GroupName</key><string>Magic Light</string></dict></plist>";
let pack = zip(
&[
("Pop 1.lmp", a.as_bytes()),
("__MACOSX/._Pop 1.lmp", b"junk"),
("Pop 2.lmp", b.as_bytes()),
("icon@2x.png", b"\x89PNG"),
("PresetsInfo.plist", info),
],
true,
);
let v = read_presets("/d/Magic Light-2.mplumpack", &pack, None).unwrap();
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(got, [("Pop 1", "Magic Light"), ("Pop 2", "Magic Light")]);
// without PresetsInfo.plist the pack's file name is the group
let v = read_presets("Seaside Looks.mplumpack", &zip(&[("Pop 2.lmp", b.as_bytes())], false), None).unwrap();
assert_eq!(v[0].preset.group, "Seaside Looks");
// a pack inside a downloaded zip, and bundle-style looks in a zip folder
let outer = zip(&[("Archive/Magic Light-2.mplumpack", &pack), ("Archive/Magic Light.plist", b"<plist/>")], false);
assert_eq!(read_presets("download.zip", &outer, None).unwrap().len(), 2);
let bundles = zip(
&[
("Wild Pack/Vintage.lmp/Contents/preset.lmp", modern().as_bytes()),
("Wild Pack/Vintage.lmp/Contents/Info.plist", b"<plist><dict/></plist>"),
("Wild Pack/Flat.lmp", a.as_bytes()),
],
true,
);
let v = read_presets("wild.zip", &bundles, None).unwrap();
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
assert_eq!(got, [("Vintage", "Wild Pack"), ("Pop 1", "Wild Pack")]);
assert!(read_presets("empty.mplumpack", &zip(&[("icon.png", b"x")], false), None).is_err());
}
#[test]
fn import_command_reads_looks_and_bundle_folders() {
let dir = std::env::temp_dir().join(format!("lc-luminar-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Wild/Vintage.lmp/Contents/resources")).unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/preset.lmp"), modern()).unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/Info.plist"), "<plist><dict/></plist>").unwrap();
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/PkgInfo"), "LMP?????").unwrap();
let mut s = crate::Session::with_demo();
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild").to_string_lossy()]})).unwrap();
let im = &r["imported"][0];
assert_eq!((im["name"].as_str(), im["group"].as_str()), (Some("Vintage"), Some("Wild")), "{r}");
assert!(im["unmapped"].as_array().unwrap().iter().any(|u| u == "OrtonFilter.Amount"));
// a dropped bundle folder on its own
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild/Vintage.lmp").to_string_lossy()], "dryRun": true})).unwrap();
assert_eq!(r["imported"][0]["group"], json!("Imported Presets"), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+171 -6
View File
@@ -97,7 +97,7 @@ pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
}
} else {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if top || ext == LCPRESET_EXT || ext == "xmp" {
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
out.push(p.to_string_lossy().to_string());
}
}
@@ -245,6 +245,151 @@ pub fn builtin() -> Vec<Preset> {
"Cinematic",
json!({"curve": {"shadows": 20.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 190.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 30.0, "sat": 18.0, "lum": 0.0}}, "light": {"contrast": 15.0}, "vignette": {"amount": -20.0}}),
),
// ---- Portrait
p(
"Portrait",
"moody-portrait",
"Moody Portrait",
json!({"light": {"exposure": -0.25, "contrast": 20.0, "highlights": -30.0, "blacks": -12.0}, "color": {"saturation": -15.0}, "vignette": {"amount": -25.0, "midpoint": 40.0}, "mixer": {"orange": {"sat": 6.0}}}),
),
p(
"Portrait",
"golden-glow",
"Golden Glow",
json!({"grading": {"highlights": {"hue": 40.0, "sat": 18.0, "lum": 0.0}, "midtones": {"hue": 35.0, "sat": 8.0, "lum": 0.0}}, "light": {"shadows": 15.0}, "effects": {"clarity": -8.0}}),
),
p(
"Portrait",
"clean-studio",
"Clean Studio",
json!({"light": {"contrast": 10.0, "whites": 15.0, "blacks": -8.0}, "effects": {"texture": -10.0}, "color": {"vibrance": 5.0}}),
),
// ---- Landscape
p(
"Landscape",
"crisp-vista",
"Crisp Vista",
json!({"effects": {"dehaze": 15.0, "clarity": 18.0, "texture": 12.0}, "light": {"contrast": 12.0, "highlights": -25.0, "shadows": 18.0}, "color": {"vibrance": 20.0}}),
),
p(
"Landscape",
"deep-sky",
"Deep Sky",
json!({"mixer": {"blue": {"lum": -25.0, "sat": 15.0}, "aqua": {"lum": -10.0}}, "light": {"highlights": -30.0}, "effects": {"dehaze": 10.0}}),
),
p(
"Landscape",
"lush-greens",
"Lush Greens",
json!({"mixer": {"green": {"hue": 12.0, "sat": 18.0, "lum": 8.0}, "yellow": {"hue": 10.0, "sat": 10.0}}, "color": {"vibrance": 12.0}, "effects": {"clarity": 8.0}}),
),
p(
"Landscape",
"desert-warmth",
"Desert Warmth",
json!({"mixer": {"orange": {"sat": 15.0, "lum": 5.0}, "yellow": {"hue": -8.0, "sat": 10.0}, "blue": {"sat": -10.0}}, "grading": {"highlights": {"hue": 38.0, "sat": 12.0, "lum": 0.0}}, "light": {"contrast": 10.0}}),
),
p(
"Landscape",
"misty-morning",
"Misty Morning",
json!({"effects": {"dehaze": -18.0, "clarity": -12.0}, "light": {"contrast": -18.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 210.0, "sat": 10.0, "lum": 0.0}}, "color": {"saturation": -10.0}}),
),
// ---- Urban
p(
"Urban",
"gritty-street",
"Gritty Street",
json!({"effects": {"clarity": 35.0, "texture": 20.0}, "light": {"contrast": 25.0, "blacks": -15.0}, "color": {"saturation": -30.0}, "vignette": {"amount": -18.0}}),
),
p(
"Urban",
"neon-night",
"Neon Night",
json!({"mixer": {"magenta": {"sat": 20.0}, "purple": {"sat": 18.0}, "blue": {"sat": 12.0}, "aqua": {"sat": 15.0}}, "light": {"contrast": 18.0, "blacks": -12.0}, "color": {"vibrance": 15.0}, "grading": {"shadows": {"hue": 250.0, "sat": 15.0, "lum": 0.0}}}),
),
p(
"Urban",
"concrete-cool",
"Concrete Cool",
json!({"grading": {"shadows": {"hue": 205.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 200.0, "sat": 6.0, "lum": 0.0}}, "color": {"saturation": -20.0}, "light": {"contrast": 12.0}, "effects": {"clarity": 15.0}}),
),
p(
"Urban",
"faded-urban",
"Faded Urban",
json!({"curve": {"shadows": 30.0}, "color": {"saturation": -22.0}, "light": {"contrast": -8.0}, "grain": {"amount": 12.0, "size": 25.0, "roughness": 45.0}}),
),
// ---- Food
p(
"Food",
"fresh-bright",
"Fresh & Bright",
json!({"light": {"exposure": 0.25, "shadows": 20.0, "whites": 10.0}, "color": {"vibrance": 22.0}, "effects": {"texture": 15.0}}),
),
p(
"Food",
"warm-table",
"Warm Table",
json!({"grading": {"midtones": {"hue": 32.0, "sat": 10.0, "lum": 0.0}}, "mixer": {"orange": {"sat": 10.0}, "red": {"sat": 8.0}}, "light": {"contrast": 8.0}}),
),
p(
"Food",
"dark-moody-food",
"Dark & Moody",
json!({"light": {"exposure": -0.3, "contrast": 22.0, "highlights": -20.0, "blacks": -15.0}, "effects": {"texture": 18.0}, "vignette": {"amount": -22.0}}),
),
// ---- Seasons
p(
"Seasons",
"autumn-gold",
"Autumn Gold",
json!({"mixer": {"green": {"hue": -30.0, "sat": -10.0}, "yellow": {"hue": -15.0, "sat": 15.0}, "orange": {"sat": 18.0}}, "grading": {"highlights": {"hue": 40.0, "sat": 10.0, "lum": 0.0}}}),
),
p(
"Seasons",
"winter-blue",
"Winter Blue",
json!({"grading": {"shadows": {"hue": 215.0, "sat": 18.0, "lum": 0.0}, "highlights": {"hue": 205.0, "sat": 8.0, "lum": 0.0}}, "color": {"saturation": -12.0}, "light": {"whites": 12.0}}),
),
p(
"Seasons",
"spring-fresh",
"Spring Fresh",
json!({"mixer": {"green": {"hue": 10.0, "sat": 12.0, "lum": 10.0}, "magenta": {"sat": 10.0}}, "light": {"shadows": 15.0}, "color": {"vibrance": 15.0}}),
),
p(
"Seasons",
"summer-haze",
"Summer Haze",
json!({"effects": {"dehaze": -12.0}, "curve": {"shadows": 18.0}, "grading": {"highlights": {"hue": 45.0, "sat": 14.0, "lum": 0.0}}, "light": {"contrast": -10.0}}),
),
// ---- Vintage
p(
"Vintage",
"instant-70s",
"Instant ’70s",
json!({"curve": {"shadows": 28.0, "highlights": -18.0}, "grading": {"shadows": {"hue": 170.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 45.0, "sat": 18.0, "lum": 0.0}}, "color": {"saturation": -10.0}, "vignette": {"amount": -15.0}, "grain": {"amount": 20.0, "size": 30.0, "roughness": 55.0}}),
),
p(
"Vintage",
"cross-process",
"Cross Process",
json!({"grading": {"shadows": {"hue": 230.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 60.0, "sat": 25.0, "lum": 0.0}}, "light": {"contrast": 22.0}, "color": {"saturation": 10.0}}),
),
p(
"Vintage",
"bleach-bypass",
"Bleach Bypass",
json!({"color": {"saturation": -40.0}, "light": {"contrast": 35.0, "highlights": -15.0}, "effects": {"clarity": 15.0}}),
),
// ---- B&W toners
p(
"B&W",
"bw-sepia",
"Sepia Tone",
json!({"treatment": "bw", "grading": {"shadows": {"hue": 32.0, "sat": 22.0, "lum": 0.0}, "highlights": {"hue": 42.0, "sat": 18.0, "lum": 0.0}}, "curve": {"shadows": 10.0}}),
),
]
}
@@ -312,14 +457,34 @@ impl Session {
pub fn profile_menu(&self) -> Value {
let item =
|p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)});
let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id)).map(item).collect::<Vec<_>>();
let lut_item = |id: &str| {
self.lut_profiles
.iter()
.find(|p| p.id == id)
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
};
let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id).map(item).or_else(|| lut_item(id))).collect::<Vec<_>>();
let mut groups: Vec<Value> = profile_groups()
.into_iter()
.map(|g| json!({"name": g, "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>()}))
.collect();
// imported LUT profiles, by their groups
let mut lut_groups: Vec<&str> = self.lut_profiles.iter().map(|p| p.group.as_str()).collect();
lut_groups.sort_unstable();
lut_groups.dedup();
for g in lut_groups {
let profiles: Vec<Value> = self
.lut_profiles
.iter()
.filter(|p| p.group == g)
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
.collect();
groups.push(json!({"name": g, "profiles": profiles, "imported": true}));
}
json!({
"favorites": list(&self.profile_favorites),
"recent": list(&self.profile_recent),
"groups": profile_groups().into_iter().map(|g| json!({
"name": g,
"profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"groups": groups,
})
}
}
+31 -10
View File
@@ -67,6 +67,8 @@ pub struct SidecarData {
pub rating: Option<u8>,
pub flag: Option<Flag>,
pub label: Option<Option<ColorLabel>>,
/// The label's text as written (`xmp:Label`), for [`SidecarData::resolve_label`].
pub label_text: Option<String>,
pub title: Option<String>,
pub caption: Option<String>,
pub copyright: Option<String>,
@@ -121,11 +123,14 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
}
if let Some(l) = &m.label {
out.label = Some(ColorLabel::parse(l.trim()));
out.label_text = Some(l.trim().to_string());
}
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
out.develop = match full {
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
None if crate::crs::has_adjustments(&d.properties) => Some(DevelopPatch::Partial(crate::crs::to_partial(&d.properties, Some(raw)))),
None if crate::crs::has_adjustments(&d.properties) => {
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
}
None => None,
};
Ok(out)
@@ -164,7 +169,13 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
}
let develop = match &sc.develop {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => lightcraft_develop::apply_partial(&p.develop, v, 1.0),
Some(DevelopPatch::Partial(v)) => {
let mut v = v.clone();
if p.width > 0 && p.height > 0 {
crate::crs_masks::refit_radials(&mut v, crate::crs_masks::DEFAULT_ASPECT, p.width as f64 / p.height as f64);
}
lightcraft_develop::apply_partial(&p.develop, &v, 1.0)
}
None => return false,
};
if develop == *p.develop {
@@ -175,12 +186,20 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
true
}
fn label_name(l: ColorLabel) -> String {
format!("{l:?}")
impl SidecarData {
/// Read the label through the catalog's label names: other editors write the label's name
/// (a custom set's "To Do" as well as "Red"); unknown names clear the label.
pub fn resolve_label(mut self, cat: &lightcraft_catalog::Catalog) -> Self {
if let Some(t) = &self.label_text {
self.label = Some(if t.is_empty() { None } else { cat.label_from_name(t) });
}
self
}
}
/// The sidecar packet for a photo.
pub fn sidecar_packet(p: &Photo) -> String {
/// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom
/// label names included, as other editors do).
pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
let meta = lightcraft_meta::Metadata {
software: Some("LightCraft".into()),
@@ -196,7 +215,7 @@ pub fn sidecar_packet(p: &Photo) -> String {
artist: nz(&p.meta.creator),
keywords: p.meta.keywords.clone(),
rating: Some(p.rating.min(5) as i8),
label: p.label.map(label_name),
label: p.label.map(|l| cat.label_name(l)),
capture_time: p.captured.as_deref().and_then(lightcraft_meta::DateTime::parse_iso),
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
..Default::default()
@@ -267,7 +286,7 @@ impl Session {
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let path = sidecar_path(orig, self.xmp.naming);
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
write_atomic(&path, sidecar_packet(p, &self.catalog).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(path)
}
@@ -276,7 +295,9 @@ impl Session {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let Some(orig) = file_path(p) else { return Ok(None) };
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
.resolve_label(&self.catalog);
let mut q = (**p).clone();
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
let mut ops = vec![
@@ -344,7 +365,7 @@ mod tests {
#[test]
fn packet_roundtrip_restores_everything() {
let p = photo();
let x = sidecar_packet(&p);
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
let sc = parse_sidecar(&x, false).unwrap();
assert_eq!(sc.rating, Some(4));
assert_eq!(sc.flag, Some(Flag::Pick));
+99
View File
@@ -0,0 +1,99 @@
//! 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.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::Photo;
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_raster::{Rgb32f, Rgba8};
const MAGIC: &[u8] = b"LCSP1\n";
/// The proxy's file name for a photo (by its content, so copies share one).
pub fn file_name(p: &Photo) -> String {
let h = lightcraft_preview::Hasher128::new().str(&crate::media::content_key(p)).finish();
format!("{:032x}.lcsp", h.0)
}
/// Encode a source image as a smart preview.
pub fn encode(img: &Rgb32f) -> 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() {
1.0
} else {
let k = ((lum.len() as f64) * 0.9995) as usize;
let k = k.min(lum.len() - 1);
let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
v.max(1.0)
};
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
92,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.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());
out.extend_from_slice(&jpg);
Ok(out)
}
/// Decode a smart preview back into a source image.
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, 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 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)
}
/// Where a library keeps its smart previews.
pub fn dir(library: &Path) -> PathBuf {
library.join("Smart Previews")
}
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
decode(&b).map(Arc::new)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_keeps_the_picture() {
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
let back = decode(&encode(&img).unwrap()).unwrap();
assert_eq!((back.width, back.height), (img.width, img.height));
let mut err = 0.0f64;
for (a, b) in img.data.iter().zip(&back.data) {
for k in 0..3 {
let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0)));
err += ((x - y) as f64).abs();
}
}
let mean = err / (img.data.len() * 3) as f64;
assert!(mean < 0.02, "mean encoded error {mean}");
assert!(decode(b"nope").is_err());
}
}
+322
View File
@@ -381,3 +381,325 @@ fn paste_from_previous_and_copy_paste_metadata() {
s.execute("photo.pasteMetadata", &json!({"ids": [ids[2].0]})).unwrap();
assert_eq!(s.catalog.photo(ids[2]).unwrap().meta.creator, "Me");
}
#[test]
fn stacks_move_and_split() {
let mut s = demo();
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "active": ids[0].0})).unwrap();
s.execute("stack.group", &json!({"collapsed": false})).unwrap();
let order = |s: &Session| s.catalog.stack_of(ids[0]).or(s.catalog.stack_of(ids[4])).map(|st| st.photos.clone()).unwrap_or_default();
let before = order(&s);
assert_eq!(before.len(), 5);
s.execute("stack.moveUp", &json!({"id": before[2].0})).unwrap();
assert_eq!(order(&s)[1], before[2]);
s.execute("stack.moveDown", &json!({"id": before[2].0})).unwrap();
assert_eq!(order(&s), before);
// split at the 4th photo: 3 stay, 2 form a new stack
s.execute("stack.split", &json!({"id": before[3].0})).unwrap();
assert_eq!(s.catalog.stack_of(before[0]).unwrap().photos, before[..3].to_vec());
assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos, before[3..].to_vec());
// splitting at the last photo of a 2-stack leaves both unstacked
s.execute("stack.split", &json!({"id": before[4].0})).unwrap();
assert!(s.catalog.stack_of(before[3]).is_none() && s.catalog.stack_of(before[4]).is_none());
assert!(s.execute("stack.split", &json!({"id": before[0].0})).is_err(), "the top can't split");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos.len(), 2);
}
#[test]
fn filter_presets_save_and_apply() {
let mut s = demo();
assert!(s.execute("filter.savePreset", &json!({"name": "Empty"})).is_err(), "nothing to save");
s.execute("library.filter", &json!({"rating": 4})).unwrap();
let four = s.visible_cloned().len();
s.execute("filter.savePreset", &json!({"name": "Best"})).unwrap();
s.execute("library.clearFilter", &json!({})).unwrap();
assert!(s.visible_cloned().len() > four);
let r = s.execute("filter.applyPreset", &json!({"name": "best"})).unwrap();
assert_eq!(r["photos"].as_u64(), Some(four as u64));
s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap();
assert!(s.filter_presets.is_empty());
}
#[test]
fn auto_bw_mix_separates_colours() {
let mut s = demo();
s.execute("develop.autoBwMix", &json!({})).unwrap();
let d = active_dev(&s);
assert_eq!(d.treatment, lightcraft_develop::Treatment::Bw);
let m = d.bw_mix.bands();
assert!(m.iter().any(|v| *v != 0.0), "some band moved: {m:?}");
assert!(m.iter().all(|v| v.abs() <= 60.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_ne!(active_dev(&s).treatment, lightcraft_develop::Treatment::Bw, "one undo step");
}
#[test]
fn auto_sync_carries_only_the_changed_settings() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
// the second photo has its own contrast, which must survive
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
s.execute("develop.set", &json!({"control": "light.contrast", "value": 25})).unwrap();
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.selection.active = Some(PhotoId(ids[0]));
assert_eq!(s.execute("develop.autoSync", &json!({})).unwrap(), json!({"on": true}));
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap();
for id in &ids {
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().light.exposure, 0.7, "photo {id}");
}
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.contrast, 25.0, "untouched settings stay");
// a slider drag syncs once, on release
let shadows: Vec<f64> = ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect();
s.begin_interaction("Shadows").unwrap();
let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone();
d.light.shadows = 40.0;
s.set_develop(PhotoId(ids[0]), d, "Shadows").unwrap();
assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, shadows[2], "not while dragging");
s.end_interaction().unwrap();
assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, 40.0);
// one undo step reverts every photo
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect::<Vec<_>>(), shadows);
// spot removal belongs to one photo
let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone();
d.spots.push(lightcraft_develop::Spot::default());
s.set_develop(PhotoId(ids[0]), d, "Remove").unwrap();
assert!(s.develop_of(PhotoId(ids[1])).unwrap().spots.is_empty());
// off: only the active photo
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1})).unwrap();
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7);
}
#[test]
fn json_delta_keeps_only_changes() {
let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1});
let b = json!({"light": {"exposure": 1, "contrast": 5}, "masks": [1, 2], "x": 1});
assert_eq!(crate::json_delta(&a, &b), Some(json!({"light": {"exposure": 1}, "masks": [1, 2]})));
assert_eq!(crate::json_delta(&a, &a), None);
}
#[test]
fn build_previews_fills_the_cache() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
let cached = |s: &mut Session, id: u64| {
let t = s.thumb_job(PhotoId(id), 512).unwrap();
let (c, k) = t.cache.clone().unwrap();
let q = s.quick_view_job(PhotoId(id), 1024, true).unwrap();
let (vc, vk) = q.cached[0].clone();
(c.get(k).is_some(), vc.get(vk).is_some())
};
assert_eq!(cached(&mut s, ids[0]), (false, false));
let r = s.execute("library.buildPreviews", &json!({"ids": [ids[0], ids[1]], "size": "standard", "edge": 512, "wait": true})).unwrap();
assert_eq!((r["total"].as_u64(), r["done"].as_u64(), r["failed"].as_u64(), r["running"].as_bool()), (Some(2), Some(2), Some(0), Some(false)));
// view previews are written to the cache in the background
let t0 = std::time::Instant::now();
while cached(&mut s, ids[1]) != (true, true) && t0.elapsed().as_secs() < 20 {
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert_eq!(cached(&mut s, ids[0]), (true, true), "thumbnail and loupe view are ready");
assert_eq!(cached(&mut s, ids[2]), (false, false), "only the photos asked for");
// background build: returns at once, progress by command
let r = s.execute("library.buildPreviews", &json!({"ids": [ids[2]], "edge": 512})).unwrap();
assert_eq!(r["total"], 1);
let t0 = std::time::Instant::now();
loop {
let p = s.execute("library.previewProgress", &json!({})).unwrap();
if p["running"] == false {
assert_eq!(p["done"], 1);
break;
}
assert!(t0.elapsed().as_secs() < 60, "build finishes");
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err());
}
/// Colour range: a click samples the colour under it, so the mask selects that colour (white
/// in the mask view) and not a different one; ⇧ adds samples, up to five.
#[test]
fn color_range_sampling_selects_the_clicked_colour() {
use lightcraft_pipeline::{MaskView, Overlay};
let mut s = demo();
// a flower: magenta petals at the centre, green around them
let id = s.catalog.photos().find(|p| p.meta.keywords.iter().any(|k| k == "flower")).unwrap().id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
let mask = |s: &mut Session| {
let m = s.develop_of(id).unwrap().masks[0].clone();
let job = s.render_job(id, 200, 200, false, true).unwrap().with_overlay(Overlay::Mask {
id: m.id as u16,
view: MaskView::WhiteOnBlack,
color: [255, 0, 0],
opacity: 100,
});
job.run().rendered.unwrap().image
};
let at =
|img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * img.height as f64) as usize * img.width + (x * img.width as f64) as usize][0];
let r = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap();
assert_eq!(r["samples"].as_array().unwrap().len(), 1);
let img = mask(&mut s);
let (centre, corner) = (at(&img, 0.5, 0.5), at(&img, 0.03, 0.03));
assert!(centre > 200 && corner < centre / 2, "the sampled colour is selected: centre {centre}, corner {corner}");
// ⇧-click adds, at most five
for _ in 0..6 {
s.execute("mask.sampleColor", &json!({"x": 0.03, "y": 0.03, "add": true})).unwrap();
}
let n = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.52, "add": true})).unwrap()["samples"].as_array().unwrap().len();
assert_eq!(n, 5);
assert!(at(&mask(&mut s), 0.03, 0.03) > 200, "the added colour is selected too");
// a plain click starts over
assert_eq!(s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap()["samples"].as_array().unwrap().len(), 1);
assert!(s.execute("mask.sampleColor", &json!({"x": 1.5, "y": 0.5})).is_err());
}
#[test]
fn leaving_an_edited_photo_keeps_an_auto_version() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
let before = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
let undo = s.undo.len();
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
let v = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.clone();
assert_eq!(v.len(), before + 1);
assert!(v.last().unwrap().auto && v.last().unwrap().settings.light.exposure == 0.4);
assert_eq!(s.undo.len(), undo, "not an undo step");
// back and away again without changes: nothing new
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
assert_eq!(s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(), before + 1);
// at most AUTO_VERSIONS
for i in 0..(crate::AUTO_VERSIONS + 5) {
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.01 * i as f64 + 0.5})).unwrap();
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
}
let autos = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.iter().filter(|v| v.auto).count();
assert_eq!(autos, crate::AUTO_VERSIONS);
}
#[test]
fn match_total_exposures() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
// reference 1/100 f/4 ISO 100, slider +0.5; other 1/50 f/4 ISO 100 (one stop more light)
for (i, (sh, ap, iso)) in [("1/100", 4.0f32, 100u32), ("1/50", 4.0, 100), ("", 4.0, 100)].iter().enumerate() {
let mut m = s.catalog.photo(PhotoId(ids[i])).unwrap().meta.clone();
m.shutter = sh.to_string();
m.aperture = Some(*ap);
m.iso = Some(*iso);
s.commit("t", lightcraft_catalog::Op::SetMeta { id: PhotoId(ids[i]), meta: Box::new(m) }).unwrap();
}
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.selection.active = Some(PhotoId(ids[0]));
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap();
let r = s.execute("develop.matchExposure", &json!({})).unwrap();
assert_eq!((r["changed"].as_u64(), r["skipped"].clone()), (Some(1), json!([ids[2]])));
let e = s.develop_of(PhotoId(ids[1])).unwrap().light.exposure;
assert!((e - (-0.5)).abs() < 1e-9, "one stop more light → one stop less exposure: {e}");
}
#[test]
fn find_similar_filters_to_look_alikes() {
let mut s = demo();
let id = s.catalog.photos().next().unwrap().id;
let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": 0.5})).unwrap();
let hits: Vec<u64> = r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect();
assert_eq!(hits[0], id.0, "the photo itself is the closest");
assert!(hits.len() < s.catalog.len(), "not everything looks alike");
let vis = s.visible_cloned();
assert!(!vis.is_empty() && vis.iter().all(|v| hits.contains(&v.0)), "the view shows only them");
s.execute("library.clearFilter", &json!({})).unwrap();
assert!(s.visible_cloned().len() > vis.len());
}
#[test]
fn quick_develop_adds_to_each_photo() {
use lightcraft_catalog::PhotoId;
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap();
s.execute("library.select", &json!({"ids": ids})).unwrap();
let before: Vec<f64> = ids.iter().map(|i| s.develop_of(PhotoId(*i)).unwrap().light.exposure).collect();
s.execute("develop.quickAdjust", &json!({"control": "light.exposure", "delta": 1.0 / 3.0})).unwrap();
for (i, id) in ids.iter().enumerate() {
let e = s.develop_of(PhotoId(*id)).unwrap().light.exposure;
assert!((e - before[i] - 1.0 / 3.0).abs() < 0.02, "{e} vs {}", before[i]);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.develop_of(PhotoId(ids[0])).unwrap().light.exposure, before[0], "one undo step");
}
#[test]
fn mask_components_invert_duplicate_rename_change_mode_and_delete() {
use lightcraft_develop::MaskOp;
let mut s = demo();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
s.execute("mask.addComponent", &json!({"op": "subtract", "kind": "linear"})).unwrap();
let mask = |s: &Session| active_dev(s).masks[0].clone();
let id = mask(&s).id;
s.execute("mask.component", &json!({"component": 1, "action": "invert"})).unwrap();
assert!(mask(&s).components[1].invert);
s.execute("mask.component", &json!({"component": 1, "action": "op", "op": "intersect"})).unwrap();
assert_eq!(mask(&s).components[1].op, MaskOp::Intersect);
s.execute("mask.component", &json!({"component": 0, "action": "rename", "name": " Face "})).unwrap();
assert_eq!(mask(&s).components[0].name.as_deref(), Some("Face"));
s.execute("mask.component", &json!({"component": 0, "action": "duplicate"})).unwrap();
assert_eq!(mask(&s).components.len(), 3);
assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face"));
// a cleared name falls back to the kind; one undo step each
s.execute("mask.component", &json!({"component": 1, "action": "rename", "name": ""})).unwrap();
assert_eq!(mask(&s).components[1].name, None);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face"));
assert!(s.execute("mask.component", &json!({"component": 9, "action": "invert"})).is_err());
assert!(s.execute("mask.component", &json!({"component": 0, "action": "explode"})).is_err());
// deleting the last component deletes the mask
for _ in 0..3 {
s.execute("mask.component", &json!({"id": id, "component": 0, "action": "delete"})).unwrap();
}
assert!(active_dev(&s).masks.is_empty());
assert_eq!(s.active_mask, None);
}
#[test]
fn curve_reset_by_channel_and_whole() {
let mut s = demo();
let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]];
for ch in ["master", "red", "green", "blue"] {
s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap();
}
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap();
s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap();
// one channel
s.execute("curve.reset", &json!({"channel": "red"})).unwrap();
let c = active_dev(&s).curve;
assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets");
assert_eq!(c.shadows, 30.0);
// parametric only
s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap();
let c = active_dev(&s).curve;
assert_eq!(c.shadows, 0.0);
assert_eq!(c.master.len(), 4, "point curves stay");
// all point curves, then undo restores them in one step
s.execute("curve.reset", &json!({"channel": "point"})).unwrap();
let c = active_dev(&s).curve;
assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty());
assert_eq!(c.refine_saturation, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(active_dev(&s).curve.master.len(), 4);
// everything (the default)
s.execute("curve.reset", &json!({})).unwrap();
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
}
+339
View File
@@ -166,6 +166,9 @@ fn missing_files_are_found_and_relinked() {
std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap();
let m = s.execute("library.missing", &serde_json::json!({})).unwrap();
assert_eq!(m.as_array().map(Vec::len), Some(2));
s.execute("library.source", &serde_json::json!({"kind": "missing"})).unwrap();
assert_eq!(s.visible_cloned().len(), 2, "the Missing Photos source lists them");
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
// one by hand
let id = m[0]["id"].as_u64().unwrap();
let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string();
@@ -182,3 +185,339 @@ fn missing_files_are_found_and_relinked() {
assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recently_added_covers_recent_imports_newest_first() {
let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let png = |name: &str, seed: u8| {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
.unwrap();
std::fs::write(dir.join(name), b).unwrap();
dir.join(name).to_string_lossy().to_string()
};
let mut s = Session::new().with_fs();
// three imports: 60 days ago, 10 days ago, today
for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] {
let path = png(name, seed);
let w = when.to_string();
s.clock = Box::new(move || w.clone());
s.execute("library.import", &serde_json::json!({"paths": [path]})).unwrap();
}
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first");
let _ = std::fs::remove_dir_all(&dir);
}
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
/// import order), and a metadata preset on every photo.
#[test]
fn copy_with_destination_organize_rename_and_metadata_preset() {
let src = temp_dir("orgsrc");
let dest = temp_dir("orgdest");
write_png(&src.join("a.png"), 1);
write_png(&src.join("b.png"), 2);
let mut s = Session::new().with_fs();
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap();
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err());
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err());
let r = s
.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat",
"rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 2, "{r}");
let mut names: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
names.sort();
assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names");
assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination");
assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam"));
// by month, no library needed when a destination is given
let dest2 = temp_dir("orgdest2");
write_png(&src.join("c.png"), 3);
let r = s
.execute(
"library.import",
&json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap();
let lightcraft_catalog::Source::File { path } = &p.source else { panic!() };
let rel = Path::new(path).strip_prefix(&dest2).unwrap();
assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}");
assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7);
for d in [&src, &dest, &dest2] {
let _ = std::fs::remove_dir_all(d);
}
}
/// Auto import: files in the watched folder are added once their size held between two scans,
/// into the named album; non-photos are tried once; the selection stays put.
#[test]
fn auto_import_watched_folder() {
let dir = temp_dir("watch");
let mut s = Session::new().with_fs();
assert!(s.execute("library.autoImport", &json!({"folder": dir.join("nope").to_string_lossy()})).is_err());
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap();
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
write_png(&dir.join("one.png"), 1);
std::fs::write(dir.join("notes.txt"), "not a photo").unwrap();
// first sight: wait (it may still be copying)
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
let r = s.execute("library.autoImportScan", &json!({})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id;
assert_eq!(s.catalog.album_count(album), 1);
assert!(s.selection.ids.is_empty(), "arrivals don't take the selection");
// nothing new: nothing happens, and the text file isn't retried
for _ in 0..2 {
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
}
write_png(&dir.join("two.png"), 2);
s.execute("library.autoImportScan", &json!({})).unwrap();
s.execute("library.autoImportScan", &json!({})).unwrap();
assert_eq!(s.catalog.album_count(album), 2);
s.execute("library.autoImport", &json!({"folder": null})).unwrap();
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["folder"], json!(null));
let _ = std::fs::remove_dir_all(&dir);
}
/// Smart previews: with the original offline the photo still renders (and edits apply) from
/// its proxy; without the proxy it can't be opened.
#[test]
fn smart_previews_stand_in_for_offline_originals() {
let src = temp_dir("smartsrc");
let lib = temp_dir("smartlib");
write_png(&src.join("a.png"), 7);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
assert_eq!(r["built"], 1, "{r}");
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap(), json!({"smartPreview": true, "originalOnline": true}));
let online = s.render_now(id, 48, 32).unwrap().image;
// the drive goes away
std::fs::rename(&src, src.with_extension("offline")).unwrap();
s.media.forget(id);
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["originalOnline"], false);
let offline = s.render_now(id, 48, 32).expect("renders from the smart preview").image;
let diff: f64 = online.data.iter().zip(&offline.data).map(|(a, b)| (a[0] as f64 - b[0] as f64).abs()).sum::<f64>() / online.data.len() as f64;
assert!(diff < 6.0, "the proxy looks like the original: {diff}");
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
s.media.forget(id);
assert_ne!(s.render_now(id, 48, 32).unwrap().image.data, offline.data, "edits apply offline");
// without the proxy it can't be opened
s.execute("library.smartPreviews", &json!({"discard": true})).unwrap();
s.media.forget(id);
assert!(s.render_now(id, 48, 32).is_err());
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
let _ = std::fs::remove_dir_all(&lib);
}
/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently
/// (Amount 0 = no change), is listed in the profile browser and comes back with the library.
#[test]
fn cube_luts_become_profiles() {
let src = temp_dir("cubesrc");
let lib = temp_dir("cubelib");
// a warm look: red up, blue down
let n = 9;
let mut cube = String::from("TITLE \"Warm Test\"\nLUT_3D_SIZE 9\n");
for b in 0..n {
for g in 0..n {
for r in 0..n {
let f = |v: usize| v as f32 / (n - 1) as f32;
cube.push_str(&format!("{} {} {}\n", (f(r) * 1.15).min(1.0), f(g), f(b) * 0.8));
}
}
}
std::fs::create_dir_all(src.join("Film Looks")).unwrap();
std::fs::write(src.join("Film Looks/warm.cube"), &cube).unwrap();
std::fs::write(src.join("Film Looks/broken.cube"), "LUT_3D_SIZE 4\n0 0 0\n").unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, true).unwrap();
let r = s.execute("profile.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!((r["imported"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (1, 1), "{r}");
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
assert_eq!((r["imported"][0]["name"].as_str(), r["imported"][0]["group"].as_str()), (Some("Warm Test"), Some("Film Looks")));
let menu = s.profile_menu();
assert!(menu["groups"].as_array().unwrap().iter().any(|g| g["name"] == "Film Looks"), "{menu}");
let photo = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
let base = s.render_now(photo, 48, 32).unwrap().image;
s.execute("develop.profile", &json!({"id": id, "amount": 100})).unwrap();
let warm = s.render_now(photo, 48, 32).unwrap().image;
let mean = |img: &lightcraft_raster::Rgba8, k: usize| img.data.iter().map(|p| p[k] as f64).sum::<f64>() / img.data.len() as f64;
assert!(
mean(&warm, 0) > mean(&base, 0) && mean(&warm, 2) < mean(&base, 2),
"warmer: R {} → {}, B {} → {}",
mean(&base, 0),
mean(&warm, 0),
mean(&base, 2),
mean(&warm, 2)
);
s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap();
assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was");
// the library keeps it
drop(s);
lightcraft_pipeline::lut::unregister(&id);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(s.lut_profiles.iter().any(|p| p.id == id));
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
assert!(lightcraft_pipeline::lut::get(&id).is_none());
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and
/// still render; clashes and moving into itself are refused.
#[test]
fn folders_rename_and_move_with_their_photos() {
let root = temp_dir("folders");
let a = root.join("Trip");
write_png(&a.join("one.png"), 3);
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
std::fs::create_dir_all(root.join("Taken")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Taken"})).is_err(), "name taken");
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "a/b"})).is_err());
let r = s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy 2026"})).unwrap();
assert_eq!(r["relinked"], 1);
let b = root.join("Italy 2026");
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => panic!(),
};
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
s.media.forget(id);
assert!(s.render_now(id, 16, 16).is_ok());
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
assert_eq!(r["relinked"], 1);
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
let _ = std::fs::remove_dir_all(&root);
}
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
#[test]
fn assisted_culling_groups_bursts_and_scores_focus() {
let dir = temp_dir("cull");
let scene = lightcraft_scenes::demo_library()[0].render(480, 320);
let soften = |img: &lightcraft_raster::Rgb32f, r: usize| {
let (w, h) = (img.width, img.height);
let mut out = img.clone();
for y in 0..h {
for x in 0..w {
let (mut acc, mut n) = ([0f32; 3], 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
let c = img.data[yy * w + xx];
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
n += 1.0;
}
}
out.data[y * w + x] = acc.map(|v| v / n);
}
}
out
};
let save = |img: &lightcraft_raster::Rgb32f, name: &str| {
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let png = lightcraft_codecs::encode_png(
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
&Default::default(),
)
.unwrap();
std::fs::write(dir.join(name), png).unwrap();
};
save(&soften(&scene, 3), "a_soft.png");
save(&scene, "b_sharp.png");
save(&soften(&scene, 6), "c_softer.png");
save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png");
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() {
let id = by_name(&s, n);
s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();
}
let other = by_name(&s, "d_other.png");
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();
let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap();
assert_eq!(r["groups"], 1, "{r}");
let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();
let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png"));
eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness);
assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness);
assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group);
assert!(odd.group.is_none(), "an unrelated photo isn't in the burst");
assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick);
// reject the blurry ones (by score), as a rule too
let cut = (soft.sharpness + sharp.sharpness) / 2.0;
s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap();
assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject);
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap();
let vis = s.visible_cloned();
assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other));
let _ = std::fs::remove_dir_all(&dir);
}
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
#[test]
fn dust_spots_are_found_and_healed() {
let dir = temp_dir("dust");
let (w, h) = (900usize, 600usize);
let spots = [(180.0f32, 140.0f32), (600.0, 300.0)];
let data: Vec<[u8; 4]> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let mut v = 0.5 + 0.3 * (y / h as f32);
for (cx, cy) in spots {
v -= 0.07 * (-((x - cx).powi(2) + (y - cy).powi(2)) / (2.0 * 25.0)).exp();
}
let b = (v * 255.0) as u8;
[b, b, (b as f32 * 1.15).min(255.0) as u8, 255]
})
.collect();
let png = lightcraft_codecs::encode_png(
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: w, height: h, data }),
&Default::default(),
)
.unwrap();
std::fs::write(dir.join("sky.png"), png).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [dir.join("sky.png").to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let before = s.render_now(id, 900, 600).unwrap().image;
let r = s.execute("spot.findDust", &json!({})).unwrap();
assert_eq!(r["added"], 2, "{r}");
let after = s.render_now(id, 900, 600).unwrap().image;
let at = |img: &lightcraft_raster::Rgba8, x: usize, y: usize| img.data[y * 900 + x][0] as i32;
for (cx, cy) in spots {
assert!(at(&after, cx as usize, cy as usize) > at(&before, cx as usize, cy as usize) + 6, "the spot at {cx},{cy} is lifted");
}
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step");
let _ = std::fs::remove_dir_all(&dir);
}
+86
View File
@@ -315,3 +315,89 @@ fn import_review_preview_and_options() {
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Label sets: built-ins, the one in use, saving (persisted with the library's prefs) and the
/// names written to / read from XMP.
#[test]
fn label_sets_and_xmp_label_names() {
use lightcraft_catalog::ColorLabel;
let dir = std::env::temp_dir().join(format!("lc-labelsets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let sets = s.execute("label.sets", &json!({})).unwrap();
assert_eq!(sets["current"], "Colors");
s.execute("label.applySet", &json!({"name": "review"})).unwrap();
assert_eq!(s.catalog.label_name(ColorLabel::Green), "Approved");
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], "Review");
// tweak one name, save as a user set; built-in names are refused
s.execute("label.setNames", &json!({"names": {"purple": "Client"}})).unwrap();
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], json!(null));
assert!(s.execute("label.saveSet", &json!({"name": "Colors"})).is_err());
s.execute("label.saveSet", &json!({"name": "Studio"})).unwrap();
s.execute("label.applySet", &json!({"name": "Colors"})).unwrap();
assert_eq!(s.catalog.custom_label_name(ColorLabel::Green), None);
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
s.execute("label.applySet", &json!({"name": "studio"})).unwrap();
assert_eq!(s.catalog.label_name(ColorLabel::Purple), "Client");
// XMP: the label's name is written, and read back through the names
let mut p = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
lightcraft_catalog::Source::Demo { scene: 0 },
"a.jpg",
"JPEG",
4,
4,
"2026-01-01T00:00:00",
);
p.label = Some(ColorLabel::Green);
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
assert!(x.contains("Approved"), "{x}");
let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog);
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog);
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
let _ = std::fs::remove_dir_all(&dir);
}
/// Keyword sets: Recent Keywords fill as keywords are added; ⌥N toggles keyword N on the
/// selection (added unless every photo has it) without reshuffling the numbers; user sets.
#[test]
fn keyword_sets_and_recent_keywords() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": ["beach", "sunset"]})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": ["family"]})).unwrap();
let sets = s.execute("keyword.sets", &json!({})).unwrap();
assert_eq!(sets["current"], "Recent Keywords");
assert_eq!(sets["keywords"], json!(["family", "beach", "sunset"]), "newest first");
// ⌥3 on both photos: added to the one without it, so both have it
s.execute("library.select", &json!({"ids": ids})).unwrap();
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
assert_eq!((r["keyword"].as_str(), r["added"].as_bool()), (Some("sunset"), Some(true)));
let has = |s: &Session, id: u64, k: &str| s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.iter().any(|x| x == k);
assert!(has(&s, ids[0], "sunset") && has(&s, ids[1], "sunset"));
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["keywords"][2], "sunset", "the numbers stay put");
// again: everyone has it, so it's removed
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
assert_eq!(r["added"], false);
assert!(!has(&s, ids[0], "sunset") && !has(&s, ids[1], "sunset"));
assert_eq!(s.execute("keyword.toggleFromSet", &json!({"index": 9})).unwrap()["changed"], 0, "an empty slot does nothing");
assert!(s.execute("keyword.toggleFromSet", &json!({"index": 10})).is_err());
// a saved set becomes current; deleting it falls back to Recent Keywords
let r = s.execute("keyword.saveSet", &json!({"name": "Wedding", "keywords": ["ceremony", "rings", "first dance"]})).unwrap();
assert_eq!((r["current"].as_str(), r["keywords"][1].as_str()), (Some("Wedding"), Some("rings")));
s.execute("keyword.toggleFromSet", &json!({"index": 2})).unwrap();
assert!(has(&s, ids[1], "rings"));
assert!(s.execute("keyword.saveSet", &json!({"name": "Recent Keywords"})).is_err(), "reserved");
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
s.execute("keyword.useSet", &json!({"name": "wedding"})).unwrap();
assert_eq!(s.keyword_set.as_deref(), Some("Wedding"));
s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap();
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords");
}
+167
View File
@@ -152,3 +152,170 @@ fn profile_favorites_and_recent_survive_reopen() {
assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true));
let _ = std::fs::remove_dir_all(&dir);
}
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
#[test]
fn smart_album_rule_sets() {
let mut s = crate::Session::with_demo();
s.clock = Box::new(|| "2026-10-02T12:00:00".to_string());
let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap();
assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3));
let bad = s.execute(
"album.createSmart",
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
);
assert!(bad.is_err(), "operators are checked");
let r = s
.execute(
"album.createSmart",
&serde_json::json!({"name": "Good ones", "rules": {"ruleSet": {"match": "all", "rules": [
{"field": "rating", "op": "gte", "value": 4},
{"group": {"match": "none", "rules": [{"field": "flag", "op": "is", "value": "reject"}]}}
]}}}),
)
.unwrap();
let id = lightcraft_catalog::AlbumId(r["id"].as_u64().unwrap());
let want = s.catalog.photos().filter(|p| !p.deleted && p.rating >= 4 && p.flag != lightcraft_catalog::Flag::Reject).count();
assert!(want > 0);
assert_eq!(s.catalog.album_count(id), want);
// live: a new 5-star photo joins
let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id;
s.execute("photo.rate", &serde_json::json!({"ids": [other.0], "rating": 5})).unwrap();
assert_eq!(s.catalog.album_count(id), want + 1);
// the same rules work as a library filter (agents: library.filter {ruleSet})
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "is", "value": 5}]}})).unwrap();
assert!(s.visible_cloned().iter().all(|id| s.catalog.photo(*id).unwrap().rating == 5));
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
let a = list.as_array().unwrap().iter().find(|a| a["name"] == "Good ones").unwrap().clone();
assert!(a.to_string().contains("rating is ≥ 4"), "{a}");
}
/// The filter bar's label multi-select: any of the chosen labels.
#[test]
fn filter_any_of_several_labels() {
use lightcraft_catalog::ColorLabel;
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, l) in ids.iter().zip(["red", "yellow", "green"]) {
s.execute("photo.label", &serde_json::json!({"ids": [id], "label": l})).unwrap();
}
s.execute("library.filter", &serde_json::json!({"labels": ["red", "yellow"]})).unwrap();
let vis = s.visible_cloned();
let want = s.catalog.photos().filter(|p| p.in_library() && matches!(p.label, Some(ColorLabel::Red | ColorLabel::Yellow))).count();
assert_eq!(want, 2);
assert_eq!(vis.len(), want);
assert!(vis.iter().all(|id| matches!(s.catalog.photo(*id).unwrap().label, Some(ColorLabel::Red | ColorLabel::Yellow))));
assert!(s.filter.describe().contains("label red or yellow"));
s.execute("library.filter", &serde_json::json!({"labels": []})).unwrap();
assert!(s.visible_cloned().len() > want);
}
/// Quick Collection / target album: B toggles the selection in it (created on first use);
/// another album can be the target; clear empties it; all undoable.
#[test]
fn quick_collection_and_target_album() {
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
assert!(s.catalog.quick_collection().is_none());
s.execute("library.select", &serde_json::json!({"ids": [ids[0], ids[1]]})).unwrap();
let r = s.execute("album.toggleTarget", &serde_json::json!({})).unwrap();
assert_eq!((r["added"].as_bool(), r["count"].as_u64(), r["name"].as_str()), (Some(true), Some(2), Some("Quick Collection")));
let quick = s.catalog.quick_collection().expect("created");
// again: both are in it, so they come out
assert_eq!(s.execute("album.toggleTarget", &serde_json::json!({})).unwrap()["count"], 0);
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!(s.catalog.album_count(quick), 2);
// a regular album as the target
let alb = s.execute("album.create", &serde_json::json!({"name": "Picks"})).unwrap()["id"].as_u64().unwrap();
s.execute("album.setTarget", &serde_json::json!({"id": alb})).unwrap();
s.execute("album.toggleTarget", &serde_json::json!({"ids": [ids[2]]})).unwrap();
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 1);
assert_eq!(s.catalog.album_count(quick), 2, "the Quick Collection is untouched");
let smart = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("album.setTarget", &serde_json::json!({"id": smart})).is_err());
s.execute("album.setTarget", &serde_json::json!({"id": null})).unwrap();
s.execute("album.clearQuick", &serde_json::json!({})).unwrap();
assert_eq!(s.catalog.album_count(quick), 0);
}
/// Scaling check (ignored: `cargo test --release -p lightcraft-engine -- --ignored scale --nocapture`):
/// the per-frame / per-click library queries on a 100k-photo catalog.
#[test]
#[ignore]
fn scale_100k_library_queries() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use std::time::Instant;
let mut s = crate::Session::new();
let n = 100_000u64;
let mut ops = Vec::with_capacity(n as usize);
for i in 0..n {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
p.rating = (i % 6) as u8;
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
p.meta.camera = format!("Model {}", i % 30);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let t = Instant::now();
s.commit("Add", Op::Batch { ops }).unwrap();
eprintln!("add 100k: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
let time = |what: &str, f: &mut dyn FnMut()| {
let t = Instant::now();
f();
let ms = t.elapsed().as_secs_f64() * 1e3;
eprintln!("{what}: {ms:.1} ms");
ms
};
let mut worst: Vec<(String, f64)> = Vec::new();
let mut m = |w: &str, f: &mut dyn FnMut()| worst.push((w.to_string(), time(w, f)));
m("visible (all)", &mut || {
let _ = s.visible_cloned();
});
m("visible (cached)", &mut || {
let _ = s.visible_cloned();
});
m("filter rating ≥ 4", &mut || {
s.execute("library.filter", &serde_json::json!({"rating": 4})).unwrap();
let _ = s.visible_cloned();
});
m("filter ruleSet keyword + date", &mut || {
s.execute("library.filter", &serde_json::json!({"rating": 0, "ruleSet": {"rules": [{"field": "keywords", "op": "contains", "value": "kw42"}, {"field": "captureDate", "op": "is", "value": "2015"}]}})).unwrap();
let _ = s.visible_cloned();
});
s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap();
m("date groups", &mut || {
let _ = s.execute("library.groups", &serde_json::json!({})).unwrap();
});
m("keyword tree", &mut || {
let _ = s.execute("keyword.list", &serde_json::json!({})).unwrap();
});
m("keyword suggestions", &mut || {
let _ = s.catalog.keyword_suggestions(&["kw1".to_string()], "kw", 12);
});
let alb = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 5}})).unwrap()["id"].as_u64().unwrap();
m("smart album count", &mut || {
let _ = s.catalog.album_count(lightcraft_catalog::AlbumId(alb));
});
m("albums.list", &mut || {
let _ = s.execute("albums.list", &serde_json::json!({})).unwrap();
});
m("select all", &mut || {
s.execute("library.selectAll", &serde_json::json!({})).unwrap();
});
m("rate 100k selected", &mut || {
s.execute("photo.rate", &serde_json::json!({"rating": 3})).unwrap();
});
m("undo", &mut || {
s.execute("edit.undo", &serde_json::json!({})).unwrap();
});
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
eprintln!("slowest: {:?}", &worst[..3]);
}
+4
View File
@@ -60,6 +60,10 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
// the type filter finds merge results (and only them)
s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();
let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();
assert_eq!(found, [id, id2].into_iter().collect());
let _ = std::fs::remove_dir_all(&dir);
}
+24
View File
@@ -176,3 +176,27 @@ fn stacks_group_collapse_and_replay() {
assert_eq!(s2.catalog.stacks().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn album_source_reports_in_library_state_and_persists_the_view() {
// An album source made `library.state` panic (serde can't put an integer in an internally
// tagged enum) and `view.json` empty, so the view was lost on the next launch.
let dir = temp_dir("album-view");
let mut s = open(&dir);
let id = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
let st = s.execute("library.state", &json!({})).unwrap();
assert_eq!(st["source"], json!({"kind": "album", "id": id}));
// the reported source is what `library.source` takes
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.execute("library.source", &st["source"]).unwrap();
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
assert_eq!(s.execute("library.state", &json!({})).unwrap()["source"], json!({"kind": "album", "id": id}));
s.close_library().unwrap();
drop(s);
let s2 = open(&dir);
assert_eq!(s2.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too");
let _ = std::fs::remove_dir_all(&dir);
}
+98
View File
@@ -163,3 +163,101 @@ fn default_copyright_and_creator_fill_gaps_on_import() {
assert_eq!(s.import_defaults.creator, "Me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metadata_presets_apply_to_photos_and_imports() {
let dir = temp_dir("metapresets");
let lib = dir.join("lib");
png(&dir.join("new.png"));
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Ann", "keywords": ["studio"]}}))
.unwrap();
assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"iso": "100"}})).is_err());
// on import
s.execute("library.preferences", &json!({"import": {"metadataPreset": "studio"}})).unwrap();
assert!(s.execute("library.preferences", &json!({"import": {"metadataPreset": "nope"}})).is_err());
s.execute("library.import", &json!({"paths": [dir.join("new.png").to_string_lossy()]})).unwrap();
let p = photo_named(&s, "new.png");
assert_eq!((p.meta.copyright.as_str(), p.meta.creator.as_str(), p.meta.keywords.clone()), ("© Studio", "Ann", vec!["studio".to_string()]));
let id = p.id;
// applied to a photo: text replaces, keywords add (one undo step)
s.execute("photo.setMeta", &json!({"ids": [id.0], "keywords": ["mine"], "creator": "Bob"})).unwrap();
s.execute("metadata.applyPreset", &json!({"name": "Studio", "ids": [id.0]})).unwrap();
let m = &s.catalog.photo(id).unwrap().meta;
assert_eq!(m.creator, "Ann");
assert_eq!(m.keywords, vec!["mine".to_string(), "studio".to_string()]);
// from the active photo, then persisted
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.setMeta", &json!({"city": "Lyon"})).unwrap();
s.execute("metadata.savePreset", &json!({"name": "Here"})).unwrap();
let saved = s.metadata_presets.iter().find(|m| m.name == "Here").unwrap().fields.clone();
assert_eq!(saved, json!({"copyright": "© Studio", "creator": "Ann", "city": "Lyon"}));
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.metadata_presets.len(), 2);
assert_eq!(s.import_defaults.metadata_preset.as_deref(), Some("studio"));
s.execute("metadata.deletePreset", &json!({"name": "STUDIO"})).unwrap();
assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn curve_presets_save_apply_export_import_and_persist() {
let lib = temp_dir("curves");
let file = lib.join("out").join("looks");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, true).unwrap();
let r = s.execute("curve.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]);
assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves");
// built-in: an S-curve on the master channel, parametric untouched
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap();
s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
assert_eq!(d.curve.master.len(), 5);
assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve");
assert_eq!(d.curve.shadows, 20.0);
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast");
// save a custom one (with a red channel)
s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap();
assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved");
s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap();
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red");
// Linear resets every point channel
s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap();
let d = s.develop_of(s.active().unwrap()).unwrap();
assert!(d.curve.master.is_empty() && d.curve.red.is_empty());
s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap();
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2);
// export (extension added), delete, re-import
let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap();
assert_eq!(r["count"], 1);
let path = r["path"].as_str().unwrap().to_string();
assert!(path.ends_with(".lccurve"));
assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err());
s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap();
assert!(s.curve_presets.is_empty());
let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap();
assert_eq!(r["imported"], json!(["Faded Red"]));
assert_eq!(s.curve_presets.len(), 1);
// a file carrying a built-in name doesn't shadow it; junk fails cleanly
let other = lib.join("one.lccurve");
std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap();
let junk = lib.join("junk.lccurve");
std::fs::write(&junk, "not json").unwrap();
let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap();
assert_eq!(r["imported"], json!(["Linear (imported)"]));
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
s.close_library().unwrap();
}
// saved with the library preferences
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let names: Vec<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
let _ = std::fs::remove_dir_all(&lib);
}
+196
View File
@@ -249,3 +249,199 @@ fn demo_photos_have_no_sidecar() {
assert_eq!(r["written"].as_array().unwrap().len(), 0);
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
}
/// All Metadata: EXIF rows from the file and its XMP, for a photo on disk.
#[test]
fn all_metadata_lists_exif_and_xmp() {
let dir = temp_dir("allmeta");
let path = dir.join("tagged.jpg");
let img = lightcraft_raster::Rgba8 { width: 16, height: 8, data: vec![[90, 120, 200, 255]; 128] };
let exif = lightcraft_meta::write_exif(&lightcraft_meta::Metadata { make: Some("Maker".into()), iso: Some(800), ..Default::default() });
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
let jpg = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
std::fs::write(&path, jpg).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
s.execute("photo.setMeta", &json!({"title": "A title"})).unwrap();
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
let r = s.execute("photo.allMetadata", &json!({})).unwrap();
let exif = r["exif"].as_array().unwrap();
assert!(exif.iter().any(|e| e["name"] == "Make" && e["value"] == "Maker"), "{r}");
assert!(exif.iter().any(|e| e["name"] == "ISO Speed" && e["value"] == "800"));
assert!(r["xmp"].as_array().unwrap().iter().any(|x| x["name"] == "dc:title" && x["value"] == "A title"), "the sidecar's fields: {r}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Convert to DNG: a corpus raw (skipped without the corpus) becomes a DNG next to it with the
/// photo relinked and its edits kept; undo goes back to the original; non-raws are skipped.
#[test]
fn convert_raw_to_dng() {
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d5100-uncompressed.nef");
let mut s = Session::new().with_fs();
// a non-raw is skipped
let dir = temp_dir("todng");
let png = dir.join("a.png");
write_png(&png, 3);
s.execute("library.import", &json!({"paths": [png.to_string_lossy()]})).unwrap();
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1));
if !corpus.exists() {
eprintln!("skip: {} absent", corpus.display());
return;
}
let nef = dir.join("shot.nef");
std::fs::copy(&corpus, &nef).unwrap();
s.execute("library.import", &json!({"paths": [nef.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
assert_eq!(r["converted"].as_array().unwrap().len(), 1, "{r}");
let p = s.catalog.photo(id).unwrap().clone();
assert_eq!((p.file_name.as_str(), p.format.as_str()), ("shot.dng", "DNG"));
assert!(dir.join("shot.dng").exists() && nef.exists(), "the original is kept");
assert_eq!(p.develop.light.exposure, 0.6, "edits kept");
let bytes = std::fs::read(dir.join("shot.dng")).unwrap();
assert_eq!(lightcraft_raw::probe(&bytes), Some(lightcraft_raw::RawFormat::Dng));
assert!(s.render_now(id, 64, 64).is_ok(), "the DNG renders");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().file_name, "shot.nef");
// Copy as DNG at import: only the DNG lands in the destination
let card = dir.join("card");
std::fs::create_dir_all(&card).unwrap();
std::fs::copy(&corpus, card.join("DSC_1.NEF")).unwrap();
let dest = dir.join("dest");
let mut s = Session::new().with_fs();
let r = s
.execute(
"library.import",
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", "dng": true}),
)
.unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
let names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
assert_eq!(names, ["DSC_1.dng"]);
assert!(card.join("DSC_1.NEF").exists(), "the card is untouched");
let id2 = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
assert_eq!(s.catalog.photo(id2).unwrap().format, "DNG");
let _ = std::fs::remove_dir_all(&dir);
}
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
#[test]
fn external_edit_copy_is_stacked() {
let dir = temp_dir("extedit");
let src = dir.join("beach.png");
write_png(&src, 9);
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let orig = s.active().unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let r = s.execute("photo.editExternal", &json!({})).unwrap();
let path = r["path"].as_str().unwrap().to_string();
assert!(path.ends_with("beach-Edit.tif"), "{path}");
let bytes = std::fs::read(&path).unwrap();
let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
assert_eq!(d.bit_depth, 16);
let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
assert_eq!(s.active(), Some(new));
let st = s.catalog.stack_of(new).expect("stacked");
assert_eq!((st.top(), st.photos.contains(&orig)), (new, true));
let r2 = s.execute("photo.editExternal", &json!({"colorSpace": "proPhoto"})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("beach-Edit-Edit.tif") || r2["path"].as_str().unwrap().contains("-2"), "{r2}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the
/// cached source is dropped and renders change; unchanged files are left alone.
#[test]
fn reload_picks_up_changed_files() {
let dir = temp_dir("reload");
let f = dir.join("x.png");
write_png(&f, 10);
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let before = s.render_now(id, 32, 32).unwrap().image.data;
let key0 = s.thumb_job(id, 128).unwrap().key;
assert_eq!(s.execute("photo.reload", &json!({})).unwrap()["reloaded"], json!([]), "unchanged");
write_png(&f, 200);
let r = s.execute("photo.reload", &json!({})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]));
assert_ne!(s.thumb_job(id, 128).unwrap().key, key0, "the UI sees a new render key");
assert_ne!(s.render_now(id, 32, 32).unwrap().image.data, before, "renders the new pixels");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn duplicate_copies_the_file_and_the_edits() {
let dir = temp_dir("dup");
let f = dir.join("pic.png");
write_png(&f, 5);
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
let orig = s.active().unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.8})).unwrap();
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let alb = s.execute("album.create", &json!({"name": "Keep", "addSelected": true})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("photo.duplicate", &json!({})).unwrap();
let dup = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
let d = s.catalog.photo(dup).unwrap().clone();
assert_eq!(d.file_name, "pic-copy.png");
assert!(dir.join("pic-copy.png").exists());
assert_eq!((d.develop.light.exposure, d.rating), (0.8, 4));
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 2);
assert_ne!(dup, orig);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.catalog.photo(dup).is_none(), "one undo step");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gps_typed_in_is_saved_to_xmp() {
let mut s = Session::with_demo();
let id = s.active().unwrap_or_else(|| s.catalog.photos().next().unwrap().id);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.setMeta", &json!({"gps": "48°51'30\"N 2°17'40\"E"})).unwrap();
let (la, lo) = s.catalog.photo(id).unwrap().meta.gps.unwrap();
assert!((la - 48.8583).abs() < 1e-3 && (lo - 2.2944).abs() < 1e-3, "{la} {lo}");
let x = crate::sidecar::sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
let back = lightcraft_meta::parse_xmp(&x).unwrap().metadata.gps.unwrap();
assert!((back.latitude - la).abs() < 1e-5 && (back.longitude - lo).abs() < 1e-5);
assert!(s.execute("photo.setMeta", &json!({"gps": "north pole-ish"})).is_err());
s.execute("photo.setMeta", &json!({"gps": null})).unwrap();
assert!(s.catalog.photo(id).unwrap().meta.gps.is_none());
}
/// DNG export compression: lossless, zip and none all decode; none is the biggest.
#[test]
fn dng_export_compression_choices() {
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d5100-uncompressed.nef");
if !corpus.exists() {
eprintln!("skip: {} absent", corpus.display());
return;
}
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let mut sizes = Vec::new();
for c in ["lossless", "deflate", "uncompressed"] {
let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c}));
let mut out = Vec::new();
let mut write = |_: &str, b: &[u8]| {
out = b.to_vec();
Ok(())
};
crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap();
let raw = lightcraft_raw::decode(&out).unwrap_or_else(|e| panic!("{c}: {e}"));
assert!(raw.width > 1000, "{c}");
sizes.push((c, out.len()));
}
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
}
+14 -4
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
/// The "My Photos" source in the left panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[serde(tag = "kind", content = "id", rename_all = "camelCase")]
pub enum LibrarySource {
#[default]
All,
@@ -17,6 +17,8 @@ pub enum LibrarySource {
Picks,
/// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not.
Folder,
/// Photos whose original file can't be found (`library.missing`).
Missing,
}
/// The folder a [`LibrarySource::Folder`] view shows.
@@ -27,20 +29,27 @@ pub struct Browse {
pub subfolders: bool,
}
/// How far back Recently Added reaches from the latest import.
pub const RECENT_DAYS: i64 = 30;
impl LibrarySource {
pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter {
let mut f = f.clone();
match self {
LibrarySource::All => {}
// everything imported in the 30 days up to the latest import (by import, newest first)
LibrarySource::RecentlyAdded => {
let latest = cat.photos().map(|p| p.imported.clone()).max().unwrap_or_default();
f.imported = Some(latest.get(..10).unwrap_or("").to_string());
let latest = cat.photos().filter(|p| p.in_library()).map(|p| p.imported.clone()).max().unwrap_or_default();
let from = lightcraft_catalog::stacks::iso_seconds(&latest)
.map(|s| lightcraft_catalog::dates::civil(s - RECENT_DAYS * 86_400))
.unwrap_or_else(|| latest.get(..10).unwrap_or("").to_string());
f.imported_from = Some(from);
}
LibrarySource::Album(a) => f.album = Some(*a),
LibrarySource::RecentlyDeleted => f.deleted = true,
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
// the folder itself is filled in by the session (it holds the path)
LibrarySource::Folder => {}
LibrarySource::Folder | LibrarySource::Missing => {}
}
f
}
@@ -53,6 +62,7 @@ impl LibrarySource {
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
LibrarySource::Picks => "Picks".into(),
LibrarySource::Folder => "Folder".into(),
LibrarySource::Missing => "Missing Photos".into(),
}
}
}
+2 -2
View File
@@ -184,8 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[cfg(not(target_arch = "wasm32"))]
{
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 {
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
return None;
}
let gpu = device()?;
+24 -7
View File
@@ -180,6 +180,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
s.masks = vec![
Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
@@ -190,11 +191,13 @@ fn cases() -> Vec<(&'static str, Edit)> {
Mask {
components: vec![
MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
},
MaskComponent {
name: None,
op: MaskOp::Intersect,
invert: true,
shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 1.0) },
@@ -223,12 +226,18 @@ fn cases() -> Vec<(&'static str, Edit)> {
let erase = BrushStroke { points: vec![Point::new(0.5, 0.4)], size: 0.03, erase: true, ..Default::default() };
s.masks = vec![
Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Brush { strokes: vec![stroke, erase] },
}],
adjust: lightcraft_develop::LocalAdjustments { exposure: 0.7, whites: 20.0, blacks: -20.0, dehaze: 30.0, ..Default::default() },
..Default::default()
},
Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::LuminanceRange { lo: 0.5, hi: 0.8, lo_feather: 0.1, hi_feather: 0.1 },
@@ -238,6 +247,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
},
Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 },
@@ -252,8 +262,9 @@ fn cases() -> Vec<(&'static str, Edit)> {
s.masks = vec![
Mask {
components: vec![
MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Sky },
MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky },
MaskComponent {
name: None,
op: MaskOp::Subtract,
invert: false,
shape: MaskShape::Radial { center: Point::new(0.7, 0.2), rx: 0.1, ry: 0.1, angle: 0.0, feather: 30.0, invert: false },
@@ -263,12 +274,12 @@ fn cases() -> Vec<(&'static str, Edit)> {
..Default::default()
},
Mask {
components: vec![MaskComponent { op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }],
components: vec![MaskComponent { name: None, op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }],
adjust: lightcraft_develop::LocalAdjustments { exposure: 0.5, ..Default::default() },
..Default::default()
},
Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape: MaskShape::Background }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape: MaskShape::Background }],
adjust: lightcraft_develop::LocalAdjustments { saturation: -60.0, ..Default::default() },
invert: true,
..Default::default()
@@ -278,7 +289,11 @@ fn cases() -> Vec<(&'static str, Edit)> {
("masks (local noise, moiré, defringe)", |s| {
let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false };
let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) };
let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() };
let m = |shape, adjust| Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust,
..Default::default()
};
use lightcraft_develop::LocalAdjustments as L;
s.masks = vec![
m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }),
@@ -301,7 +316,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
st(&[(0.5, 0.58)], true, true),
];
s.masks = vec![Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
..Default::default()
}];
@@ -452,6 +467,7 @@ fn overlays_match() {
Mask {
id: 1,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
@@ -460,13 +476,14 @@ fn overlays_match() {
},
Mask {
id: 2,
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
..Default::default()
},
Mask {
id: 3,
visible: false,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
+3 -1
View File
@@ -13,6 +13,7 @@ mod container;
mod datetime;
mod exif;
mod iptc;
pub mod tags;
mod xmp;
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
@@ -20,7 +21,8 @@ pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, parse_xmp, write_xmp, write_xmp_lc};
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};
use serde::{Deserialize, Serialize};
+331
View File
@@ -0,0 +1,331 @@
//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info
//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications;
//! common enumerations are spelled out, everything else is shown as stored. Maker notes and
//! binary blobs are summarised by size.
use lightcraft_tiff::{Ifd, Tiff, Value};
/// One metadata row.
#[derive(Clone, Debug, PartialEq)]
pub struct TagRow {
/// `TIFF`, `EXIF`, `GPS`, `Interop`.
pub group: &'static str,
pub tag: u16,
pub name: String,
pub value: String,
}
const NAMES: &[(u16, &str)] = &[
// TIFF / IFD0
(0x00fe, "Subfile Type"),
(0x0100, "Image Width"),
(0x0101, "Image Height"),
(0x0102, "Bits Per Sample"),
(0x0103, "Compression"),
(0x0106, "Photometric Interpretation"),
(0x010e, "Image Description"),
(0x010f, "Make"),
(0x0110, "Model"),
(0x0112, "Orientation"),
(0x0115, "Samples Per Pixel"),
(0x0116, "Rows Per Strip"),
(0x0214, "Reference Black/White"),
(0x9216, "TIFF/EP Standard ID"),
(0x828d, "CFA Repeat Pattern Dim"),
(0x828e, "CFA Pattern"),
(0xa302, "CFA Pattern"),
(0x9217, "Sensing Method"),
(0xc4a5, "Print Image Matching"),
(0xa500, "Gamma"),
(0x011a, "X Resolution"),
(0x011b, "Y Resolution"),
(0x011c, "Planar Configuration"),
(0x0128, "Resolution Unit"),
(0x0131, "Software"),
(0x0132, "Date/Time Modified"),
(0x013b, "Artist"),
(0x013e, "White Point"),
(0x013f, "Primary Chromaticities"),
(0x0213, "YCbCr Positioning"),
(0x8298, "Copyright"),
(0xc612, "DNG Version"),
(0xc614, "Unique Camera Model"),
(0xc62f, "Camera Serial Number"),
// EXIF
(0x829a, "Exposure Time"),
(0x829d, "F-Number"),
(0x8822, "Exposure Program"),
(0x8824, "Spectral Sensitivity"),
(0x8827, "ISO Speed"),
(0x8830, "Sensitivity Type"),
(0x8832, "Recommended Exposure Index"),
(0x9000, "Exif Version"),
(0x9003, "Date/Time Original"),
(0x9004, "Date/Time Digitized"),
(0x9010, "Offset Time"),
(0x9011, "Offset Time Original"),
(0x9012, "Offset Time Digitized"),
(0x9101, "Components Configuration"),
(0x9102, "Compressed Bits Per Pixel"),
(0x9201, "Shutter Speed Value"),
(0x9202, "Aperture Value"),
(0x9203, "Brightness Value"),
(0x9204, "Exposure Compensation"),
(0x9205, "Max Aperture Value"),
(0x9206, "Subject Distance"),
(0x9207, "Metering Mode"),
(0x9208, "Light Source"),
(0x9209, "Flash"),
(0x920a, "Focal Length"),
(0x9214, "Subject Area"),
(0x927c, "Maker Note"),
(0x9286, "User Comment"),
(0x9290, "Sub-second Time"),
(0x9291, "Sub-second Time Original"),
(0x9292, "Sub-second Time Digitized"),
(0xa000, "FlashPix Version"),
(0xa001, "Color Space"),
(0xa002, "Pixel X Dimension"),
(0xa003, "Pixel Y Dimension"),
(0xa20e, "Focal Plane X Resolution"),
(0xa20f, "Focal Plane Y Resolution"),
(0xa210, "Focal Plane Resolution Unit"),
(0xa215, "Exposure Index"),
(0xa217, "Sensing Method"),
(0xa300, "File Source"),
(0xa301, "Scene Type"),
(0xa401, "Custom Rendered"),
(0xa402, "Exposure Mode"),
(0xa403, "White Balance"),
(0xa404, "Digital Zoom Ratio"),
(0xa405, "Focal Length in 35mm Film"),
(0xa406, "Scene Capture Type"),
(0xa407, "Gain Control"),
(0xa408, "Contrast"),
(0xa409, "Saturation"),
(0xa40a, "Sharpness"),
(0xa40c, "Subject Distance Range"),
(0xa420, "Image Unique ID"),
(0xa430, "Camera Owner Name"),
(0xa431, "Body Serial Number"),
(0xa432, "Lens Specification"),
(0xa433, "Lens Make"),
(0xa434, "Lens Model"),
(0xa435, "Lens Serial Number"),
// GPS
(0x0000, "GPS Version"),
(0x0001, "GPS Latitude Ref"),
(0x0002, "GPS Latitude"),
(0x0003, "GPS Longitude Ref"),
(0x0004, "GPS Longitude"),
(0x0005, "GPS Altitude Ref"),
(0x0006, "GPS Altitude"),
(0x0007, "GPS Time Stamp"),
(0x000c, "GPS Speed Ref"),
(0x000d, "GPS Speed"),
(0x0010, "GPS Img Direction Ref"),
(0x0011, "GPS Img Direction"),
(0x0012, "GPS Map Datum"),
(0x001d, "GPS Date Stamp"),
];
/// GPS tags reuse low numbers, so they are looked up separately.
fn name(group: &str, tag: u16) -> String {
let gps = group == "GPS";
NAMES
.iter()
.find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f)))
.map(|(_, n)| n.to_string())
.unwrap_or_else(|| format!("Tag 0x{tag:04X}"))
}
fn rational(n: f64, d: f64) -> String {
if d == 0.0 {
return "—".into();
}
let v = n / d;
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() }
}
fn list<T: ToString>(v: &[T]) -> String {
let shown: Vec<String> = v.iter().take(16).map(|x| x.to_string()).collect();
if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") }
}
/// Spelled-out enumerations for the commonest tags.
fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
if group == "GPS" {
return None;
}
Some(match (tag, v) {
(0x0112, 1) => "Horizontal (normal)",
(0x0112, 3) => "Rotated 180°",
(0x0112, 6) => "Rotated 90° CW",
(0x0112, 8) => "Rotated 90° CCW",
(0x0112, 2) => "Mirrored horizontally",
(0x0112, 4) => "Mirrored vertically",
(0x0103, 1) => "Uncompressed",
(0x0103, 6) => "JPEG (old-style)",
(0x0103, 7) => "JPEG",
(0x0103, 8) => "Deflate",
(0x0103, 32773) => "PackBits",
(0x0103, 34892) => "Lossy JPEG",
(0x0106, 0) => "WhiteIsZero",
(0x0106, 1) => "BlackIsZero",
(0x0106, 2) => "RGB",
(0x0106, 6) => "YCbCr",
(0x0106, 32803) => "Color Filter Array",
(0x0106, 34892) => "Linear Raw",
(0x011c, 1) => "Chunky",
(0x011c, 2) => "Planar",
(0x0128, 2) => "inches",
(0x0128, 3) => "centimetres",
(0x8822, 1) => "Manual",
(0x8822, 2) => "Program",
(0x8822, 3) => "Aperture priority",
(0x8822, 4) => "Shutter priority",
(0x9207, 2) => "Center-weighted average",
(0x9207, 3) => "Spot",
(0x9207, 5) => "Pattern",
(0xa001, 1) => "sRGB",
(0xa001, 0xffff) => "Uncalibrated",
(0xa402, 0) => "Auto",
(0xa402, 1) => "Manual",
(0xa402, 2) => "Auto bracket",
(0xa403, 0) => "Auto",
(0xa403, 1) => "Manual",
(0xa406, 0) => "Standard",
(0xa406, 1) => "Landscape",
(0xa406, 2) => "Portrait",
(0xa406, 3) => "Night",
(0x9209, 0) => "No flash",
(0x9209, 1) => "Fired",
(0x9209, 5) => "Fired, return not detected",
(0x9209, 7) => "Fired, return detected",
(0x9209, 9) => "On, fired",
(0x9209, 16) => "Off, did not fire",
(0x9209, 24) => "Auto, did not fire",
(0x9209, 25) => "Auto, fired",
(0x9209, 32) => "No flash function",
(0x9208, 0) => "Unknown",
(0x9208, 1) => "Daylight",
(0x9208, 2) => "Fluorescent",
(0x9208, 3) => "Tungsten",
(0x9208, 4) => "Flash",
_ => return None,
})
}
fn value_text(group: &str, tag: u16, v: &Value) -> String {
let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string());
match v {
Value::Ascii(s) => s.trim().to_string(),
Value::Short(a) if a.len() == 1 => single(a[0] as i64),
Value::Long(a) if a.len() == 1 => single(a[0] as i64),
Value::Short(a) => list(a),
Value::Long(a) => list(a),
Value::SShort(a) => list(a),
Value::SLong(a) => list(a),
Value::Long8(a) => list(a),
Value::SLong8(a) => list(a),
Value::Float(a) => list(a),
Value::Double(a) => list(a),
Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => {
// exposure time as a fraction of a second
let (n, d) = a[0];
let s = n as f64 / d as f64;
if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) }
}
Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)),
Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)),
Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::<Vec<_>>().join(", "),
Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::<Vec<_>>().join(", "),
Value::Byte(b) | Value::Undefined(b) => {
// version tags and short printable values read as text
let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0);
if printable && !b.is_empty() {
String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string()
} else if b.len() <= 8 {
list(b)
} else {
format!("({} bytes)", b.len())
}
}
Value::SByte(a) => list(a),
Value::Ifd(a) => format!("(IFD at {})", list(a)),
Value::Ifd8(a) => format!("(IFD at {})", list(a)),
}
}
fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
for e in &ifd.entries {
// pointers to other IFDs and image data are structure, not metadata
if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773)
&& group != "GPS"
{
continue;
}
out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) });
}
}
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
/// then the EXIF, GPS and interoperability IFDs.
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
let mut out = Vec::new();
if let Some(ifd0) = tiff.ifds.first() {
rows_of("TIFF", ifd0, &mut out);
if let Some(e) = &ifd0.exif {
rows_of("EXIF", e, &mut out);
if let Some(i) = &e.interop {
rows_of("Interop", i, &mut out);
}
}
if let Some(g) = &ifd0.gps {
rows_of("GPS", g, &mut out);
}
}
out
}
/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block
/// (JPEG, PNG, WebP, HEIF…). Empty when there is none.
pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
if Tiff::sniff(bytes).is_some()
&& let Ok(t) = Tiff::parse(bytes)
{
return tag_rows(&t);
}
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_from_written_exif() {
let m = crate::Metadata {
make: Some("Maker".into()),
model: Some("Model X".into()),
exposure_time: Some(1.0 / 250.0),
f_number: Some(2.8),
iso: Some(400),
focal_length: Some(35.0),
gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }),
..Default::default()
};
let exif = crate::write_exif(&m);
let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap();
let rows = tag_rows(&t);
let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone());
assert_eq!(get("Make").as_deref(), Some("Maker"));
assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s"));
assert_eq!(get("F-Number").as_deref(), Some("f/2.8"));
assert_eq!(get("Focal Length").as_deref(), Some("35 mm"));
assert_eq!(get("ISO Speed").as_deref(), Some("400"));
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
}
}
+66 -1
View File
@@ -65,6 +65,67 @@ pub struct XmpData {
/// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep
/// item order, language alternatives put `x-default` first.
pub properties: BTreeMap<String, Vec<String>>,
/// Every top-level property with its full structure (arrays of structs such as local
/// corrections, which [`XmpData::properties`] flattens).
pub values: BTreeMap<String, XmpValue>,
}
/// A structured XMP value.
#[derive(Clone, Debug, PartialEq)]
pub enum XmpValue {
Text(String),
Array(Vec<XmpValue>),
Struct(BTreeMap<String, XmpValue>),
}
impl XmpValue {
/// A struct field (`prefix:name`).
pub fn field(&self, k: &str) -> Option<&XmpValue> {
match self {
XmpValue::Struct(m) => m.get(k),
_ => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
XmpValue::Text(s) => Some(s.trim()),
XmpValue::Array(a) => a.first().and_then(XmpValue::text),
XmpValue::Struct(_) => None,
}
}
/// Array items (a lone value is a one-item array).
pub fn items(&self) -> &[XmpValue] {
match self {
XmpValue::Array(a) => a,
other => std::slice::from_ref(other),
}
}
}
fn node_struct(n: &Node) -> XmpValue {
let mut m = BTreeMap::new();
for (k, v) in &n.attrs {
if !is_rdf_meta_attr(k) {
m.insert(k.clone(), XmpValue::Text(v.clone()));
}
}
for c in &n.children {
m.insert(c.name.clone(), node_value(c));
}
XmpValue::Struct(m)
}
fn node_value(n: &Node) -> XmpValue {
if let Some(arr) = n.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
return XmpValue::Array(arr.children.iter().filter(|li| li.name == "rdf:li").map(node_value).collect());
}
if let Some(d) = n.children.iter().find(|c| c.name == "rdf:Description") {
return node_struct(d);
}
if !n.children.is_empty() || n.attrs.iter().any(|(k, _)| !is_rdf_meta_attr(k)) {
return node_struct(n);
}
XmpValue::Text(n.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| n.text.trim().to_string()))
}
#[derive(Debug, Default)]
@@ -322,8 +383,12 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
let mut descs = Vec::new();
find_descriptions(&tree, &mut descs);
let mut props = BTreeMap::new();
let mut values = BTreeMap::new();
for d in descs {
collect_description(d, "", &mut props);
if let XmpValue::Struct(m) = node_struct(d) {
values.extend(m);
}
}
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
@@ -394,7 +459,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
}
let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned();
Ok(XmpData { metadata: m, lc_settings, properties: props })
Ok(XmpData { metadata: m, lc_settings, properties: props, values })
}
fn frac(v: f64) -> String {
+57
View File
@@ -58,6 +58,46 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo
}
}
/// An automatic black & white mix (slider values, red … magenta) for `src` under `s`'s white
/// balance and tone: each hue band's colourful pixels are pushed away from the image's mean
/// lightness — bands brighter than average get brighter, darker ones darker — so areas that
/// differ only in colour stay apart in grey. Bands with almost no colourful pixels stay at 0.
pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] {
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box);
let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() };
crate::local::scene_linear_pre(&mut img, info, &base);
let gain = 2f32.powf(base.light.exposure as f32);
let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]);
let (mut all_l, mut n) = (0f64, 0usize);
for p in &img.data {
let lch = lab_to_lch(oklab_from_2020(p.map(|v| (v * gain).max(0.0))));
all_l += lch[0] as f64;
n += 1;
let k = (lch[1] / 0.2).min(1.0) as f64; // as in the B&W conversion
if k < 0.05 {
continue;
}
let w = crate::colorops::band_weights(lch[2]);
for i in 0..8 {
mass[i] += w[i] as f64 * k;
sum_l[i] += w[i] as f64 * k * lch[0] as f64;
}
}
if n == 0 {
return [0.0; 8];
}
let mean = all_l / n as f64;
let total: f64 = mass.iter().sum();
std::array::from_fn(|i| {
if total <= 0.0 || mass[i] / total < 0.01 {
return 0.0;
}
let sep = sum_l[i] / mass[i] - mean;
(sep * 400.0).clamp(-60.0, 60.0).round()
})
}
/// Grey-world white balance weighted towards mid-tone, low-chroma pixels. Returns (temp, tint).
pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) {
let img = lightcraft_raster::resample::fit(src, 256, 256, lightcraft_raster::resample::Filter::Box);
@@ -134,3 +174,20 @@ mod tests {
assert!((c[0] - c[2]).abs() < 0.02, "{c:?}");
}
}
#[cfg(test)]
mod bw_tests {
use super::*;
#[test]
fn auto_bw_mix_pushes_light_and_dark_hues_apart() {
// left: a light yellow, right: a dark blue (scene-linear Rec. 2020)
let src = Rgb32f::from_fn(64, 32, |x, _| if x < 32 { [0.55, 0.5, 0.05] } else { [0.01, 0.02, 0.12] });
let m = auto_bw_mix(&src, &SourceInfo::default(), &DevelopSettings::default());
let (yellow, blue) = (m[2], m[5]);
assert!(yellow > 0.0 && blue < 0.0, "{m:?}");
// an image without colour leaves the mix alone
let grey = Rgb32f::from_fn(16, 16, |x, _| [x as f32 / 16.0; 3]);
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
}
}
+27 -1
View File
@@ -191,6 +191,27 @@ impl ColorOps {
return rgb;
}
let lab = oklab_from_2020(rgb);
// only chroma changes (vibrance / saturation, maybe grading): scale a, b directly — the same
// result as the OkLCh round trip without its sin / cos (and atan2 unless vibrance needs the hue)
if !self.mixer && self.points.is_empty() && self.bw.is_none() && local_hue == 0.0 {
let mut lab = lab;
let c0 = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
let mut c = c0;
if self.vibrance != 0.0 {
let low = 1.0 - (c / 0.22).clamp(0.0, 1.0);
let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(lab[2].atan2(lab[1]) - self.skin) / 0.35).powi(2)).exp() } else { 1.0 };
c *= (1.0 + self.vibrance * low * low * skin * 1.2).max(0.0);
}
if self.saturation != 0.0 || local_sat != 0.0 {
c *= (1.0 + self.saturation + local_sat).max(0.0);
}
if c0 > 0.0 {
let k = c / c0;
lab[1] *= k;
lab[2] *= k;
}
return self.grade(lab);
}
let [mut l, mut c, mut h] = lab_to_lch(lab);
if self.mixer {
let w = band_weights(h);
@@ -223,7 +244,12 @@ impl ColorOps {
l = (l + mix * (c / 0.2).min(1.0) * 0.25).max(0.0);
c = 0.0;
}
let mut lab = lch_to_lab([l, c, h]);
self.grade(lch_to_lab([l, c, h]))
}
/// Colour grading on OkLab, then back to linear Rec.2020.
#[inline]
fn grade(&self, mut lab: [f32; 3]) -> [f32; 3] {
if let Some((wheels, blending, balance)) = &self.grading {
let m = 0.5 - balance * 0.25;
let width = 0.15 + blending * 0.5;
+138
View File
@@ -0,0 +1,138 @@
//! Assisted culling measurements on a decoded source (any size; measured on a fixed-size copy so
//! scores compare across photos): focus, clipping, and a tiny signature for finding similar
//! shots (bursts). Classical image statistics — no learned models.
use lightcraft_raster::Rgb32f;
/// Long edge the measurements run at.
const EDGE: usize = 512;
/// Display-ish luminance (sRGB-encoded) of `img` resampled to fit `EDGE` (box filter).
fn luma(img: &Rgb32f) -> (Vec<f32>, usize, usize) {
let (w, h) = (img.width.max(1), img.height.max(1));
let s = (EDGE as f64 / w.max(h) as f64).min(1.0);
let (ow, oh) = (((w as f64 * s).round() as usize).max(8), ((h as f64 * s).round() as usize).max(8));
let mut out = vec![0f32; ow * oh];
for (y, row) in out.chunks_mut(ow).enumerate() {
let (y0, y1) = (y * h / oh, ((y + 1) * h / oh).max(y * h / oh + 1).min(h));
for (x, v) in row.iter_mut().enumerate() {
let (x0, x1) = (x * w / ow, ((x + 1) * w / ow).max(x * w / ow + 1).min(w));
let mut acc = 0.0f32;
for yy in y0..y1 {
for xx in x0..x1 {
let c = img.data[yy * w + xx];
acc += 0.2627 * c[0] + 0.678 * c[1] + 0.0593 * c[2];
}
}
let l = acc / ((y1 - y0) * (x1 - x0)) as f32;
*v = lightcraft_color::transfer::linear_to_srgb(l.clamp(0.0, 1.0));
}
}
(out, ow, oh)
}
/// Focus 0..100: detail energy (Laplacian) of the sharpest areas — the 90th percentile of
/// 16 × 16-pixel blocks, so a sharp subject on a soft background still reads as sharp.
pub fn sharpness(img: &Rgb32f) -> f32 {
let (l, w, h) = luma(img);
let b = 16;
let mut blocks = Vec::new();
for by in (1..h.saturating_sub(1)).step_by(b) {
for bx in (1..w.saturating_sub(1)).step_by(b) {
let mut e = 0.0f32;
let mut n = 0;
for y in by..(by + b).min(h - 1) {
for x in bx..(bx + b).min(w - 1) {
let c = l[y * w + x];
let lap = 4.0 * c - l[y * w + x - 1] - l[y * w + x + 1] - l[(y - 1) * w + x] - l[(y + 1) * w + x];
e += lap * lap;
n += 1;
}
}
if n > 0 {
blocks.push(e / n as f32);
}
}
}
if blocks.is_empty() {
return 0.0;
}
let k = (blocks.len() as f32 * 0.9) as usize;
let k = k.min(blocks.len() - 1);
let (_, v, _) = blocks.select_nth_unstable_by(k, f32::total_cmp);
// map energy to 0..100: a crisp, ordinary photo at 512 px reads ≈ 50–80 (calibrated on the
// CC0 corpus), soft ones well below
100.0 * (1.0 - (-*v / 0.012).exp())
}
/// Share of clipped pixels (crushed blacks + blown highlights), 0..1.
pub fn clipped(img: &Rgb32f) -> f32 {
let n = img.data.len().max(1);
let c = img.data.iter().filter(|p| p.iter().any(|v| *v >= 0.985) || p.iter().all(|v| *v <= 0.002)).count();
c as f32 / n as f32
}
/// A tiny look-alike signature: 8 × 8 luminance, normalised to zero mean and unit norm.
pub fn signature(img: &Rgb32f) -> [f32; 64] {
let (l, w, h) = luma(img);
let mut s = [0f32; 64];
for (i, v) in s.iter_mut().enumerate() {
let (cx, cy) = (i % 8, i / 8);
let (x0, x1) = (cx * w / 8, (cx + 1) * w / 8);
let (y0, y1) = (cy * h / 8, (cy + 1) * h / 8);
let mut acc = 0.0;
for y in y0..y1 {
for x in x0..x1 {
acc += l[y * w + x];
}
}
*v = acc / ((x1 - x0) * (y1 - y0)).max(1) as f32;
}
let mean = s.iter().sum::<f32>() / 64.0;
s.iter_mut().for_each(|v| *v -= mean);
let norm = s.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-6);
s.iter_mut().for_each(|v| *v /= norm);
s
}
/// Similarity of two signatures: 1 = the same picture, 0 = unrelated.
pub fn similarity(a: &[f32; 64], b: &[f32; 64]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>().clamp(-1.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn blur(img: &Rgb32f, r: usize) -> Rgb32f {
let (w, h) = (img.width, img.height);
let mut out = img.clone();
for y in 0..h {
for x in 0..w {
let mut acc = [0f32; 3];
let mut n = 0.0;
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
let c = img.data[yy * w + xx];
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
n += 1.0;
}
}
out.data[y * w + x] = acc.map(|v| v / n);
}
}
out
}
#[test]
fn blur_lowers_focus_and_signatures_match() {
let img = lightcraft_scenes::demo_library()[0].render(480, 320);
let soft = blur(&img, 4);
let (a, b) = (sharpness(&img), sharpness(&soft));
assert!(a > b + 10.0, "sharp {a} vs blurred {b}");
assert!(similarity(&signature(&img), &signature(&soft)) > 0.95, "the same picture");
let other = lightcraft_scenes::demo_library()[5].render(480, 320);
assert!(similarity(&signature(&img), &signature(&other)) < 0.9);
assert!(clipped(&img) < 0.5);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Dust-spot detection: sensor dust shows as small, soft, darker-than-surroundings round spots
//! on smooth areas (sky, walls). Classical: a blurred copy gives the surroundings, the darkening
//! is compared with the local texture so detail doesn't trigger it, and each candidate blob
//! must be small and roughly round.
use lightcraft_raster::{Plane, Rgba8, blur::gaussian};
/// A found spot: centre (0..1 of the image's width / height) and radius (fraction of the long
/// edge), with how much darker it is (0..1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DustSpot {
pub x: f64,
pub y: f64,
pub radius: f64,
pub strength: f32,
}
/// Dust spots in a rendered image; `sensitivity` 0..100 (higher finds fainter spots).
pub fn detect(img: &Rgba8, sensitivity: f32) -> Vec<DustSpot> {
let (w, h) = (img.width, img.height);
if w < 32 || h < 32 {
return Vec::new();
}
let long = w.max(h) as f32;
let l = Plane::from_fn(w, h, |x, y| {
let p = img.data[y * w + x];
(0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0
});
let around = gaussian(&l, (0.012 * long).max(2.0));
// texture: local mean of the fine detail (what isn't explained by the surroundings)
let detail = Plane::from_fn(w, h, |x, y| (l.data[y * w + x] - around.data[y * w + x]).abs());
let texture = gaussian(&detail, (0.02 * long).max(3.0));
let min_dark = 0.03 - 0.022 * (sensitivity / 100.0).clamp(0.0, 1.0);
let dark: Vec<f32> = (0..w * h).map(|i| (around.data[i] - l.data[i]).max(0.0)).collect();
let candidate = |i: usize| dark[i] > min_dark && dark[i] > 3.0 * texture.data[i] && l.data[i] > 0.08;
// blobs of candidate pixels (4-connected)
let mut seen = vec![false; w * h];
let mut out = Vec::new();
let (rmin, rmax) = (0.0015 * long, 0.02 * long);
for start in 0..w * h {
if seen[start] || !candidate(start) {
continue;
}
let mut stack = vec![start];
seen[start] = true;
let (mut n, mut sx, mut sy, mut peak) = (0usize, 0.0f64, 0.0f64, 0.0f32);
let (mut x0, mut x1, mut y0, mut y1) = (w, 0, h, 0);
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
n += 1;
sx += x as f64;
sy += y as f64;
peak = peak.max(dark[i]);
(x0, x1, y0, y1) = (x0.min(x), x1.max(x), y0.min(y), y1.max(y));
if n > (rmax * rmax * 4.0) as usize {
break; // too big to be dust: stop growing
}
for j in [i.wrapping_sub(1), i + 1, i.wrapping_sub(w), i + w] {
let ok = j < w * h && !seen[j] && ((j % w).abs_diff(x) <= 1);
if ok && candidate(j) {
seen[j] = true;
stack.push(j);
}
}
}
let (bw, bh) = ((x1 - x0 + 1) as f32, (y1 - y0 + 1) as f32);
let r = (bw.max(bh)) / 2.0;
let round = bw.max(bh) / bw.min(bh) <= 1.8 && n as f32 >= 0.45 * bw * bh;
if r >= rmin && r <= rmax && round {
out.push(DustSpot {
x: (sx / n as f64 + 0.5) / w as f64,
y: (sy / n as f64 + 0.5) / h as f64,
radius: (r / long) as f64 * 1.6,
strength: peak,
});
}
}
out.sort_by(|a, b| b.strength.total_cmp(&a.strength));
out.truncate(60);
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A smooth sky gradient with three soft dark spots: all found, nothing else.
#[test]
fn finds_spots_on_smooth_areas() {
let (w, h) = (600usize, 400usize);
let spots = [(120.0f32, 90.0f32, 4.0f32), (400.0, 200.0, 6.0), (500.0, 330.0, 3.0)];
let data: Vec<[u8; 4]> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let mut v = 0.55 + 0.25 * (y / h as f32) + 0.05 * (x / w as f32);
for (cx, cy, r) in spots {
let d2 = (x - cx).powi(2) + (y - cy).powi(2);
v -= 0.06 * (-d2 / (2.0 * r * r)).exp();
}
let b = (v * 255.0) as u8;
[b, b, (b as f32 * 1.1).min(255.0) as u8, 255]
})
.collect();
let found = detect(&Rgba8 { width: w, height: h, data }, 50.0);
assert_eq!(found.len(), 3, "{found:?}");
for (cx, cy, _) in spots {
assert!(
found.iter().any(|s| (s.x * w as f64 - cx as f64).abs() < 3.0 && (s.y * h as f64 - cy as f64).abs() < 3.0),
"{cx},{cy}: {found:?}"
);
}
}
#[test]
fn textured_scenes_give_few_false_spots() {
let img = lightcraft_scenes::demo_library()[2].render(600, 400);
let data = img
.data
.iter()
.map(|c| {
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0) as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let found = detect(&Rgba8 { width: 600, height: 400, data }, 50.0);
assert!(found.len() <= 3, "{} false spots", found.len());
}
}
+84 -22
View File
@@ -150,6 +150,8 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
/// Everything the per-pixel stage computes once per render: the CPU loop below and the GPU kernel
/// (`lightcraft-gpu`) both read their parameters from here, so the two cannot drift apart.
pub struct FinishParams {
/// A LUT profile and its amount (0..2), applied to the display-encoded colour.
pub lut: Option<(std::sync::Arc<crate::lut::Lut3d>, f32)>,
pub tone: ToneMap,
pub ops: ColorOps,
/// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1).
@@ -165,6 +167,8 @@ pub struct FinishParams {
pub to_out: [[f32; 3]; 3],
pub out_luma: [f32; 3],
pub out_trc: OutputTrc,
/// Soft proofing (CPU only; the GPU path declines proof renders).
pub proof: Option<crate::output::ProofParams>,
pub hl: f32,
pub sh: f32,
pub clar: f32,
@@ -222,6 +226,7 @@ impl FinishParams {
} else {
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
},
lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)),
ops: ColorOps::new(s),
curves: curve_luts(&s.curve),
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
@@ -229,6 +234,7 @@ impl FinishParams {
to_out: space.from_working(),
out_luma: space.luma(),
out_trc: space.trc(),
proof: None,
hl: (s.light.highlights / 100.0) as f32,
sh: (s.light.shadows / 100.0) as f32,
clar,
@@ -251,9 +257,10 @@ impl FinishParams {
}
}
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 {
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option<crate::Proof>) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
fp.proof = proof.map(|pr| pr.params(space));
let trc = fp.out_trc;
let data = finish_with(p, &fp, false, |e| match trc {
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
@@ -266,10 +273,20 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
}
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage {
#[allow(clippy::too_many_arguments)]
pub(crate) fn finish_deep(
p: &Prepared,
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
space: OutputSpace,
depth: OutputDepth,
proof: Option<crate::Proof>,
) -> DeepImage {
use lightcraft_color::transfer::srgb_to_linear;
let (w, h) = (p.img.width, p.img.height);
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
fp.proof = proof.map(|pr| pr.params(space));
let trc = fp.out_trc;
let samples = match depth {
OutputDepth::F32Linear => {
@@ -298,6 +315,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
store: impl Fn([f32; 3]) -> T + Sync + Send,
) -> Vec<T> {
let (w, h) = (p.img.width, p.img.height);
let p_lut = fp.lut.clone();
let FinishParams {
tone,
ops,
@@ -503,25 +521,25 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
}
}
// --- gamut map to the output space (desaturate towards luminance until in range)
let m = to_out;
let mut r = [
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
];
let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0);
let mut t = 1.0f32;
for c in r {
if c < 0.0 {
t = t.min(yy / (yy - c).max(1e-9));
} else if c > 1.0 {
t = t.min((1.0 - yy) / (c - yy).max(1e-9));
// --- gamut map to the output space (desaturate towards luminance until in range);
// soft proofing maps into the proof space first and shows that in the output space
let mut warn = None;
let mut r = match &fp.proof {
Some(pp) => {
let q0 = mul3(&pp.to_proof, d);
let (q, t) = gamut_map(q0, pp.luma);
if pp.dest_warning && out_of_gamut(q0, t) {
warn = Some(crate::output::PROOF_DEST_WARNING);
}
mul3(&pp.proof_to_out, q)
}
None => mul3(to_out, d),
};
let (mapped, t) = gamut_map(r, *out_luma);
if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) {
warn = Some(crate::output::PROOF_DISPLAY_WARNING);
}
if t < 1.0 {
r = r.map(|c| yy + (c - yy) * t);
}
r = mapped;
// --- encode, curves, grain
let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) };
@@ -542,12 +560,56 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
e = e.map(|v| v + k);
}
*px = store(e);
let e = match &p_lut {
Some((l, k)) => {
let m = l.apply(e.map(|v| v.clamp(0.0, 1.0)));
[e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k]
}
None => e,
};
*px = store(warn.unwrap_or(e));
}
});
out
}
/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for
/// the gamut warnings (tolerates rounding at the gamut boundary).
const GAMUT_WARN: f32 = 0.995;
/// For the gamut warnings: `r` needed desaturating by `t` to fit, and isn't simply a neutral
/// beyond white or below black (that's clipping, which the clipping warnings show).
#[inline]
fn out_of_gamut(r: [f32; 3], t: f32) -> bool {
let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2]));
t < GAMUT_WARN && (hi - lo) > 0.02 * hi.abs().max(1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0))
}
#[inline]
fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] {
[
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
]
}
/// Desaturate linear `r` towards its luminance (weights `luma`) until every channel is in 0..1;
/// returns the mapped colour and the chroma scale used (1 = already in gamut).
#[inline]
pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) {
let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0);
let mut t = 1.0f32;
for c in r {
if c < 0.0 {
t = t.min(yy / (yy - c).max(1e-9));
} else if c > 1.0 {
t = t.min((1.0 - yy) / (c - yy).max(1e-9));
}
}
if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) }
}
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
/// its fine blur: high on strong edges with a purple or green cast.
#[inline]
+46 -4
View File
@@ -24,9 +24,12 @@
pub mod auto;
pub mod colorops;
pub mod cull;
pub mod dust;
pub mod finish;
pub mod geometry;
pub mod local;
pub mod lut;
pub mod masks;
pub mod optics;
pub mod output;
@@ -38,7 +41,7 @@ pub mod transform;
pub mod upright;
pub mod visualize;
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc, Proof};
pub use visualize::{MaskView, Overlay};
use lightcraft_develop::{DevelopSettings, Treatment};
@@ -90,11 +93,22 @@ pub struct RenderRequest {
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
pub proof: Option<Proof>,
}
impl RenderRequest {
pub fn fit(max_w: usize, max_h: usize) -> Self {
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 }
Self {
max_w,
max_h,
quality: Quality::Full,
apply_crop: true,
overlay: Overlay::None,
space: OutputSpace::Srgb,
depth: OutputDepth::U8,
proof: None,
}
}
}
@@ -395,13 +409,13 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
}
if req.depth != OutputDepth::U8 {
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth);
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof);
let image = deep.to_rgba8();
let histogram = Histogram::of_srgb8(&image);
lap("finish (deep)", &mut t);
return Rendered { image, histogram, deep: Some(deep) };
}
let image = finish::finish(&prep, s, frame, info, req.space);
let image = finish::finish(&prep, s, frame, info, req.space, req.proof);
lap("finish", &mut t);
let histogram = Histogram::of_srgb8(&image);
lap("histogram", &mut t);
@@ -411,6 +425,34 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Rendered { image, histogram, deep: None }
}
/// The colour a colour-range mask would sample at normalized image point `p` (OkLab of the
/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares
/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image.
pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> {
let plan = plan(src, info, s, req);
let mut img = plan.frame.sample(src, plan.w, plan.h);
lin_cpu(&mut img, info, &plan);
let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p);
let (cx, cy) = (o.x.floor() as i64, o.y.floor() as i64);
if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 {
return None;
}
let gain = (s.light.exposure as f32).exp2();
let mut acc = [0f64; 3];
let mut n = 0.0;
for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) {
for x in (cx - 1).max(0)..=(cx + 1).min(plan.w as i64 - 1) {
let c = img.data[y as usize * plan.w + x as usize].map(|v| v * gain);
let lab = lightcraft_color::perceptual::oklab_from_2020(masks::tonemap_for_select(c));
for k in 0..3 {
acc[k] += lab[k] as f64;
}
n += 1.0;
}
}
Some(acc.map(|v| v / n))
}
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
/// fresh evaluation.
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
+143
View File
@@ -0,0 +1,143 @@
//! 3D LUT profiles: `.cube` files (the plain-text Cube LUT format: `LUT_3D_SIZE`, optional
//! `DOMAIN_MIN` / `DOMAIN_MAX`, then size³ "r g b" lines with red changing fastest), applied to
//! the display-encoded output colour and blended by the profile amount. Profiles are registered
//! here by id (`lut:…`) and found by the finishing stage; LUT profiles render on the CPU.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
/// A 3D colour lookup table.
#[derive(Clone, Debug, PartialEq)]
pub struct Lut3d {
pub size: usize,
pub data: Vec<[f32; 3]>,
pub domain_min: [f32; 3],
pub domain_max: [f32; 3],
pub title: Option<String>,
}
impl Lut3d {
/// Parse a `.cube` file.
pub fn parse_cube(text: &str) -> Result<Lut3d, String> {
let (mut size, mut title) = (0usize, None);
let (mut min, mut max) = ([0.0f32; 3], [1.0f32; 3]);
let mut data = Vec::new();
let three = |rest: &str| -> Option<[f32; 3]> {
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
(v.len() == 3).then(|| [v[0], v[1], v[2]])
};
for line in text.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
if let Some(r) = l.strip_prefix("TITLE") {
title = Some(r.trim().trim_matches('"').to_string());
} else if let Some(r) = l.strip_prefix("LUT_3D_SIZE") {
size = r.trim().parse().map_err(|_| format!("bad LUT_3D_SIZE `{}`", r.trim()))?;
if !(2..=256).contains(&size) {
return Err(format!("LUT_3D_SIZE {size} out of range"));
}
} else if l.starts_with("LUT_1D_SIZE") {
return Err("1D LUTs are not supported (only 3D .cube files)".into());
} else if let Some(r) = l.strip_prefix("DOMAIN_MIN") {
min = three(r).ok_or("bad DOMAIN_MIN")?;
} else if let Some(r) = l.strip_prefix("DOMAIN_MAX") {
max = three(r).ok_or("bad DOMAIN_MAX")?;
} else if l.starts_with(|c: char| c.is_ascii_alphabetic()) {
// other keywords (LUT_IN_VIDEO_RANGE…) are ignored
} else {
data.push(three(l).ok_or_else(|| format!("bad line `{l}`"))?);
}
}
if size == 0 {
return Err("not a 3D .cube LUT (no LUT_3D_SIZE)".into());
}
if data.len() != size * size * size {
return Err(format!("expected {} entries, found {}", size * size * size, data.len()));
}
Ok(Lut3d { size, data, domain_min: min, domain_max: max, title })
}
/// Look up `c` (trilinear).
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let n = self.size;
let s = (n - 1) as f32;
let mut idx = [0usize; 3];
let mut frac = [0.0f32; 3];
for k in 0..3 {
let span = (self.domain_max[k] - self.domain_min[k]).max(1e-6);
let x = (((c[k] - self.domain_min[k]) / span).clamp(0.0, 1.0)) * s;
let i = (x.floor() as usize).min(n - 2);
idx[k] = i;
frac[k] = x - i as f32;
}
let at = |r: usize, g: usize, b: usize| self.data[r + n * (g + n * b)];
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
let (r, g, b) = (idx[0], idx[1], idx[2]);
let c00 = lerp(at(r, g, b), at(r + 1, g, b), frac[0]);
let c10 = lerp(at(r, g + 1, b), at(r + 1, g + 1, b), frac[0]);
let c01 = lerp(at(r, g, b + 1), at(r + 1, g, b + 1), frac[0]);
let c11 = lerp(at(r, g + 1, b + 1), at(r + 1, g + 1, b + 1), frac[0]);
lerp(lerp(c00, c10, frac[1]), lerp(c01, c11, frac[1]), frac[2])
}
}
fn registry() -> &'static RwLock<HashMap<String, Arc<Lut3d>>> {
static R: OnceLock<RwLock<HashMap<String, Arc<Lut3d>>>> = OnceLock::new();
R.get_or_init(Default::default)
}
/// Make a LUT available to renders under profile id `id` (`lut:…`).
pub fn register(id: &str, lut: Lut3d) {
registry().write().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), Arc::new(lut));
}
/// The LUT registered for profile `id`.
pub fn get(id: &str) -> Option<Arc<Lut3d>> {
if !id.starts_with("lut:") {
return None;
}
registry().read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Forget a LUT profile.
pub fn unregister(id: &str) {
registry().write().unwrap_or_else(|e| e.into_inner()).remove(id);
}
/// Profile ids that render with a LUT (and so on the CPU).
pub fn is_lut_profile(id: &str) -> bool {
id.starts_with("lut:")
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(f: impl Fn([f32; 3]) -> [f32; 3], n: usize) -> String {
let mut s = format!("TITLE \"test\"\nLUT_3D_SIZE {n}\n");
for b in 0..n {
for g in 0..n {
for r in 0..n {
let c = f([r as f32 / (n - 1) as f32, g as f32 / (n - 1) as f32, b as f32 / (n - 1) as f32]);
s.push_str(&format!("{} {} {}\n", c[0], c[1], c[2]));
}
}
}
s
}
#[test]
fn identity_and_swap() {
let id = Lut3d::parse_cube(&cube(|c| c, 17)).unwrap();
assert_eq!(id.title.as_deref(), Some("test"));
let v = id.apply([0.2, 0.55, 0.9]);
assert!(v.iter().zip([0.2, 0.55, 0.9]).all(|(a, b)| (a - b).abs() < 1e-5), "{v:?}");
let swap = Lut3d::parse_cube(&cube(|c| [c[2], c[1], c[0]], 9)).unwrap();
let v = swap.apply([0.1, 0.5, 0.8]);
assert!((v[0] - 0.8).abs() < 1e-5 && (v[2] - 0.1).abs() < 1e-5, "{v:?}");
assert!(Lut3d::parse_cube("LUT_1D_SIZE 4\n0 0 0").is_err());
assert!(Lut3d::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err(), "too few entries");
}
}
+39 -3
View File
@@ -56,6 +56,16 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
}
first = false;
}
if m.refine > 0.0 {
// Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge,
// the width of a soft brush edge or gradient)
let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32;
let sigma = (0.04 * w.max(h) as f32 * k).max(1.0);
let refined = guided_cross(log_l, &alpha, sigma, 0.02);
for (a, r) in alpha.data.iter_mut().zip(&refined.data) {
*a += (r - *a) * k.sqrt();
}
}
if m.invert {
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
@@ -175,7 +185,7 @@ fn smooth_plane(p: &mut Plane, sigma: f32) {
}
/// A rough display mapping for colour picking (so samples taken on screen match).
fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
c.map(|v| v / (1.0 + v))
}
@@ -354,7 +364,7 @@ mod tests {
let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false };
let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0);
assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01);
let m = Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape }], ..Default::default() };
let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() };
let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0);
assert!(e[0].alpha.get(50, 50) < 0.01);
}
@@ -367,7 +377,7 @@ mod tests {
let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() };
let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() };
let m = Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
..Default::default()
};
let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0);
@@ -407,4 +417,30 @@ mod tests {
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
}
/// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge.
#[test]
fn refine_edges_follows_the_photo() {
let (w, h) = (120usize, 80usize);
// left half dark, right half bright
let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] });
let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() });
let f = Frame::new(w, h, &Default::default(), true);
let soft = Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) },
}],
..Default::default()
};
let refined = Mask { refine: 100.0, ..soft.clone() };
let step = |m: &Mask| {
let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0);
a.data[40 * w + 63] - a.data[40 * w + 56]
};
let (plain, sharp) = (step(&soft), step(&refined));
assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}");
}
}
+43
View File
@@ -138,6 +138,49 @@ impl OutputSpace {
}
}
/// Soft proofing: render as if the result were converted to `space` (colours outside its gamut
/// are mapped into it, then shown in the render's own space), optionally painting what the
/// destination can't hold (`dest_warning`, red) and what the display can't show
/// (`display_warning`, blue) — Lightroom's destination and monitor gamut warnings.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Proof {
pub space: OutputSpace,
pub dest_warning: bool,
pub display_warning: bool,
}
/// Warning colours (sRGB-encoded): destination gamut red, display gamut blue.
pub const PROOF_DEST_WARNING: [f32; 3] = [1.0, 0.0, 0.0];
pub const PROOF_DISPLAY_WARNING: [f32; 3] = [0.0, 0.25, 1.0];
/// [`Proof`] as the per-pixel stage uses it.
#[derive(Clone, Copy, Debug)]
pub struct ProofParams {
/// Linear Rec.2020 → linear proof RGB, its luminance weights, linear proof → linear output RGB.
pub to_proof: [[f32; 3]; 3],
pub luma: [f32; 3],
pub proof_to_out: [[f32; 3]; 3],
pub dest_warning: bool,
pub display_warning: bool,
}
impl Proof {
pub fn key(self) -> u64 {
(self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9
}
pub fn params(self, out: OutputSpace) -> ProofParams {
ProofParams {
to_proof: self.space.from_working(),
luma: self.space.luma(),
proof_to_out: self.space.rgb_space().to_space(&out.rgb_space()).to_f32(),
dest_warning: self.dest_warning,
display_warning: self.display_warning,
}
}
}
/// Sample format of a render.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+43
View File
@@ -106,6 +106,7 @@ fn mask_brightens_only_inside() {
let mut s = DevelopSettings::default();
s.masks.push(Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 10.0, invert: false },
@@ -287,3 +288,45 @@ fn refine_saturation_tames_a_contrast_curve() {
let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32;
assert!((y(full) - y(refined)).abs() < 2.0);
}
#[test]
fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
use crate::{OutputSpace, Proof};
let src = scene();
let mut s = DevelopSettings::default();
s.color.saturation = 100.0;
s.color.vibrance = 100.0;
let info = SourceInfo { raw: true, ..Default::default() };
let req = |space, proof| RenderRequest { space, proof, ..RenderRequest::fit(120, 120) };
let red = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[..3] == [255, 0, 0]).count();
let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count();
// proofing sRGB on an sRGB render changes nothing but the warning
let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image;
let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
assert_eq!(plain, same);
let warned =
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image;
let n = red(&warned);
assert!(n > 0 && n < warned.len(), "{n}");
// …and nothing is flagged on an unsaturated edit of a neutral ramp
let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
let g = render(
&grey,
&info,
&DevelopSettings::default(),
&RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) },
);
assert_eq!(red(&g.image) + blue(&g.image), 0);
// a wide-gamut render proofed for sRGB loses the colours sRGB can't hold
let wide = render(&src, &info, &s, &req(OutputSpace::DisplayP3, None)).image;
let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
assert_ne!(wide, proofed);
// proofing a wider space than the display: what the display can't show is flagged blue
let pro =
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true })))
.image;
assert!(blue(&pro) > 0);
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
}
+1 -1
View File
@@ -22,7 +22,7 @@ fn noise(x: usize, y: usize, k: u32) -> f32 {
fn left_mask(adjust: LocalAdjustments) -> DevelopSettings {
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) };
let mut s = DevelopSettings::default();
s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
s.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
s
}
+1 -1
View File
@@ -310,7 +310,7 @@ mod tests {
let info = SourceInfo::default();
let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let mut s = DevelopSettings::default();
s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() });
s.masks.push(Mask { id: 7, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() });
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let shot = |s: &DevelopSettings, view: MaskView| {
let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 };
+1
View File
@@ -43,6 +43,7 @@ pub struct JobPool<S, R> {
shared: Arc<Shared<S, R>>,
tx: Sender<Done<S, R>>,
rx: Receiver<Done<S, R>>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
threads: usize,
started: bool,
seq: u64,
+3
View File
@@ -18,6 +18,9 @@ serde = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true }
miniz_oxide = { workspace = true }
zune-jpeg = { workspace = true }
zune-core = { workspace = true }
[dev-dependencies]
jpeg-encoder = { workspace = true }
proptest = { workspace = true }
-3
View File
@@ -57,9 +57,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
if !(1..=4).contains(&cpp) {
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
}
if info.compression == t::compression::LOSSY_JPEG {
return Err(RawError::Unsupported("lossy (DCT) JPEG DNG".into()));
}
if info.compression == t::compression::JPEG_XL {
return Err(RawError::Unsupported("JPEG XL DNG".into()));
}
+1 -1
View File
@@ -10,7 +10,7 @@
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
//! and produces camera → linear Rec.2020 D65 matrices.
//!
//! Formats: DNG (uncompressed, lossless JPEG, Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! Canon CR2, Nikon NEF/NRW (uncompressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon Huffman NEF, Panasonic
+84
View File
@@ -60,6 +60,13 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
crate::Mode::Full => read_image(data, info, order, packing),
crate::Mode::Header => {
check_image(data, info)?;
// a lossless-JPEG layout the decoder rejects (subsampled components, e.g. Sony's
// lossless M/S sizes) must fail here too, as the full decode will
if info.compression == 7
&& let Some(src) = info.chunks(data.len() as u64).first().and_then(|c| chunk_bytes(data, c))
{
ljpeg::frame_info(src)?;
}
Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) })
}
}
@@ -150,6 +157,19 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
v.resize(n, 0);
Ok(ChunkPx::U16(v))
}
comp::LOSSY_JPEG => {
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
let mut v = vec![0u16; n];
for y in 0..ch.min(jh) {
for x in 0..cw.min(jw) {
for k in 0..cpp {
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
}
}
}
Ok(ChunkPx::U16(v))
}
comp::ADOBE_DEFLATE | comp::DEFLATE => {
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) };
@@ -160,6 +180,24 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
}
}
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let c = if cpp == 1 { 1 } else { 3 };
if w == 0 || h == 0 || px.len() < w * h * c {
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
}
Ok((px, w, h, c))
}
#[allow(clippy::too_many_arguments)]
fn unpack_chunk(
src: &[u8],
@@ -248,3 +286,49 @@ fn unpack_chunk(
}
Ok(ChunkPx::U16(out))
}
#[cfg(test)]
mod lossy_tests {
use super::*;
use lightcraft_tiff::image::Layout;
/// A lossy-DNG-style image: two 16×8 tiles, each a baseline JPEG, the right one cut short
/// by the image edge.
#[test]
fn lossy_jpeg_tiles_decode() {
let (tw, th) = (16usize, 8usize);
let tile = |shade: u8| {
let px: Vec<u8> = (0..tw * th).flat_map(|i| [shade, (i % tw * 15) as u8, 200]).collect();
let mut out = Vec::new();
jpeg_encoder::Encoder::new(&mut out, 100).encode(&px, tw as u16, th as u16, jpeg_encoder::ColorType::Rgb).unwrap();
out
};
let (a, b) = (tile(40), tile(220));
let mut file = vec![0u8; 8];
let oa = file.len() as u64;
file.extend_from_slice(&a);
let ob = file.len() as u64;
file.extend_from_slice(&b);
let info = ImageInfo {
width: 24,
height: 8,
bits_per_sample: vec![8, 8, 8],
samples_per_pixel: 3,
compression: comp::LOSSY_JPEG,
photometric: 34892,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: tw as u32, tile_height: th as u32 },
offsets: vec![oa, ob],
byte_counts: vec![a.len() as u64, b.len() as u64],
};
let RawData::U16(v) = read_image(&file, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
assert_eq!(v.len(), 24 * 8 * 3);
let px = |x: usize, y: usize| &v[(y * 24 + x) * 3..(y * 24 + x) * 3 + 3];
assert!((px(2, 3)[0] as i32 - 40).abs() <= 3 && (px(2, 3)[2] as i32 - 200).abs() <= 3, "{:?}", px(2, 3));
assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3));
assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5));
}
}
+130 -4
View File
@@ -14,12 +14,14 @@
//! 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.
//!
//! Also: uncompressed 16-bit ARW, and lossless-JPEG tiled ARW through the generic TIFF path.
//! 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.
use crate::tiffraw::{Packing, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
use crate::tiffraw::{Packing, check_image, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
use lightcraft_tiff::tags::{self as t, photometric};
use lightcraft_tiff::{Ifd, Tiff};
use rayon::prelude::*;
@@ -87,6 +89,79 @@ pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) {
}
}
/// Whether `info` holds Sony's lossless-compressed layout: LJ92 tiles whose frames are half the tile in each
/// direction with four components (checked on the first tile).
fn is_quad_tiled(bytes: &[u8], info: &ImageInfo) -> bool {
let Layout::Tiles { tile_width, tile_height } = info.layout else { return false };
info.samples_per_pixel == 1
&& info
.chunks(bytes.len() as u64)
.first()
.and_then(|c| chunk_bytes(bytes, c))
.and_then(|src| ljpeg::frame_info(src).ok())
.is_some_and(|(fw, fh, nc, _)| nc == 4 && fw * 2 == tile_width as usize && fh * 2 == tile_height as usize)
}
/// Decode Sony's lossless-compressed raw data. Each tile is one LJ92 frame of half the tile's width and height whose
/// four components are the tile's 2×2 CFA cells in raster order (top-left, top-right, bottom-left, bottom-right), so
/// all four colour planes are predicted separately. Observed in the files' own frame headers (512×512 tiles holding
/// 256×256×4 frames) and checked on decoded images; the generic TIFF path reads a frame as a row-major sample stream
/// (DNG's convention), which would interleave each tile's left and right halves row by row.
fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
let (w, h) = (info.width as usize, info.height as usize);
let total = check_image(bytes, info)?;
let chunks = info.chunks(bytes.len() as u64);
let decoded: Vec<Result<(Chunk, ljpeg::Frame)>> = chunks
.par_iter()
.map(|c| {
let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("tile offset past end of file".into()))?;
let f = ljpeg::decode(src, (c.width as usize * c.height as usize).max(1 << 16))?;
if f.components != 4 || f.data.len() < f.width * f.height * 4 {
return Err(RawError::Corrupt(format!(
"lossless ARW tile: {} samples in a {}×{}×{} frame",
f.data.len(),
f.width,
f.height,
f.components
)));
}
Ok((*c, f))
})
.collect();
let mut out = vec![0u16; total];
let mut ok = 0usize;
let mut first_err = None;
for r in decoded {
let (c, f) = match r {
Ok(v) => v,
Err(e) => {
first_err.get_or_insert(e);
continue;
}
};
ok += 1;
let (x0, y0) = (c.x as usize, c.y as usize);
for fy in 0..f.height {
for k in 0..4 {
let y = y0 + 2 * fy + (k >> 1);
if y >= h {
continue;
}
for fx in 0..f.width {
let x = x0 + 2 * fx + (k & 1);
if x < w {
out[y * w + x] = f.data[(fy * f.width + fx) * 4 + k];
}
}
}
}
}
if ok == 0 {
return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable tiles".into())));
}
Ok(out)
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@@ -125,6 +200,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
(RawData::U16(data), 14)
}
32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())),
7 if is_quad_tiled(bytes, &info) => match mode {
Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info)?), bits),
Mode::Header => {
check_image(bytes, &info)?;
(RawData::U16(Vec::new()), bits)
}
},
1 => {
let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb };
(read_image_in(mode, bytes, &info, tiff.order, packing)?, bits)
@@ -245,4 +327,48 @@ mod tests {
let _ = code_curve(&[]);
let _ = code_curve(&[60000, 1, 0, 0]);
}
#[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,
// stored column by column in the file as Sony does while TileOffsets stay in raster order
let (w, h, tw) = (14usize, 12usize, 8usize);
let mosaic: Vec<u16> = (0..w * h).map(|i| 100 + (i * 37 % 4000) as u16).collect();
let at = |x: usize, y: usize| if x < w && y < h { mosaic[y * w + x] } else { 0 };
let mut file = vec![0u8; 16];
let mut offsets = vec![0u64; 4];
let mut counts = vec![0u64; 4];
for tx in 0..2 {
for ty in 0..2 {
let mut frame = Vec::new();
for fy in 0..tw / 2 {
for fx in 0..tw / 2 {
let (x, y) = (tx * tw + 2 * fx, ty * tw + 2 * fy);
frame.extend([at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)]);
}
}
let enc = ljpeg::encode(&frame, tw / 2, tw / 2, 4, 14, 1, 0);
offsets[ty * 2 + tx] = file.len() as u64;
counts[ty * 2 + tx] = enc.len() as u64;
file.extend(enc);
}
}
let info = ImageInfo {
width: w as u32,
height: h as u32,
bits_per_sample: vec![14],
samples_per_pixel: 1,
compression: 7,
photometric: photometric::CFA,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: tw as u32, tile_height: tw as u32 },
offsets,
byte_counts: counts,
};
assert!(is_quad_tiled(&file, &info));
assert_eq!(read_quad_tiles(&file, &info).unwrap(), mosaic);
}
}
+2 -1
View File
@@ -16,6 +16,8 @@ fn corpus_root() -> PathBuf {
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // CR3 / CRX (M11.1)
"arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG
"arw-sony-a7m4-lossless-s", // "
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
"rw2-panasonic-gh5.", // Panasonic raw format 4 (quantised)
"rw2-panasonic-gx80", // "
@@ -24,7 +26,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[
"nef-nikon-d5100-lossless", // Nikon Huffman NEF
"nef-nikon-d7000-lossy", // "
"sraw", // Canon sRAW / mRAW
"-lossy.dng", // lossy (DCT) DNG
];
#[test]
+2 -1
View File
@@ -453,7 +453,8 @@ fn rejects_unsupported_and_broken() {
let mut raw = base_ifd(w, h, 8, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG]));
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 10]] });
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Unsupported(_))));
// lossy JPEG DNGs decode now; a strip that isn't a JPEG is reported as corrupt
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Corrupt(_))));
let mut raw = base_ifd(w, h, 16, 1, true);
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 3, 1]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
+2
View File
@@ -73,6 +73,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"pixelsPerPoint": ctx.pixels_per_point(),
"canvasRect": app.canvas_rect.map(rect_json),
"imageRect": app.image_rect.map(rect_json),
"scroll": {"grid": app.grid_scroll, "filmstrip": app.film_scroll},
"active": app.session.active().map(|p| p.0),
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"activeMask": app.session.active_mask,
@@ -81,6 +82,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
"status": app.ui.status,
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
"memory": memory(app),
})
+275 -3
View File
@@ -553,6 +553,41 @@ mod tests {
/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows
/// progress, and reports the result when the files are written.
#[test]
fn export_dialog_keeps_its_width() {
// A dialog row sized as "available width − a guessed button width" made the auto-sized
// window grow a little every frame when the real button was wider (issue #8, Linux).
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.app.services.pick_folder = Some(Box::new(|| None));
h.app.services.pick_files = Some(Box::new(Vec::new));
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
// the widest variant: watermark graphic and every optional row
if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog {
let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() };
wm.text.clear();
opts.watermark = Some(wm);
}
let width = |h: &mut Headless| {
let r = h.request("ui.widgets", json!({}), t);
r["result"]
.as_array()
.and_then(|a| a.iter().find(|w| w["id"] == "dialog:window"))
.map(|w| w["rect"][2].as_f64().unwrap())
.expect("dialog on screen")
};
for _ in 0..5 {
h.step();
}
let w0 = width(&mut h);
for _ in 0..60 {
h.step();
}
let w1 = width(&mut h);
assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}");
assert!(w1 < 700.0, "{w1}");
}
#[test]
fn export_dialog_runs_in_the_background() {
let mut h = demo([1200.0, 900.0]);
@@ -621,6 +656,53 @@ mod tests {
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
}
/// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge
/// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos
/// show none of it.
#[test]
fn preview_only_raw_shows_badge_and_notices() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
let vis = h.app.session.visible_cloned();
let (po, other) = (vis[0], vis[1]);
let ph = h.app.session.catalog.photo(po).unwrap().clone();
h.app
.session
.catalog
.apply(lightcraft_catalog::Op::SetContent {
id: po,
width: ph.width,
height: ph.height,
file_size: ph.file_size,
content_hash: ph.content_hash.clone(),
preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()),
})
.unwrap();
h.settle(SETTLE);
let ids = |h: &mut Headless, filter: &str| -> Vec<String> {
let w = h.request("ui.widgets", json!({"filter": filter}), t);
w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect()
};
let badges = ids(&mut h, "badge:previewOnly:");
assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge");
// the loupe and the Edit panel
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
let n = ids(&mut h, "notice:previewOnly:");
assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}");
// Info
h.app.ui.right = crate::state::RightPanel::Info;
h.settle(SETTLE);
assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string()));
// a regular photo: no notice
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t);
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
assert!(ids(&mut h, "notice:previewOnly:").is_empty());
}
/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect.
#[test]
fn crop_keys() {
@@ -728,6 +810,40 @@ mod tests {
assert_eq!(exposure(&h), 1.0);
}
/// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo.
#[test]
fn slider_nudge_and_pick_advance() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
h.settle(SETTLE);
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true);
h.request("ui.key", json!({"key": "Up"}), t);
h.request("ui.key", json!({"key": "Up", "shift": true}), t);
assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h));
h.request("ui.key", json!({"key": "Down"}), t);
assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h));
let first = h.app.session.active().unwrap();
h.request("ui.key", json!({"key": "Z", "shift": true}), t);
assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick);
assert_ne!(h.app.session.active(), Some(first), "advanced");
}
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
#[test]
fn return_commits_the_crop_tool() {
let mut h = demo([1200.0, 800.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.crop"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools");
}
/// ⌘Q (File → Quit LightCraft) closes the window.
#[test]
fn cmd_q_quits() {
@@ -765,11 +881,76 @@ mod tests {
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert!(opts.copy);
let _ = std::fs::remove_dir_all(&dir);
}
/// The folder scan runs in the background: the request returns at once and the review opens
/// when the scan finishes (a folder on a network share must not freeze the window).
#[test]
fn add_folder_scans_in_the_background() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let png = lightcraft_engine::export::encode_image(
&img,
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
)
.unwrap();
std::fs::write(dir.join("a.png"), &png).unwrap();
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some());
h.settle(SETTLE);
assert!(h.app.scan.is_none());
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let _ = std::fs::remove_dir_all(&dir);
}
/// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is
/// refused and leaves the running scan's options alone.
#[test]
fn folder_scan_cancel_and_busy() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let png = lightcraft_engine::export::encode_image(
&img,
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
)
.unwrap();
std::fs::write(dir.join("a.png"), &png).unwrap();
// a scan that can't finish until the test lets it: the probe waits on a flag
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let g = gate.clone();
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
while !g.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
}));
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t);
assert_ne!(r["ok"], true, "a second scan is refused: {r}");
assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options");
let r = h.request("ui.inspect", json!({}), t);
assert!(r["result"]["scan"].is_object(), "{r}");
let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.scan.is_none(), "Cancel closes the scan at once");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
h.settle(SETTLE);
assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review");
let _ = std::fs::remove_dir_all(&dir);
}
/// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group
/// leaves it alone. ⌘F puts typing into the search field.
#[test]
@@ -841,7 +1022,7 @@ mod tests {
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green));
h.request("engine.execute", json!({"command": "dialog.labelNames"}), t);
if let Some(crate::state::Dialog::LabelNames { names }) = &mut h.app.ui.dialog {
if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog {
names[2] = "Approved".into();
} else {
panic!("no dialog");
@@ -880,9 +1061,11 @@ mod tests {
let n0 = h.app.session.catalog.len();
let undo0 = h.app.session.undo.len();
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
assert_eq!(r["result"]["candidates"], 6, "{r}");
assert_eq!(r["result"]["duplicates"], 1, "{r}");
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.candidates.len(), 6);
assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1);
assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails");
// uncheck the first photo; name a new album and keywords
let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t);
@@ -909,6 +1092,53 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across
/// the import's batches.
#[test]
fn import_copy_renames_into_destination() {
let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
for i in 0..10u8 {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
}
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.candidates.len(), 10);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
assert!(opts.copy);
assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder");
opts.organize = "flat".into();
opts.rename = "Trip-{seq:2}".into();
} else {
panic!("no import dialog");
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.import.is_none(), "finished");
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
let _ = std::fs::remove_dir_all(&base);
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]
@@ -1010,6 +1240,48 @@ mod tests {
h.settle(SETTLE);
}
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
#[test]
fn click_zoom_ratio_animates() {
use crate::render::Slot;
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default");
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t);
assert_eq!(r["result"]["ratio"], 3, "{r}");
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t);
assert_ne!(r["ok"], true, "{r}");
let fit = h.app.image_rect.unwrap();
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
assert!(h.app.ui.zoom_anim, "animation started");
// every animation frame asks for the same (final-size) render
let mut keys = std::collections::HashSet::new();
for _ in 0..40 {
h.step();
keys.extend(h.app.renderer.wanted(Slot::Main));
if !h.app.ui.zoom_anim {
break;
}
}
assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}");
h.settle(SETTLE);
assert!(!h.app.ui.zoom_anim, "animation finished");
assert!(h.app.image_rect.unwrap().width() > fit.width());
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
// Z zooms to the same ratio as a click
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
h.settle(SETTLE);
}
/// The Navigator appears when zoomed in; clicking it pans to that point.
#[test]
fn navigator_pans_the_zoomed_loupe() {
+188 -14
View File
@@ -5,9 +5,10 @@
//! frame, with a progress window; the whole import is one undo step.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_engine::import::ImportCandidate;
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use crate::LightcraftApp;
use crate::render::Slot;
@@ -15,7 +16,7 @@ use crate::theme::Tokens;
use crate::widgets::register;
/// Files per batch (one batch per frame, so the progress window updates).
const BATCH: usize = 8;
pub(crate) const BATCH: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -32,6 +33,16 @@ pub struct ImportDialog {
pub preset: String,
/// Comma-separated keywords.
pub keywords: String,
/// Copy: destination folder ("" = the library's Originals/).
pub destination: String,
/// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`.
pub organize: String,
/// Copy: file-name template for the copies ("" = keep the names).
pub rename: String,
/// Metadata preset name ("" = none).
pub metadata_preset: String,
/// Copy: raws are copied as DNG.
pub dng: bool,
}
impl ImportDialog {
@@ -69,19 +80,93 @@ pub struct ImportTask {
first: Option<u64>,
}
/// Scan `paths` and open the review dialog. Returns the candidate counts.
/// A folder scan running on a worker thread (a network share can take minutes to read; the
/// window must keep answering meanwhile).
pub struct ScanTask {
progress: std::sync::Arc<ScanProgress>,
rx: std::sync::mpsc::Receiver<ScanOutput>,
/// Open the review with "copy into the library" checked (a camera / card).
pub copy: bool,
}
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
let r = app.session.execute("library.importPreview", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let candidates: Vec<ImportCandidate> = serde_json::from_value(r["candidates"].clone()).unwrap_or_default();
if candidates.is_empty() {
app.toast(&egui::Context::default(), "No photos found");
return Ok(json!({"candidates": 0}));
if app.scan.is_some() {
return Err("a scan is already running".into());
}
let (input, paths) = ScanInput::new(&mut app.session, &paths);
let progress = std::sync::Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
let job = move || {
let _ = tx.send(scan_with(input, &paths, &p));
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false });
Ok(json!({"scanning": true}))
}
/// Collect a finished scan and open the review (called every frame).
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = app.scan.as_ref() else { return };
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let out = match task.rx.try_recv() {
Ok(o) => o,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.scan = None;
app.toast(ctx, "Scan failed");
return;
}
};
let task = app.scan.take().expect("scan task");
if task.progress.cancel.load(Ordering::Relaxed) {
return;
}
app.session.import_probes = out.probes;
if out.candidates.is_empty() {
app.toast(ctx, "No photos found");
return;
}
app.renderer.forget_imports();
let d = ImportDialog::new(candidates);
let out = json!({"candidates": d.candidates.len(), "duplicates": r["duplicates"]});
let mut d = ImportDialog::new(out.candidates);
d.copy = task.copy;
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
Ok(out)
}
/// The progress window while a folder is being scanned.
pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.scan else { return };
let t = Tokens::get(ctx);
let total = task.progress.total.load(Ordering::Relaxed);
let done = task.progress.done.load(Ordering::Relaxed);
let text = if total == 0 { "Looking for photos…".to_string() } else { format!("Reading photos… {done} of {total}") };
let mut cancel = false;
egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show(
ctx,
|ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0));
let r = ui.button("Cancel");
register(ui.ctx(), "button:scanCancel", r.rect);
cancel = r.clicked();
},
);
if cancel {
// the worker stops at its next file; don't wait for it (a NAS read can take a while)
task.progress.cancel.store(true, Ordering::Relaxed);
app.scan = None;
}
}
impl ScanTask {
/// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed).
pub fn status(&self) -> Value {
json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)})
}
}
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`).
@@ -103,6 +188,24 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
if !d.preset.is_empty() {
params["preset"] = json!(d.preset);
}
if !d.metadata_preset.is_empty() {
params["metadataPreset"] = json!(d.metadata_preset);
}
if d.copy {
if !d.destination.trim().is_empty() {
params["destination"] = json!(d.destination.trim());
}
if !d.organize.is_empty() {
params["organize"] = json!(d.organize);
}
if !d.rename.trim().is_empty() {
params["rename"] = json!(d.rename.trim());
params["renameStart"] = json!(1);
}
if d.dng {
params["dng"] = json!(true);
}
}
let total = queue.len();
app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() });
app.renderer.forget_imports();
@@ -116,6 +219,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let batch: Vec<String> = task.queue.drain(..n).collect();
let mut p = task.params.clone();
p["paths"] = json!(batch);
// renamed copies keep counting across batches
if p.get("rename").is_some() {
p["renameStart"] = json!(1 + task.imported);
}
let r = app.session.execute("library.import", &p);
let task = app.import.as_mut().expect("import task");
task.done += n;
@@ -216,12 +323,64 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
d.copy = false;
}
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk);
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy into library", d.copy)).inner;
if r.on_hover_text("Copy into the library's Originals/YYYY/YYYY-MM-DD/ folders").clicked() {
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy)).inner;
if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() {
d.copy = true;
}
});
if d.copy {
field(ui, "Copy to", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
{
d.destination = f;
}
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() {
d.destination.clear();
}
});
field(ui, "Folders", |ui| {
let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
for (k, label) in opts {
if ui.selectable_label(key == k, label).clicked() {
d.organize = k.to_string();
}
}
});
});
field(ui, "Raw files", |ui| {
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
register(ui.ctx(), "check:importDng", r.rect);
});
field(ui, "Rename", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY));
register(ui.ctx(), "field:importRename", r.rect);
});
if !d.rename.trim().is_empty()
&& let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c)
{
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(0),
lightcraft_catalog::Source::Demo { scene: 0 },
&c.name,
&c.format,
0,
0,
"",
);
q.captured = c.captured.clone();
let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1);
ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim));
}
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
@@ -270,6 +429,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() {
d.metadata_preset.clear();
}
for m in &app.session.metadata_presets {
if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() {
d.metadata_preset = m.name.clone();
}
}
});
});
}
field(ui, "Keywords", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY));
register(ui.ctx(), "field:importKeywords", r.rect);
+271 -5
View File
@@ -22,8 +22,12 @@ pub mod state;
pub mod theme;
pub mod widgets;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_masking;
#[cfg(test)]
mod tests_scroll;
use std::sync::mpsc::{Receiver, Sender};
@@ -46,16 +50,22 @@ pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a URL in the user's browser.
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a file in an application (`app` = "" for the system's default one).
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`).
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
pub pick_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
pub pick_curve_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lccurve` file.
pub save_curve_preset_file: Option<SaveFile>,
pub write: Option<WriteFn>,
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
pub write_shared: Option<SharedWrite>,
@@ -67,6 +77,8 @@ pub struct Services {
pub pick_folder: Option<PickFolder>,
/// Open a web link in the browser (Help menu, About, Discord button).
pub open_url: Option<OpenUrlFn>,
/// Open a file in an external editor (Edit in External Editor; desktop only).
pub open_with: Option<OpenWithFn>,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -76,6 +88,9 @@ pub struct Perf {
}
pub struct LightcraftApp {
/// Per-catalog-revision caches of library-wide results the panels show every frame
/// (expensive on big libraries).
pub caches: Caches,
pub session: Session,
pub ui: UiState,
pub services: Services,
@@ -107,6 +122,10 @@ pub struct LightcraftApp {
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
pub canvas_rect: Option<egui::Rect>,
pub image_rect: Option<egui::Rect>,
/// Scroll offsets (points) of the photo grid (vertical) and the filmstrip (horizontal) as
/// drawn last (`ui.inspect` → `scroll`).
pub grid_scroll: Option<f32>,
pub film_scroll: Option<f32>,
/// Widget registry from the last frame (automation ids → rects).
pub widgets: Vec<(String, egui::Rect)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
@@ -118,6 +137,8 @@ pub struct LightcraftApp {
pub merge: merge::MergeState,
/// An import in progress (the import review dialog's batches).
pub import: Option<import::ImportTask>,
/// A folder scan in progress (feeds the import review).
pub scan: Option<import::ScanTask>,
/// A background export in progress.
pub export: Option<export_task::ExportTask>,
/// The files of the last finished background export (`ui.inspect` → `export.last`).
@@ -143,6 +164,7 @@ impl LightcraftApp {
services,
renderer: render::Renderer::default(),
perf: Perf::default(),
caches: Caches::default(),
integrated_titlebar: false,
native_menu: false,
native_shortcuts: Default::default(),
@@ -159,11 +181,14 @@ impl LightcraftApp {
last_time: 0.0,
canvas_rect: None,
image_rect: None,
grid_scroll: None,
film_scroll: None,
widgets: vec![],
gesture: None,
loupe_shown: None,
merge: merge::MergeState::default(),
import: None,
scan: None,
export: None,
last_export_result: None,
hover_preview: None,
@@ -193,6 +218,54 @@ impl LightcraftApp {
}
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
/// Advance a running slideshow (wrapping around at the end).
fn slideshow_tick(&mut self, ctx: &egui::Context) {
let Some((interval, due, paused)) = self.ui.slideshow else { return };
if !self.ui.fullscreen {
self.ui.slideshow = None;
return;
}
if paused {
return;
}
let now = ctx.input(|i| i.time);
if now >= due {
let vis = self.session.visible_cloned();
if let Some(cur) = self.session.active()
&& !vis.is_empty()
{
let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len());
let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]}));
}
self.ui.slideshow = Some((interval, now + interval, false));
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval)));
}
/// Announce the start and end of a Build Previews run.
fn preview_build_status(&mut self, ctx: &egui::Context) {
use std::sync::atomic::Ordering;
let Some(b) = self.session.preview_build.clone() else { return };
let key = std::sync::Arc::as_ptr(&b) as usize;
if b.finished.load(Ordering::Relaxed) {
if self.ui.preview_build_seen != Some((key, true)) {
self.ui.preview_build_seen = Some((key, true));
let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed));
let mut msg = format!("Previews ready for {done} photo{}", if done == 1 { "" } else { "s" });
if failed > 0 {
msg.push_str(&format!(" · {failed} couldn't be rendered"));
}
self.toast(ctx, msg);
}
} else {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
self.toast(ctx, format!("Building previews for {} photos…", b.total));
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
}
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 1.4));
@@ -348,7 +421,36 @@ impl LightcraftApp {
}
self.renderer.poll(ctx, &mut self.session);
merge::poll(self, ctx);
import::poll_scan(self, ctx);
import::tick(self, ctx);
self.preview_build_status(ctx);
self.slideshow_tick(ctx);
// back from an external editor: pick up the files it saved
let focused = ctx.input(|i| i.focused);
if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() {
let ids = self.ui.external_edits.clone();
if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids}))
&& r["reloaded"].as_array().is_some_and(|a| !a.is_empty())
{
self.toast(ctx, "Updated the edits saved in the external editor");
}
}
self.ui.was_focused = focused;
// auto import: scan the watched folder every few seconds
#[cfg(not(target_arch = "wasm32"))]
if self.session.import_defaults.auto_folder.is_some() {
let now = ctx.input(|i| i.time);
if now - self.ui.auto_import_at >= 3.0 {
self.ui.auto_import_at = now;
if let Ok(r) = self.session.execute("library.autoImportScan", &serde_json::json!({})) {
let n = r["imported"].as_array().map_or(0, Vec::len);
if n > 0 {
self.toast(ctx, format!("Auto Import: added {n} photo{}", if n == 1 { "" } else { "s" }));
}
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
self.session.persist_if_dirty();
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
@@ -359,8 +461,13 @@ impl LightcraftApp {
{
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
if !dropped.is_empty() {
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
// preset files import as presets, everything else as photos
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
if !photos.is_empty() {
let _ = self.run("library.import", serde_json::json!({"paths": photos}));
}
}
}
@@ -438,6 +545,7 @@ impl LightcraftApp {
if self.ui.fullscreen {
// full-screen preview: the photo alone on black
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
panels::second::show(self, &ctx);
panels::dialogs::show(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
@@ -459,16 +567,23 @@ impl LightcraftApp {
}
panels::bottombar::show(self, ui);
let t = theme::Tokens::get(&ctx);
let bg =
if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg };
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference)
{
t.canvas
} else {
t.grid_bg
};
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
state::ViewMode::Detail => panels::detail::show(self, ui),
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
});
panels::second::show(self, &ctx);
panels::dialogs::show(self, &ctx);
import::progress(self, &ctx);
import::scan_progress(self, &ctx);
export_task::poll(self, &ctx);
panels::grid::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
@@ -514,3 +629,154 @@ pub struct HoverPreview {
pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.")
}
/// Files dropped on the window that are presets rather than photos.
pub fn is_preset_file(path: &str) -> bool {
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str())
}
#[cfg(test)]
mod drop_tests {
#[test]
fn dropped_presets_are_told_apart_from_photos() {
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
assert!(super::is_preset_file(p), "{p}");
}
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
assert!(!super::is_preset_file(p), "{p}");
}
}
}
/// Results recomputed only when the catalog (or their inputs) change.
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
date_runs: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateRun>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
}
/// The left panel's counts.
#[derive(Clone, Copy, Debug, Default)]
pub struct LibraryCounts {
pub total: usize,
pub picks: usize,
pub deleted: usize,
}
/// The values the filter bar's pickers offer.
#[derive(Clone, Debug, Default)]
pub struct FilterValues {
pub cameras: Vec<String>,
pub lenses: Vec<String>,
pub keywords: Vec<String>,
}
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}
impl Caches {
/// The library's keyword tree.
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
match &self.keyword_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.keyword_tree());
self.keyword_tree = Some((cat.revision, t.clone()));
t
}
}
}
/// All Photos / Picks / Recently Deleted counts.
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
if let Some((r, c)) = self.counts
&& r == cat.revision
{
return c;
}
let mut c = LibraryCounts::default();
for p in cat.photos() {
if p.in_library() {
c.total += 1;
if p.flag == lightcraft_catalog::Flag::Pick {
c.picks += 1;
}
} else if p.deleted && !p.local {
c.deleted += 1;
}
}
self.counts = Some((cat.revision, c));
c
}
/// The By Date tree.
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
match &self.date_groups {
Some((r, g)) if *r == cat.revision => g.clone(),
_ => {
let g = std::sync::Arc::new(cat.date_groups());
self.date_groups = Some((cat.revision, g.clone()));
g
}
}
}
/// Cameras, lenses and keywords in the library (filter bar pickers).
pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> {
match &self.filter_values {
Some((r, v)) if *r == cat.revision => v.clone(),
_ => {
let distinct = |mut v: Vec<String>| {
v.retain(|s| !s.trim().is_empty());
v.sort_by_key(|s| s.to_lowercase());
v.dedup();
v
};
let lib = || cat.photos().filter(|p| p.in_library());
let v = std::sync::Arc::new(FilterValues {
cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()),
lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()),
keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(),
});
self.filter_values = Some((cat.revision, v.clone()));
v
}
}
}
/// Date headers for `ids` in the grid.
pub fn date_runs(
&mut self,
cat: &lightcraft_catalog::Catalog,
ids: &[lightcraft_catalog::PhotoId],
key: lightcraft_catalog::SortKey,
by: lightcraft_catalog::GroupBy,
) -> std::sync::Arc<Vec<lightcraft_catalog::DateRun>> {
let k = key_of((cat.revision, ids, format!("{key:?}{by:?}")));
match &self.date_runs {
Some((h, r)) if *h == k => r.clone(),
_ => {
let r = std::sync::Arc::new(cat.date_runs(ids, key, by));
self.date_runs = Some((k, r.clone()));
r
}
}
}
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
let k = key_of((cat.revision, current, prefix, n));
match &self.suggestions {
Some((h, v)) if *h == k => v.clone(),
_ => {
let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n));
self.suggestions = Some((k, v.clone()));
v
}
}
}
}
+4
View File
@@ -21,8 +21,12 @@ pub const LINKS: &[(&str, &str, &str)] = &[
("app.website", "LightCraft Website", APP_PAGE),
("app.github", "LightCraft on GitHub", GITHUB),
("app.artcraft", "ArtCraft Website", WEBSITE),
("app.feedback", "Send Feedback…", FEEDBACK),
];
/// Where feedback and bug reports go.
pub const FEEDBACK: &str = "https://github.com/storytold/lightcraft/issues/new";
/// The URL behind a link command id.
pub fn url_of(cmd: &str) -> Option<&'static str> {
LINKS.iter().find(|(id, _, _)| *id == cmd).map(|(_, _, u)| *u)
+118 -1
View File
@@ -60,6 +60,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"dialog.newAlbum",
"dialog.newFolder",
"dialog.smartAlbum",
"dialog.newSmartAlbum",
"---",
"file.importPresets",
@@ -70,6 +71,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"@Export with Preset",
"---",
"library.toggleAutoWriteXmp",
"@Previews",
"---",
"app.quit",
],
@@ -84,6 +86,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"dialog.copySettings",
"develop.paste",
"dialog.pasteSettings",
"develop.sync",
"develop.autoSync",
"---",
"library.selectAll",
"library.selectNone",
@@ -99,11 +103,13 @@ const LAYOUT: &[(&str, &[&str])] = &[
&[
"view.photoGrid",
"view.squareGrid",
"@Grid Info",
"view.detail",
"view.compare",
"view.survey",
"---",
"view.leftPanel",
"view.photoCounts",
"view.filmstrip",
"view.histogram",
"view.navigator",
@@ -124,6 +130,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.zoom100",
"---",
"view.clipping",
"view.softProof",
"view.maskOverlay",
"view.maskOverlayMode",
"view.maskOverlayColor",
@@ -147,6 +154,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"@Set Rating",
"@Set Flag",
"@Set Color Label",
"@Metadata Preset",
"view.autoAdvance",
"---",
"photo.rotateLeft",
@@ -192,7 +200,23 @@ const LAYOUT: &[(&str, &[&str])] = &[
"@Tools",
],
),
("Help", &["app.discord", "---", "app.website", "app.github", "app.artcraft", "---", "app.shortcuts", "---", "app.about"]),
(
"Help",
&[
"app.discord",
"app.feedback",
"---",
"app.website",
"app.github",
"app.artcraft",
"---",
"app.whatsNew",
"app.shortcuts",
"app.systemInfo",
"---",
"app.about",
],
),
];
/// Registry entries that are reached another way (parameterized commands are expanded into
@@ -220,15 +244,20 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
let u = &app.ui;
let panel = |p: RightPanel| Some(u.right == p);
match id {
"develop.autoSync" => Some(app.session.auto_sync),
"view.photoCounts" => Some(u.show_counts),
"view.secondWindow" => Some(u.second_window),
"view.photoGrid" => Some(u.view == ViewMode::PhotoGrid),
"view.squareGrid" => Some(u.view == ViewMode::SquareGrid),
"view.detail" => Some(u.view == ViewMode::Detail),
"view.compare" => Some(u.view == ViewMode::Compare),
"view.survey" => Some(u.view == ViewMode::Survey),
"view.reference" => Some(u.view == ViewMode::Reference),
"view.leftPanel" => Some(u.left_panel),
"view.filmstrip" => Some(u.filmstrip),
"view.histogram" => Some(u.histogram),
"view.clipping" => Some(u.show_clipping),
"view.softProof" => Some(u.soft_proof),
"view.maskOverlay" => Some(u.mask_overlay),
"view.maskPins" => Some(u.mask_pins),
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
@@ -273,6 +302,41 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
let has = active.is_some();
Some(match name {
"Previews" => {
let running = app.session.preview_build.as_ref().is_some_and(|b| !b.finished.load(std::sync::atomic::Ordering::Relaxed));
let scope = if app.session.selection.ids.len() > 1 { "Selected" } else { "Visible" };
vec![
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
format!("Build Standard-Sized Previews ({scope})"),
None,
!running,
None,
),
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
item(
"library.smartPreviews",
Value::Null,
format!("Build Smart Previews ({scope})"),
None,
app.session.media.smart_dir.is_some(),
None,
),
item(
"library.smartPreviews",
json!({"discard": true}),
format!("Discard Smart Previews ({scope})"),
None,
app.session.media.smart_dir.is_some(),
None,
),
MenuNode::Separator,
item("library.clearPreviews", Value::Null, "Discard Preview Cache", None, true, None),
]
}
"Set Rating" => (0..=5u8)
.map(|r| {
let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) };
@@ -313,9 +377,28 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.push(MenuNode::Separator);
v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none()))));
v.push(MenuNode::Separator);
// label sets: each a checkable item; Edit… names them
let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session);
let current = sets["current"].as_str().map(str::to_string);
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
v.push(item(
"label.applySet",
json!({"name": name}),
format!("Label Set: {name}"),
None,
true,
Some(current.as_deref() == Some(name)),
));
}
v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None));
v
}
"Grid Info" => ["filename", "exposure", "date"]
.into_iter()
.zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"])
.map(|(k, label)| item("view.gridInfo", json!({"info": k}), label, None, true, Some(app.ui.grid_info == k)))
.collect(),
"Sort" => {
use lightcraft_catalog::SortKey::*;
let cur = app.session.sort;
@@ -353,6 +436,20 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
devices.into_iter().map(|d| item("file.addFromDevice", json!({"path": d.path}), format!("{}…", d.name), None, true, None)).collect()
}
}
"Metadata Preset" => {
let sel = !app.session.selection.ids.is_empty() || has;
let mut v: Vec<MenuNode> = app
.session
.metadata_presets
.iter()
.map(|m| item("metadata.applyPreset", json!({"name": m.name}), m.name.clone(), None, sel, None))
.collect();
if !v.is_empty() {
v.push(MenuNode::Separator);
}
v.push(item("dialog.saveMetadataPreset", Value::Null, "Save Metadata Preset…", None, has, None));
v
}
"Select by" => {
let mut v: Vec<MenuNode> = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")]
.into_iter()
@@ -771,6 +868,26 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metadata_preset_menu() {
let mut app = app();
app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap();
run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap();
if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog {
*value = "Mine".into();
}
let d = app.ui.dialog.take().unwrap();
crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap();
let bar = menu_bar(&app);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") };
assert_eq!(params["name"], "Mine");
let other = app.session.visible()[1];
app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap();
run_item(&mut app, id, params.clone()).unwrap();
assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me");
}
#[test]
fn shortcut_text_per_platform() {
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
+373 -18
View File
@@ -11,11 +11,16 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
pub const UI_COMMANDS: &[UiCommand] = &[
("view.photoGrid", "Photo Grid", Some("G"), "View"),
("view.squareGrid", "Square Grid", Some("Shift+G"), "View"),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
("view.gridToggle", "Grid", Some("G"), ""),
("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"),
("view.detail", "Detail", Some("D"), "View"),
("view.compare", "Compare", Some("Shift+C"), "View"),
("view.survey", "Survey", Some("N"), "View"),
("view.reference", "Reference View", Some("Shift+R"), "View"),
("photo.setReference", "Set as Reference Photo", None, ""),
("compare.swap", "Swap Compare Photos", None, "View"),
("compare.makeSelect", "Make Candidate the Select", None, "View"),
("view.autoAdvance", "Auto Advance", None, "Photo"),
@@ -27,11 +32,15 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"),
("view.showOriginal", "Show Original", Some("\\"), "View"),
("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"),
("view.zoom100", "Zoom 100%", Some("Cmd+1"), "View"),
("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"),
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
// the ratio a click (and Z / Space) zooms to
("view.clickZoom", "Click Zoom Ratio", None, ""),
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
("view.clipping", "Show Clipping", Some("J"), "View"),
// in grids S expands/collapses stacks (the engine command it shadows)
("view.softProof", "Soft Proofing", Some("S"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
@@ -42,6 +51,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("tool.done", "Done", Some("Enter"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
@@ -59,11 +69,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("panel.versions", "Versions", Some("Shift+V"), "Window"),
("panel.activity", "History", None, "Window"),
("panel.close", "Close Panel", None, ""),
("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"),
("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"),
("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"),
("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"),
("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"),
("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"),
("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"),
("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"),
("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"),
("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"),
("tool.brush", "Brush", Some("B"), "Window>Tools"),
("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"),
("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"),
@@ -76,13 +86,21 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
("dialog.smartAlbum", "New Smart Album…", None, "File"),
("view.photoCounts", "Show Photo Counts", None, "View"),
("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"),
("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"),
("tool.keywordPainter", "Keyword Painter", None, ""),
("view.gridInfo", "Grid Info", None, ""),
("dialog.allMetadata", "All Metadata…", None, "Photo"),
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"),
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"),
("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"),
("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"),
("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"),
("dialog.export", "Export…", None, "File"),
("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"),
("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"),
("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"),
("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"),
@@ -94,21 +112,29 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("file.addFromDevice", "Add from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
("photo.locate", "Locate Missing File…", None, ""),
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
("file.importPresets", "Import Presets…", None, "File"),
("file.importPresets", "Import Profiles & Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
// Edit panel ▸ Curve ▸ Point Curve dropdown
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
("app.openLibrary", "Open Library…", None, "File"),
("app.about", "About LightCraft", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.whatsNew", "What's New", None, "Help"),
("dialog.cull", "Assisted Culling…", None, "Photo"),
("app.help", "LightCraft Help", Some("F1"), "Help"),
("app.discord", "Join the ArtCraft Discord…", None, "Help"),
("app.feedback", "Send Feedback…", None, "Help"),
("app.website", "LightCraft Website", None, "Help"),
("app.github", "LightCraft on GitHub", None, "Help"),
("app.artcraft", "ArtCraft Website", None, "Help"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
("dialog.rename", "Rename Photos…", None, "Photo"),
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
("dialog.labelNames", "Edit Color Label Names…", None, ""),
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
@@ -182,6 +208,44 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.view = ViewMode::SquareGrid;
Ok(Value::Null)
}
"view.gridInfo" => {
// {info?: filename | exposure | date} (cycles when omitted)
let next = match p.get("info").and_then(Value::as_str) {
Some(i @ ("filename" | "exposure" | "date")) => i.to_string(),
Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))),
None => match app.ui.grid_info.as_str() {
"filename" => "exposure".into(),
"exposure" => "date".into(),
_ => "filename".into(),
},
};
app.ui.grid_info = next;
app.ui.show_filenames = true;
Ok(json!({"info": app.ui.grid_info}))
}
"tool.keywordPainter" => {
// {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops
app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string);
if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) {
app.ui.view = ViewMode::PhotoGrid;
}
if let Some(k) = app.ui.keyword_painter.clone() {
app.toast(&ctx, format!("Painting “{k}”: click photos to add or remove it · Esc stops"));
}
Ok(json!({"keyword": app.ui.keyword_painter}))
}
"view.secondWindow" => {
app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window);
Ok(json!({"show": app.ui.second_window}))
}
"view.photoCounts" => {
app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts);
Ok(json!({"show": app.ui.show_counts}))
}
"view.gridToggle" => {
app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid };
Ok(Value::Null)
}
"view.detail" => {
app.ui.view = ViewMode::Detail;
Ok(Value::Null)
@@ -191,6 +255,30 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.view = ViewMode::Survey;
Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()}))
}
"view.reference" => {
// the reference: the one set before, else the active photo (the next one becomes active)
let vis = app.session.visible_cloned();
let reference = app
.ui
.reference
.filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some())
.or_else(|| app.session.active().map(|a| a.0));
let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) };
app.ui.reference = Some(r);
if app.session.active().is_none_or(|a| a.0 == r)
&& let Some(i) = vis.iter().position(|x| x.0 == r)
&& let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j)))
{
let _ = app.session.execute("library.select", &json!({"ids": [next.0]}));
}
app.ui.view = ViewMode::Reference;
Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)}))
}
"photo.setReference" => {
let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0));
app.ui.reference = id;
Ok(json!({"reference": id}))
}
"compare.swap" => crate::panels::compare::swap(app),
"compare.makeSelect" => crate::panels::compare::make_select(app),
"view.autoAdvance" => {
@@ -201,8 +289,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"view.back" => {
if app.ui.dialog.is_some() {
app.ui.dialog = None;
} else if app.ui.keyword_painter.is_some() {
app.ui.keyword_painter = None;
} else if app.ui.fullscreen {
app.ui.fullscreen = false;
app.ui.slideshow = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
@@ -212,8 +303,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(Value::Null)
}
"view.slideshow" => {
// {interval?: seconds (4)}: the photos in view, full screen, one after another
let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0);
if app.session.active().is_none() {
let first = app.session.visible_cloned().first().copied();
match first {
Some(f) => {
let _ = app.run("library.select", json!({"ids": [f.0]}));
}
None => return Some(Err("no photos to show".into())),
}
}
let now = ctx.input(|i| i.time);
app.ui.slideshow = Some((interval, now + interval, false));
app.ui.fullscreen = true;
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
app.toast(&ctx, "Slideshow · Space pauses · Esc ends");
Ok(json!({"interval": interval}))
}
"view.fullScreenPreview" => {
app.ui.fullscreen = !app.ui.fullscreen;
if !app.ui.fullscreen {
app.ui.slideshow = None;
}
if app.ui.fullscreen {
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
@@ -293,9 +408,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.zoomToggle" => {
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
// the same ratio a click on the photo zooms to
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
app.ui.zoom_anim = true;
Ok(Value::Null)
}
"view.clickZoom" => {
// {ratio?: 1|2|3|4|8} → {ratio}
if let Some(r) = p.get("ratio").and_then(Value::as_f64) {
let pct = (r * 100.0).round() as u32;
if !crate::state::CLICK_ZOOMS.contains(&pct) {
return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)")));
}
app.ui.click_zoom = pct;
}
Ok(json!({"ratio": app.ui.click_zoom / 100}))
}
"view.zoomIn" | "view.zoomOut" => {
let steps = [25u32, 50, 100, 200, 400, 800];
let cur = match app.ui.zoom {
@@ -314,6 +442,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.show_clipping = !app.ui.show_clipping;
Ok(Value::Null)
}
"view.softProof" => {
// {on?, space?, destWarning?, displayWarning?}; no params toggles
let has = |k: &str| p.get(k).is_some();
if let Some(s) = p.get("space").and_then(Value::as_str) {
match lightcraft_engine::pipeline::OutputSpace::parse(s) {
Some(sp) => app.ui.proof.space = sp,
None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))),
}
}
if let Some(b) = p.get("destWarning").and_then(Value::as_bool) {
app.ui.proof.dest_warning = b;
}
if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) {
app.ui.proof.display_warning = b;
}
app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) {
Some(b) => b,
None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof,
None => !app.ui.soft_proof,
};
if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) {
app.ui.view = ViewMode::Detail;
}
let pr = app.ui.proof;
Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning}))
}
"view.histogram" => {
app.ui.histogram = !app.ui.histogram;
Ok(Value::Null)
@@ -460,6 +614,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(Value::Null)
}
"tool.done" => {
// Return commits a tool panel (crop, remove, red eye, masking): back to Edit
use RightPanel::*;
if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) {
let _ = app.session.end_interaction();
app.ui.tool.clear();
app.ui.right = Edit;
}
Ok(Value::Null)
}
"panel.close" => {
app.ui.right = RightPanel::None;
app.ui.presets = false;
@@ -477,6 +641,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let tool = &s["tool.".len()..];
match tool {
"none" => app.ui.tool.clear(),
"guidedUpright" => {
// Crop & Geometry with Guided Upright on, ready to draw guides
app.ui.right = RightPanel::Crop;
app.ui.view = ViewMode::Detail;
let guided = app
.session
.active()
.and_then(|id| app.session.develop_of(id))
.is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided);
if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) {
return Some(Err(e));
}
app.ui.tool = "guidedUpright".into();
}
"brush" => {
app.ui.right = RightPanel::Masking;
app.ui.view = ViewMode::Detail;
@@ -509,6 +687,35 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 });
Ok(Value::Null)
}
"dialog.allMetadata" => {
let r = match app.session.execute("photo.allMetadata", p) {
Ok(r) => r,
Err(e) => return Some(Err(e.to_string())),
};
let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default();
app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() });
Ok(Value::Null)
}
"dialog.smartAlbum" => {
// {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3
let album = p
.get("id")
.and_then(Value::as_u64)
.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned());
app.ui.dialog = Some(match album {
Some(a) => Dialog::SmartRules {
id: Some(a.id.0),
name: a.name.clone(),
rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(),
},
None => Dialog::SmartRules {
id: None,
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
},
});
Ok(Value::Null)
}
"dialog.newSmartAlbum" => {
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
Ok(Value::Null)
@@ -523,7 +730,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"dialog.labelNames" => {
let names =
lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect();
app.ui.dialog = Some(Dialog::LabelNames { names });
app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() });
Ok(Value::Null)
}
"dialog.rename" => {
@@ -573,6 +780,64 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"photo.editInExternal" => {
// render an edit copy (stacked on the original), then open it in the editor
let mut params = p.clone();
if !params.is_object() {
params = json!({});
}
let r = match app.session.execute("photo.editExternal", &params) {
Ok(r) => r,
Err(e) => return Some(Err(e.to_string())),
};
let path = r["path"].as_str().unwrap_or_default().to_string();
if let Some(id) = r["id"].as_u64() {
app.ui.external_edits.push(id);
}
let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone());
if let Some(f) = app.services.open_with.as_mut()
&& let Err(e) = f(&path, &editor)
{
app.toast(&ctx, format!("Couldn't open the editor: {e}"));
}
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
Ok(r)
}
"dialog.cull" => {
app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true });
Ok(Value::Null)
}
"app.whatsNew" => {
app.ui.dialog = Some(Dialog::WhatsNew);
Ok(json!({"text": crate::panels::dialogs::WHATS_NEW}))
}
"app.systemInfo" => {
let info = app.session.execute("library.info", &json!({})).unwrap_or_default();
let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten();
let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64);
let mut rows = vec![
("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)),
("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())),
("GPU".to_string(), gpu.unwrap_or_else(|| "none (CPU rendering)".into())),
("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }),
("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)),
("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)),
("Photos".to_string(), info["photos"].to_string()),
("Albums".to_string(), info["albums"].to_string()),
];
if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) {
rows.push(("Library".into(), dir.to_string()));
}
rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps)));
rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms)));
let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>());
if p.get("open").and_then(Value::as_bool).unwrap_or(true) {
app.ui.dialog = Some(Dialog::SystemInfo { rows });
}
Ok(r)
}
"app.shortcuts" => {
app.ui.dialog = Some(Dialog::Shortcuts);
Ok(Value::Null)
@@ -592,6 +857,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.quit = true;
Ok(Value::Null)
}
"dialog.saveMetadataPreset" => {
// from the active photo's copyright, creator and place
crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name");
Ok(Value::Null)
}
"file.findMissing" => {
let folder = match p.get("folder").and_then(Value::as_str) {
Some(f) => Some(f.to_string()),
@@ -627,8 +897,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
},
};
let r = crate::import::open(app, vec![path]);
if let Some(crate::state::Dialog::Import { opts }) = &mut app.ui.dialog {
opts.copy = true;
// only the scan just started (an error means another scan is running)
if r.is_ok()
&& let Some(t) = &mut app.scan
{
t.copy = true;
}
r
}
@@ -654,15 +927,54 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if paths.is_empty() {
return Some(Ok(Value::Null));
}
let r = app.session.execute("preset.import", &json!({"paths": paths})).map_err(|e| e.to_string());
// .cube LUTs (and those inside zips / folders) become profiles
let lut_paths: Vec<&String> = paths
.iter()
.filter(|p| {
let l = p.to_ascii_lowercase();
l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir()
})
.collect();
let profiles = if lut_paths.is_empty() {
0
} else {
app.session
.execute("profile.import", &json!({"paths": lut_paths}))
.ok()
.and_then(|v| v["imported"].as_array().map(Vec::len))
.unwrap_or(0)
};
let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect();
let r = if preset_paths.is_empty() {
Ok(json!({"imported": [], "failed": [], "skipped": 0}))
} else {
app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string())
};
if profiles > 0 {
app.toast(&ctx, format!("Imported {profiles} profile{} (Profile browser ▸ their groups)", if profiles == 1 { "" } else { "s" }));
}
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
let failed = v["failed"].as_array().map_or(0, Vec::len);
let msg = match (n, failed) {
let mut msg = match (n, failed) {
(0, 0) => "No new presets".to_string(),
(n, 0) => format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }),
(n, f) => format!("Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }),
};
// settings with no counterpart here (the other editor's profiles, masks…)
let mut skipped: Vec<&str> = v["imported"]
.as_array()
.into_iter()
.flatten()
.flat_map(|i| i["unmapped"].as_array().into_iter().flatten())
.filter_map(Value::as_str)
.collect();
skipped.sort_unstable();
skipped.dedup();
if !skipped.is_empty() {
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
msg += &format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
}
app.toast(&ctx, msg);
if n > 0 {
app.ui.presets = true;
@@ -696,9 +1008,51 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
return Some(r);
}
"file.importCurvePresets" => {
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => match app.services.pick_curve_preset_files.as_mut() {
Some(f) => f(),
None => return Some(Err("no file dialog on this platform".into())),
},
};
if paths.is_empty() {
return Some(Ok(Value::Null));
}
let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string());
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
if failed > 0 {
msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" });
}
app.toast(&ctx, msg);
}
return Some(r);
}
"file.exportCurvePresets" => {
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => match app.services.save_curve_preset_file.as_mut() {
Some(f) => f("Point Curves.lccurve"),
None => return Some(Err("no file dialog on this platform".into())),
},
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let mut params = json!({"path": path});
if let Some(n) = p.get("names") {
params["names"] = n.clone();
}
let r = app.session.execute("curve.exportPresets", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}
"app.export" => crate::control::export_active(app, p),
"app.showInFinder" => show_in_finder(app),
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" => {
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE);
crate::links::open(app, url)
}
@@ -728,6 +1082,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
}
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
"view.compare" => app.session.catalog.len() > 1,
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more