eframe 0.36's App::on_exit takes the glow context as a second argument.
The macOS-only menu-bar Quit path still called on_exit(self), so the
0.7.0 macOS release job failed with E0061. Pass None; our on_exit ignores
it. macOS isn't built in CI, so only the release build caught it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On merge: the HDR histogram is applied in develop_reporting (#712 split develop), is skipped for
draft renders (a second render per drag tick), and HdrBins::bin clamps a hand-built split so it
can't underflow (test hdr_bins_with_any_split_stay_in_range).
#884 focuses a develop slider on click so the wheel and arrows adjust it, but a focused widget
owned the whole keyboard, so after touching a slider no shortcut fired (O for the mask overlay,
caught by mask_adjustments_hide_overlay_without_disabling_the_mask). Now a selected slider owns
the arrow keys only. The wheel test opens the Edit panel explicitly (the demo may start on it, and
panel.edit then toggles it shut), and the Export dialog snapshot is 546 px wide since #833's
nine watermark positions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
photo.editExternal takes the selection (or ids, at most 50): each photo gets its own -Edit copy
stacked on its original, the copies are selected and returned in copies (path / id / original
still name the first). Photo ▸ Edit In opens each copy in the editor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
On merge: Lightroom's default profiles (Adobe Standard, Adobe Color), named by nearly every sidecar,
give no camera-profile warning; other profiles still do (test default_lightroom_profiles_are_not_reported).
The import help string keeps main's onDeleted wording and gains warnings.
On merge: the quick preview path orients like the whole-file path (preview_orientation with the
raw's header prefix, so a portrait raw whose embedded JPEG carries no orientation is not sideways),
and its doc links point at preview_window.
- Settings ▸ General ▸ External Editing: the PhotoCraft location and Other editor
fields move from egui::TextEdit to TextField (edit menu, Esc restores).
- Edit In with the system default on Windows starts explorer.exe with the file
as one argument instead of cmd /C start, which read & ^ % | in file names as
shell syntax.
- psd::packbits walks the row with first()/get()/split_at_checked instead of
direct indexing and slicing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
move_across_volumes read both whole files into memory to compare them; it now
compares lengths first, then streams 1 MiB chunks. The grid's Move to Folder…
asks through pick::ask (background dialog where the host has one) like the
other folder pickers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
Check the target (Lightroom catalog, file, unreadable folder, locked by another
program) with Session::check_library before closing the open library, and if
opening still fails, reopen the previous one (both errors reported if that fails
too). New Library names reject Windows-reserved characters and trailing dots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
On merge: the XWayland default goes in front of the catch_unwind-wrapped launch (#951), and the
README's Linux section names the LIGHTCRAFT_WAYLAND=1 opt-out.
Turning on AI Denoise without a model opens Settings > AI Denoise and
keeps a pending request, so the switch showed checked and Settings
offered "Cancel pending action". Closing the setup with no model
download or installation running now drops that request: the switch
goes back off and nothing claims to be pending. A download started
before closing still turns AI Denoise on when it finishes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
File > Import Profiles & Presets... now builds one toast for both halves
(a second toast replaced the first, so a .cube-only import said "No new
presets"). The preset half is skipped when no preset files were picked,
and profile.import's failures are shown with their reason. profile.import
also reports .cube files and zips it couldn't read instead of dropping
them silently. The command's result carries the profile half under
"profiles".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
On merge: metadata.downloadPlaceNames accepts plain http only for the loopback address itself
(http://127.0.0.1.example.com/ and http://127.0.0.1@host/ are refused; test
download_base_is_https_or_loopback_only).
- Filter empty EXIF values and join with ' · ' separator (matches grid.rs pattern)
- Single centered text element — no overlap possible
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing
(cherry picked from commit 44a29d7fc7)
- Filter empty EXIF values instead of rendering empty strings at fixed positions
- Distribute remaining items evenly across available width using (i + 0.5) / count
- Apply left/center/right alignment dynamically based on item position
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing
(cherry picked from commit 28ab23e633)
`lightcraft_raw::sparse::header_plan` says which byte ranges of a raw file the header
probe (`probe_info`, `lightcraft_meta::extract`) reads, given a buffer of the file's length
in which only some ranges are loaded: everything but the bulk sample data (large TIFF
strips/tiles minus the first chunk's coding headers, a CR3's image tracks minus the raw
track's CRX headers and QP maps, a RAF's CFA strip, an RW2's raw data). `preview_plan`
also reads what `embedded_preview` needs, for raws shown from their preview. NEF, ORF and
PEF (their header probe looks at samples) and anything else are read whole.
The corpus test checks, for every raw.pixls.us sample the plan describes, that probing the
partly loaded buffer gives exactly what the whole file gives, with the skipped bytes zero and
again 0xFF: CR3 2.7 %, DNG 2.4 %, CR2 3 %, RAF 6 %, RW2 7 %, ARW 8 % of their bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the Masking panel open the loupe routes clicks to mask_overlay, so the
colorRange eyedropper armed by the Pick button (handled only in
general_interaction) never ran and no sample was taken. mask_overlay now
handles the eyedropper (crosshair cursor, click samples, shift-click adds)
through a shared color_range_pick helper.
Fixes#954
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Preserve upstream compact-toolbar layout and keyboard regressions alongside the rating and flag padding regression.
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Honor Enter/Escape on the completed opening release, release focus for a
cancelled opening click, and keep a numeric Escape batch out of app shortcuts.
Defer the Edit panel's clicked Reset until after its controls so the normal
click-away commit precedes the explicit reset. Retain the original Numeric
regressions and the single active-draft Reset regression.
Co-authored-by: Codex <codex@openai.com>
On some Windows machines FXC fails to compile egui's own shaders on DX12
("The parameter is incorrect. (0x80070057)" in egui_pipeline) and wgpu's
default error handler panicked. Each renderer (DX12, Vulkan, GL, glow) is
now probed with error scopes and a logging error handler before the window
opens; the one that works is remembered; a start that dies leaves a marker
and relaunches with the next renderer. LIGHTCRAFT_GPU_BACKEND=glow added.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port of storytold/photocraft#2445. Each theme in Settings > Interface gets a
star; starred themes are saved with the settings and listed in a right-click
menu on the title bar's appearance button, next to Appearance Settings, so
switching between favorites takes one click. The theme cards now list one
theme per row to make room for the stars, so the settings dialog test
scrolls further to reach the controls below them.
- A failed, cancelled or finished place-names download is reported once
as a toast (failures as an error toast that says what to do), and
metadata.placeNamesStatus reports downloading / lastDownload / lastError.
- The parsed GeoNames data is cached in the session and re-read only when
the cities file's size or modification time changes.
- End-to-end download test against a fake HTTP server on 127.0.0.1:
a synthetic zip, a garbage zip, a truncated body and 404s.
- Info panel: Fill Location gets its own row (it was clipped beside
Show on Map in a narrow panel).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- New Library… from the menu asks for a name (default prefilled), then the location.
- The open library's name shows under My Photos in the left panel (hover: its path), so it
is visible on macOS too, where the window title isn't.
- Switching also remembers the library being left (e.g. the one opened at launch).
- whats-new entry and parity row LRC-LIB-CATALOG-SWITCH.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New crate lightcraft-geocode (L1): parses the GeoNames cities dump and finds the
nearest place within 50 km through a 1-degree cell index; NaN / out-of-range
positions and malformed lines find nothing. Engine commands
metadata.reverseGeocode (Photo menu, Info panel "Fill Location"; fills only
empty fields unless overwrite, one undo step, activity row for batches),
metadata.downloadPlaceNames (background download of the CC-BY 4.0 GeoNames
files into the settings folder, cancellable activity row) and
metadata.placeNamesStatus. No data is bundled; NOTICE records the licence.
Fixes#582.
Sources: GeoNames dump readme (prose); haversine (textbook); PKWARE APPNOTE
(ZIP, prose); cell index own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
folder.create {parent, name} makes a folder on disk (one undo step; undo
removes it again while it is empty). photo.moveToFolder {folder, ids?}
moves the photos' files with their XMP sidecars into another folder and
relinks them as one undo step (undo moves them back); virtual copies
follow their master. It never overwrites: a name already taken in the
destination refuses the whole move with an actionable error. Missing or
offline originals and Local photos are refused. Across volumes a file is
copied, compared byte for byte and only then removed. The move holds a
row in the activity stack.
UI: New Folder on Disk… and Move Selected Photos Here on a Folders row,
Move to Folder… in the photo context menu; strings in every catalog.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- app.newLibrary {path?, name?}: create a library (in a chosen location, or a given folder)
and switch to it; refuses an existing library, a non-empty folder, a file or a bad name.
- File ▸ Open Recent Library: the last 8 libraries (app settings), missing ones greyed out;
opening a gone one is an error that forgets it instead of creating an empty library.
app.recentLibraries / app.clearRecentLibraries for CLI and MCP.
- The window title names the open library; Settings ▸ General gains New Library….
- A library that can't be opened leaves the current one open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`lightcraft-cli run --library <dir>/catalog.snap` failed with the OS's "File exists"
(creating a folder over the file). Session::open_library now opens the folder that holds
catalog.snap / catalog.log / catalog.lock, and says "is a file; a library is a folder"
for any other file.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
View > Lights Out (view.lightsOut) cycles On -> Dim (80 %) -> Off; Esc turns
the lights back on; {mode: on|dim|off} sets a state directly. The panels stay
where they are: a black veil is painted over everything but the loupe's photo
(or the grid / Compare / Survey area). Shift+L, since L is Linear Gradient.
Fixes#611. Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings the branch up to date with main (#869 shared docking and the docs it
added); no conflicts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photo > Edit In > Edit in PhotoCraft (Cmd+Shift+E, also on the photo
context menu) renders a copy with the edits as a flat 16-bit PSD, which
PhotoCraft saves back to in place, stacks it on the original and opens
it in PhotoCraft, found where its installers put it. Edit in Other
Editor (Cmd+Alt+E) keeps the configured program. Per-editor format,
colour space and bit depth in Settings > External Editing. Export gains
a flat PSD format.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4kqvR9KVCPia3SSXyB8WG
A raw shot in a monochrome picture style records full colour, but its
camera JPEG is grey: neither the full look nor the partial look has
colour to fit, so the photo started from the spectral matrices or the
neutral fallback with the default tone curve, about 1.3 EV darker than
the same body's colour-shot photos. The grey JPEG still shows the
camera's tone curve.
When no look was fitted and the JPEG is black and white (`monochrome`),
fit the quantile tone curve alone on top of the colour the photo gets
otherwise: the camera profile's (in `fit_preview`), else the spectral
matrices or the fallback (`monochrome_look`, after the spectral step).
The chroma curve stays neutral; the partial look's checks apply. A
tone-only look keeps the file's colour model for white balance.
LOOK_VERSION 2 -> 3, RENDER_CACHE_VERSION 42 -> 43.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
export_progress_and_cancel failed every time on a fast machine (M5 Max, 4
export lanes): the 8 demo photos exported within the 100 ms progress
throttle, so the first progress above 0 was the final 8/8 and the cancel
arrived after the export had finished (a reply to the cancelled request,
or all 8 files on disk).
The test now wraps Headless in a test-only backend that, at the first
photo boundary, keeps polling the server's real progress hook (which
answers pings and reads cancels) until the cancel is read or the test
releases it, bounded at 20 s so a broken cancel fails rather than hangs.
The first export is released once the mid-batch ping is answered (that
was the same race), the second only ends by the cancel. That makes the
outcome exact, so the check tightens from "1..7 files" to "exactly the
one completed photo", which also proves the photos the other lanes had
already rendered are never written. No production code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soft Proofing's Profile menu takes a printer / paper ICC profile (Other…;
`view.softProof {profile, intent, paperInk}`), RGB or CMYK, matrix or LUT.
The CMS (moxcms) builds a 33^3 proof table over the working space: forward with
the chosen intent (Perceptual / Relative), back relative colorimetric; the
destination gamut warning comes from the colorimetric round trip. Simulate
Paper & Ink renders absolute colorimetric: the profile's media white replaces
screen white and the ink black is unscaled. The pipeline gets the table by id
(`Proof::profile`, `register_proof_profile`), so Proof stays Copy and render
keys include it. Proof copies are named after the profile.
Sources: ICC.1:2022 (rendering intents, media white point, absolute
colorimetric); own design for the proof table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New `inpaint` crate (L1): multi-scale patch-based completion with a PatchMatch
nearest-neighbour field, seeded by a membrane fill. Deterministic, no panics on
hostile sizes, cancellable. Ported from PhotoCraft (MIT OR Apache-2.0).
Remove tool: a fourth spot mode, `fill` ("Content-Aware"), rebuilds the spot
from patches of its surroundings instead of one source; coarse-to-fine, so the
structure is chosen at a scale set by the spot size and preview and export agree
(tested). spot.add leaves its source unset.
Sources: C. Barnes et al., PatchMatch (SIGGRAPH 2009); Y. Wexler, E. Shechtman,
M. Irani, Space-Time Completion of Video (TPAMI 2007); P. Perez et al., Poisson
Image Editing (SIGGRAPH 2003) for the membrane fill; code ported from PhotoCraft
crates/algo/src/inpaint.rs + poisson.rs (a680ed22).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
HsvTable::apply took the value through linear -> sRGB -> linear even when the table's value
scale is 1 (every hue/saturation-only table, as fitted camera looks are), and wrapped hues with
rem_euclid (an fmodf call) three times per pixel. The value is now kept as it is for a unit
scale and the sRGB-encoded value is reused otherwise; hues are wrapped by a helper that equals
rem_euclid bit for bit (tested) without the call for values within one period.
2048 px render of a 24 MP ARW, single thread: 16.4 -> 15.1 G instructions, 4.94 -> 4.76 G cycles.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
library.buildPreviews rendered one photo at a time, and each photo's thumbnail job decoded the
file again at thumbnail level after (before, in fact) the view job had decoded it at preview
level. Now the view renders first and its decoded source serves the thumbnail, and photos
render in as many lanes as an export batch (two for 1:1 previews, a full-size source each);
a panic in one photo counts as that photo's failure.
100 24 MP ARWs, standard previews, loaded machine: 49.4 s -> 38.4 s (CPU 3m28 -> 2m49).
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A noise-reduction change invalidated the scene-linear stage and with it every spatial
plane (base, clarity, dehaze, chromaticity), so each tick of an NR drag on the CPU rebuilt
all of them. The plan now also carries the linear key without the NR settings; a draft
render whose image differs from the cached one only in NR keeps those wide blurs and redoes
only log luminance and the fine texture band. Full-quality renders never take them.
Draft NR drag, single thread: prepare 18.7 -> 7 ms (render ~135 -> ~125 ms CPU).
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No location in the XMP sidecar or the DNG's packet; exporting an original
that embeds GPS with the option on is refused instead of leaking it.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Raws without an embedded lens correction look their EXIF lens up in the
user-supplied database; the profile goes through the existing Enable Profile
Corrections path (amount sliders, CPU/GPU geometry stage, DNG export).
Synthetic-grid test renders a distorted chart through the pipeline.
Sources: own design (see lightcraft-lensdb for the model sources).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Profiles are interpolated in focal length (vignetting also in aperture and
focus distance), scaled by crop factor, and converted by least squares to the
DNG WarpRectilinear / FixVignetteRadial polynomials the pipeline already
applies. The lensfun database (CC-BY-SA) is only read from a directory the
user supplies; nothing is bundled.
Sources: lensfun's published database format and lens calibration
documentation (no lensfun code read), PanoTools wiki (a, b, c distortion
model), DNG specification 1.6 (WarpRectilinear, FixVignetteRadial);
interpolation and fitting: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Between a kept luminance share of 0.15 and 0.25 the table colour is now blended in with a smoothstep,
so noisy shadows show no step between pixels that get the table colour and pixels that keep theirs.
At 0.25 and above pixels render exactly as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CRX 0x200 QP map opens with its first symbol as a 32-bit literal; every
later symbol is Rice coded with a limited-length escape: 23 zeros, the
terminating 1 and the symbol in 8 bits (the ITU-T T.87 layout for a 32-bit
limit and 8-bit symbols, without its minus-one offset). The old rule (an
escape after 16 zeros with a 16-bit literal) matched only the first symbol,
which is all the R8 and R7 maps need. R5 Mark II maps (QP 150-162) jump by up
to 12 where the Rice parameter is 0, giving 18- to 22-zero prefixes that
misparsed ("Canon CRX adaptive QP value -21087"): 174 of 306 sampled files
from a private R5 Mark II library opened as previews. Every QP map of its
33,665 C-RAW files now decodes to the exact end of its entropy stream; the six
oracle fixtures are unchanged. Established by black-box analysis of the
bitstream; no third-party decoder source consulted.
Adds the four CC0 R5 Mark II samples (raw.pixls.us 7881-7884) to the corpus
with pinned sensor hashes (the crop-mode framing is left to #754), bumps
RENDER_CACHE_VERSION to 43, and updates cr3.md, raw-parity (235 verified
models), coverage, gaps, the checklist rows, ROADMAP, target-app-parity and
What's New.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto the activity stack (#601). An export adding its files to
Photos keeps its row through that step, with "Adding to Apple Photos…" as
the detail. The worker turns the row's ✕ off (TaskHandle::set_cancellable)
before its last look at the cancel flag and reports what it saw, so a
cancel either stops the export before Photos is asked or is refused, never
both "cancelled" and imported. activity: a per-task gate orders a cancel's
decision against a step switching cancelling off, so a cancel can't be
accepted between the switch and the step's look at the flag and then be
ignored. An import of its own (export.addToPhotos, or
the Photos step left running after a foreground app.export) holds a row of
kind applePhotos while Photos works: no ✕, no count, the album as detail.
The desktop app refuses export.addToPhotos {wait: true}: its window's
thread never waits for Photos. The label is in every catalog; tests assert
the rows, the refused cancel and the refused wait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Ten minutes plus five seconds a file per import (was one minute): the
first live run timed out while macOS's permission prompt was still up,
and Photos' duplicates question waits for a person too.
- Files go to osascript 200 at a time, all within the import's one time
limit: one run with every path went over macOS's 1 MiB argument limit
at about 10,000 files, and Photos got nothing. A failed later batch says
how many files Photos had added.
- The progress line "Adding to Apple Photos…" is translated on the UI
thread; on the export worker it was always English.
- docs/apple-photos.md: who can add what (the control port is reachable by
any local program, other users' included), batching, the new limit, and
tested end to end (macOS 26.6.2, Photos 11.0, packaged app). The parity
row says so; What's New has an entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Export dialog gets "Add to Apple Photos" with an optional album (macOS
only). app.export takes addToPhotos / photosAlbum (dialog, Export with
Previous, presets, MCP, lightcraft-cli run and render --opt); the new
export.addToPhotos {paths, album?, wait?} adds files already on disk and
export.photosImports {job?} reports how imports went.
A constant AppleScript run by /usr/bin/osascript asks Photos' import
command (Apple Events, not synthetic input) to import the files into a
top-level album of exactly that name (made when missing; albums in folders
are never used; several of that name: refused), skip check duplicates
false, and returns the media item ids. Album names and paths are passed
only as separate arguments (on run argv), never spliced into the script.
The runner is injectable (Session::apple_photos): with_fs installs the real
one on macOS only, and tests use fakes, so no test starts osascript or
Photos.
Imports are jobs (Session::apple_photos_imports), one at a time. The
desktop app (Session::apple_photos_background, set in open_session) never
waits for Photos on its UI thread: the background export adds on its
worker, app.export without background and export.addToPhotos answer with
the running job, and the app announces the outcome. Without a window (MCP,
lightcraft-cli) imports wait by default, and both headless front ends (the
MCP/CLI backend and the snapshot UI) wait for a running one before the
process ends, on every exit path, through one engine helper
(Session::wait_for_photos_imports) whose deadline follows the job: a
writing allowance while an export still writes, then the import's own
time limit from when Photos was asked, under an overall cap. A runner that panics, waiting or on a
worker, ends its import as failed and frees the slot (panic guard plus a
completion guard). Session::after_export reserves
the import slot before an export writes anything and frees it on every
exit path (handed the files, cancelled, failed, dropped), so a competing
import is refused with the export's job number instead of the export
failing its Photos step after writing. Permission (-1743), missing Photos,
cancel, timeouts, ambiguous albums, busy imports, other osascript failures
and partial imports produce actionable messages; the export's files are
kept either way. Hidden on other platforms; the web build compiles it out.
Packaging: the hardened-runtime entitlements gain
com.apple.security.automation.apple-events (app and CLI) and Info.plist
gains NSAppleEventsUsageDescription, without which macOS refuses the
events without asking.
Not yet tested end to end against a real Photos library: manual steps,
with the packaged app, in docs/apple-photos.md. Parity row LR-EXP-PHOTOS is
now partial.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open Library given a Lightroom Classic catalog folder, one of its .lrdata /
.lrcat-data folders, Backups/, the .lrcat or its -wal/-shm/.lock files used to
create an empty LightCraft library inside Lightroom's folder (or, for the
.lrcat file, close the current library and then fail). Now:
- engine: lightroom_folder::lightroom_catalog_at (names only, top-level scan
bounded to 10 000 entries, never fails); Session::open_library refuses those
places with EngineError::LightroomCatalog {path, catalog} before creating
anything. A LightCraft library (catalog.snap / catalog.log) always wins.
- app: app.openLibrary checks before closing the current library, keeps it,
returns {opened: false, lightroomCatalog, path} and shows a dialog with
Import Lightroom Catalog… (file.importLightroom with the catalog found) and
Cancel. The library problem window gets the error instead.
- Lightroom import status keeps the catalog path after the task ends.
- CLI --library explains how to import instead.
Compare and Reference drew each pane from a whole-frame render of at most
2560 px, so 1:1 was a magnified preview and focus could not be checked.
Each pane now also renders the window on screen from the photo's own
pixels (Slot::CompareRegion), as the loupe does (#323). The media cache
keeps up to two full-size originals while either view is open, so one
pane's window does not evict the other's source.
Docs: gaps.md entry 4 shrinks to overlays and soft proofing; checklist
LR-VIEW-ZOOM / LR-VIEW-COMPARE and ui-parity zoom row updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UyHnpE9573a7nJFQqnGNex
Lightroom's Light panel "HDR" switch (`crs:HDREditMode`, `1`/`True` on,
`0`/`False` off) now sets `hdr.enabled` when importing XMP sidecars, presets,
DNG presets and Lightroom catalogs, instead of being reported as unmapped (an
off value was already silent). The rest of the HDR settings keep their
defaults. Follow-up to #646, ported from #466.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HDR editing is switched with an HDR button in the Edit panel header, beside
Auto and B&W and drawn the same way (the shared text_button: highlighted
while on), instead of a checkbox at the top of the Light panel. While HDR is
on, the Light panel still shows Visualize HDR and the headroom slider above
the light sliders; with HDR off they're hidden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An HDR edit's preview shows its SDR rendition, so its histogram said nothing
about the highlights above SDR white. Now it shows the HDR rendition:
`pipeline::hdr_histogram` renders the edit small (384 px, float HDR, same
framing) and bins it with SDR on the left half and the HDR range on the right
(`Histogram::of_hdr`, `HdrBins`), on a fixed scale of Hdr::MAX_EV_LIMIT (5)
stops above SDR white so it doesn't rescale when the headroom limit changes.
The engine's `develop()` (CPU and GPU renders) and the web worker swap it in
for HDR edits; high-bit-depth, windowed and proofed requests keep the image's
histogram, and a display profile's histogram doesn't replace it. Web workers
carry the HDR bins across their wire (`Histogram::to_words` / `from_words`).
The panel draws it in Lightroom's layout: a solid line at SDR white, dashed
lines between the stops, the headroom limit as an orange line with the range
past it dimmed, a bar under the plot (white; one band per stop in Visualize
HDR range's colours while that is on), SDR / HDR labels and an info tooltip.
Follow-up to #646, ported from #466.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Readers that don't read the PNG cICP chunk show the PQ samples as if they
were sRGB (flat, washed out): expected for PQ PNG, but it surprises people.
Noted in What's new (with the gain map JPEG as the look-right-everywhere
choice), as the export dialog's HDR PNG hint, and in the option and encoder
docs. Review suggestion on #664.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With HDR output on, a photo edited in HDR exports as a 16-bit PNG with the PQ
curve in Rec. 2020 (SDR white at HDR_REFERENCE_WHITE_NITS), described by a
`cICP` chunk (PNG third edition: BT.2020 primaries, PQ, RGB, full range) and
a `cLLI` content light level chunk (the levels the HDR AVIF records). EXIF,
XMP and print resolution are kept; no ICC profile next to cICP. Photos without
an HDR edit, and HDR edits with HDR output off, export as before.
`codecs::encode_png_pq` sits next to `encode_avif_pq` and takes the same
input. Follow-up to #646, ported from #466.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a What's New entry for keeping the install folder when upgrading from a
pre-0.6 Windows installer, and describe the new Windows MSI job in the
Packaging lint workflow header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The App Paths directory search doesn't check that lightcraft.exe exists, and
when the old installers' unquoted path is gone, Windows Installer falls back
to the text before the first space: a deleted
C:\Program Files\LightCraft\lightcraft.exe resolved to C:\, so an upgrade
would install into the drive root. Gate the fallback on a second search of
the same value with a FileSearch for lightcraft.exe.
The install-folder test gains legacy-folder-deleted (failed before this fix)
and legacy-exe-deleted cases, and removes its empty parent registry key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
storytold/craftrules is not a public repository, so the standards link in the
Chinese/Japanese fonts note was a 404 for anyone outside the maintainers. The
rule the sentence states lives with the font repo it points at anyway.
Fall back to the parent directory of the App Paths executable when an
older installer did not write InstallFolder. Keep explicit destinations
and the newer stored folder ahead of the legacy fallback.
Exercise the real MSI searches and actions with isolated per-user probes
on x64 and x86, including legacy paths with spaces and Unicode.
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Raws with a fitted camera look (e.g. Nikon NEF) rendered HDR with the
generic tone curve instead, more than a stop darker through the midtones
than the SDR render: PQ JPEG XL / AVIF exports looked dark everywhere,
and gain map JPEGs carried negative gains, so they went dark on HDR
displays. ToneMap::camera_hdr keeps the camera curve up to 1.5 EV over
grey and lifts highlights as ToneMap::hdr does.
Co-Authored-By: Claude <noreply@anthropic.com>
Exercise both separator directions for folder and individual-file imports.
Verify duplicate detection, stable photo IDs, added/deleted file detection,
and reuse of a confirmed scan when the folder spelling changes.
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Rebuild fixture path components with native separators so expected paths
match directory scans on Windows, including files that have been removed.
This is a prerequisite for Windows validation and overlaps the path-helper
fix in upstream PR #660; it can be dropped once that change lands.
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
The grid already showed the camera's embedded preview first, then queued a render of the whole raw
to replace it. For a 512 px thumbnail that render decodes 50 of a 56 MB Fuji RAF's bytes for a
picture the 4 416 × 2 944 embedded JPEG already covers — 45 s a photo on the 1.2 MB/s share this was
measured on, 8.3 GB of traffic for a 149-photo folder, and a queue that delayed the render the user
was actually waiting for (measured: the first loupe render of a cold photo finished 174 s after the
click, most of it spent behind thumbnails). Idle for a minute afterwards, the same folder read 12
previews (26.9 MB) and decoded zero raws for thumbnails, against 638 MB of whole-file reads before.
- `Session::embedded_thumb_is_final` answers whether a photo is an unedited raw whose camera JPEG the
viewer is showing — the same `has_import_look` gate the preview path uses.
- `grid::skips_the_real_render` is the decision (unit-tested): a cached render as stand-in, an edited
photo and a raw without an embedded JPEG all still render, because their thumbnail must show the
pipeline's output rather than the camera's.
- `Renderer::remember_embedded_thumb` records the quick render's key and the bucket, and
`thumb_quick_is_final` / `thumb_quick_pending` let the per-frame grid request stop at the embedded
preview instead of rebuilding the render job (and its cache key) every frame.
- The thumbnail key still follows the settings hash, so editing a photo re-renders its thumbnail
through the ordinary background path.
`docs/raw-preview-reads.md` and the `LR-IMP-PREVIEW-READ` parity row record the behaviour and the
numbers.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
ROADMAP.md: take main's restructured version; JPEG XL export is now
recorded in docs/file-format-parity.md and docs/gaps.md (gap 25).
Co-Authored-By: Claude <noreply@anthropic.com>
HueSat::apply restores luminance with before / after. Near black, where noise makes some channels
negative, the hue/saturation table can bring after close to 0, and the gain turned single pixels
white (isolated white dots in dark areas, most visible at fit). Luminance is now restored only if
the table kept at least 25 % of it; such colours keep their colour, like black and non-finite ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview was capped at 348 × 200 pt, so a portrait photo showed about
130 pt wide. It now spans the dialog for any photo shape. The cached 512 px
thumbnail still shows at once; a render at the preview's own pixel size
(in 128 px steps) then replaces it, so the wider preview stays sharp. The
web build, which has no worker thread, keeps the thumbnail only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The crop tool's rotate pointer always bulged up, which only reads right
above the box. It now turns to bulge away from the nearest point of the box
outline: right beside the right edge, down below it, left beside the left
edge, diagonally off a corner, and following a box that is already rotated.
icons::paint_turned draws an icon turned about its centre; icons::paint is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Sony body shot in Adobe RGB writes ColorSpace Uncalibrated with InteropIndex R03 in the raw's EXIF and leaves the
embedded JPEG untagged, so the look was fitted against the JPEG decoded as sRGB. The EXIF fallback now applies to
all makes (the Nikon maker note still wins); DNGs are skipped, since their previews come from the converter.
- release.yml: new linux-riscv64 job (ported from GridCraft): cross-compiles on
ubuntu-24.04 against ports.ubuntu.com riscv64 libs, packages a tar.gz with
package.sh --formats tar, smoke-tests lightcraft-cli under QEMU. The release
job needs it, so its tarball lands in the draft and SHA256SUMS.
- packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR
support (target dir, cross strip, emulated --version).
- README Downloads and docs/releasing.md list the riscv64 tarball.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A watermark (text, horizontal or vertical, or a graphic) can be turned 0,
90, 180 or 270 degrees clockwise, like Lightroom Classic's rotate buttons.
It is sized before it is turned and placed and inset by the turned box; the
shadow still falls down and to the right. New watermark key `rotation`
(other values are errors); settings without it draw unturned. The Export
dialog gets a Rotate row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Watermark.inset becomes inset_x (from the left or right edge) and inset_y
(from the top or bottom edge), like Lightroom Classic's watermark editor.
The watermark keys are insetX and insetY; the old inset key still reads as
both, so saved presets and existing MCP calls keep working.
The Export dialog's Inset slider becomes Horizontal inset and Vertical
inset, each greyed out when the position is centred along its axis.
cargo fmt reflows WM_POSITIONS and its test from the previous commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Watermark on, the Export dialog shows the first photo it will export
(its cached thumbnail) with the watermark drawn as the file will have it.
The watermark is redrawn on a copy only when it or the output size changes.
draw_watermark_preview scales the 6 px minimum text size and the 1 px
minimum shadow offset by the preview size over the export size, so a small
watermark doesn't look bigger in the preview than in the file. Exports draw
with a scale of 1 and are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the side-by-side and top/bottom Before/After layouts a click in the
Before view was mapped to image coordinates through the After view's
rectangle, so the White Balance Selector sampled the wrong point (at Fit,
the photo's edge). The click tools in general_interaction now use the
Before view's image rectangle when the pointer is in the Before view's
pane; the pane decides, since zoomed in an image reaches past its pane.
New headless test tests_before_after.rs clicks the same image point in
the After and Before views (side by side, top/bottom; Fit and 100 %) and
expects the same white balance. Without the fix: Before 8202 K / +116,
After 2710 K / +33.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In Compare a filmstrip click made the clicked photo active without
showing it, so the Edit panel and the rating keys acted on a photo on
neither side (a headless test rated the hidden photo 2 -> 3 stars). The
click now puts the photo into the active side (the candidate after
entering Compare, the select after clicking the select's photo); a photo
already on the other side swaps the two. The active photo is always one
of the two shown. Other views keep the usual selection clicks.
New UI command compare.set {id, side?: "select" | "candidate"} for the
same through the control channel and MCP.
New headless tests in tests_compare.rs: the active photo stays on screen
and no hidden photo is rated (Fit and 100 %), and replace / swap /
explicit side / unknown side.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-web-<version>.zip carried Cargo host build output next to the site: deps/ (81 files, 90.6 MB), build/ (149 files, 22.4 MB) and .fingerprint/ (297 files), ~113 MB that the app never references and that leaked build paths and crate names to anyone hosting the zip.
Cause: xtask wrote the bundle to <target>/web (SITE in packaging/web/package.sh) while building with --profile web. Cross-compiling with --target wasm32-unknown-unknown makes Cargo write the selected profile host artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/) to <target>/<profile>/, so they landed in the bundle directory and package.sh copied the whole tree into the zip.
Fix: write the bundle to <target>/web-site, a directory that is not a Cargo profile name, and point SITE at it. The wasm32/web/ target artifacts and the web profile are unchanged, so the rust-cache keys and the CI web job keep working.
Guard: package.sh now fails if deps/, build/ or .fingerprint/ exists in $SITE, and a unit test asserts the bundle directory is not a Cargo profile name, so the collision cannot come back silently.
Reference (#793)
The White Balance Selector button in the Edit panel only armed the tool,
so in Compare (which doesn't handle on-image tools) it did nothing while
W opened the photo in Detail with the selector armed. The button now runs
tool.wbPicker like W; a second click still disarms it.
New headless test tests_wb_selector.rs clicks the button in Compare and
expects Detail with the selector armed on the photo that was active; it
fails without the fix (the view stays Compare).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Color Grading gets 3-Way / Shadows / Midtones / Highlights tabs (3-Way by
default). 3-Way shows three equal wheels; a range tab shows one large wheel
with that range's Hue, Saturation and Luminance sliders. Blending and Balance
are in every tab.
Precision (#559):
- Shift-drag on a wheel locks the hue and changes saturation only; the whole
spoke is drawn as a guide. Shift can be pressed or released mid-drag.
- A white dot marks each wheel's neutral centre.
- The large wheel has dashed 25/50/75 % saturation rings.
- 3-Way shows the hovered wheel's hue and saturation above it.
Each wheel has an eye (bottom-left, on hover) that hides its effect for
viewing only: RenderJob::with_hidden_grading renders those wheels neutral
under their own key, never cached as the photo's preview. Not an edit, not
saved, reset on another photo; export is unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Canon EOS R50 C-RAW files reach QP 168 (quant 44, step 102, measured about 100). The quantization step and the adaptive QP map range accept it; anything above stays rejected. Bumps RENDER_CACHE_VERSION since such files change from the embedded preview to a raw decode.
view.back (Esc) closed dialogs, the keyword painter, full screen and an
armed on-image tool, but not the crop tool (a right panel, not
app.ui.tool), so Esc with the crop tool open went straight to the grid.
It now closes the crop tool like C does (the crop stays); the next Esc
goes back to the grid.
Esc that ends an edit in a text field (the crop tool's Angle field) also
ran Back: egui drops the field's focus as Esc arrives, before
shortcuts::handle checks egui_wants_keyboard_input(). The app now
records whether a text field had the keyboard at the end of the last
frame (LightcraftApp::text_focus); that Esc is then left to the field,
like one that closes a popup.
New headless tests in tests_crop_tool.rs; both fail without the fix
(the existing escape_in_the_angle_field_keeps_the_angle fails with only
the crop step).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frame dropped the flips together with the crop (flip_h: apply_crop &&
...), so the crop tool's uncropped view (render and screen <-> image
mapping) ignored Flip H / Flip V until the tool was closed. A flip isn't
part of the crop: the frame now keeps it without the crop. Cropped
renders and exports are unchanged.
The crop overlay's dimming paired the photo's corners with the crop
box's corners in data order; flipped, the box reaches the screen
mirrored and the photo was dimmed inside the box. It now takes the box's
corners in screen order (flipped_corners). Box, handles, drags and guides
already go through the frame's mapping.
Tests: geometry uncropped_frame_keeps_the_flips (fails without the fix)
and detail flipped_crop_box_corners_come_in_screen_order; headless
screenshots for H, V and H+V checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured with RawNIND on a 20.8 MP raw: an Apple M3 does a tile in 116 ms, an
RTX 3060 Ti over Tailscale 345 ms there and back; sharing blindly made the
export take 34 s instead of 6.4 s while the photo waited for the server's last
tiles. The greeting now times a check tile there and back, and tiles are
shared only when that beats this computer's own time per tile (the card's, or
the CPU's with its threads counted as `card_is_faster` does). Otherwise
`denoise.status` → `device.remote` says `slower` with both times. With the
server configured but slower the export takes 7.2 s (greeting + check tile).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zoomed in, each view's photo reaches past its pane. The whole-frame renders
were drawn unclipped (only the window renders were clipped to their pane),
so the After's magnified whole-frame render covered the Before pane's margins
and the gap between the panes with a blurred picture, and the Before side
looked like it didn't fill its pane. Both whole-frame renders are now clipped
to their pane, like the windows over them; the Before / After labels stay
under the pane instead of moving off screen with the photo's bottom edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve Target-aware sidecar operations, add French/Ukrainian warnings, and ensure omitted diagnostic fields decode in the native UI.
Generated with Codex.
- `lightcraft_denoise::remote`: protocol v1 over TCP (JSON lines + byte-shuffled,
deflated half-float tiles), a `Remote` client (connection pool, four slots,
greeting checks the model's SHA-256 and tile size, gives up after three failed
tiles) and `serve` (shared token compared in constant time, size caps including
payloads that inflate past their tile, connection limit, timeouts).
- Engine: with LIGHTCRAFT_DENOISE_URL / LIGHTCRAFT_DENOISE_TOKEN set, the loaded
model greets the server on first use and adds its slots to this computer's
tiles at once; a tile goes to a free server slot, else runs here, and a tile
the server fails runs here too. `denoise.status` → `device.remote` says where.
- `lightcraft-cli denoise-serve` serves the installed, selected model on this
computer's card (checked against the CPU, as in the app) or CPU.
- docs/denoise.md: setup, safety (unencrypted: VPN/LAN only) and the protocol.
Tested: protocol round trips, refusals (token, model, tile size, hostile
payloads), fallback and give-up; the engine model with a live, a mismatched
and a dead server; the CLI binary serving a synthetic model end to end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 2668-entry table read off the black-box oracle's output is gone.
arw6_curve.rs now builds the 4096-entry table at first use from our own
measurement: piecewise linear through knots u_k = 128 k, c_k = u_k for
k <= 11 and 356.89 + 655.646 ln(k - 6) above, rounded half up, fitted to
the 7 same-exposure lossless (LJ92) / ARW6 pairs of the CC0 ILCE-7RM6 set.
A unit test pins the whole table (sum 27776336, weighted 92792980288),
its anchors, the identity through 1408 and strict monotonicity.
The corpus sums become regression values of the new output; the 12-bit
codes before the curve stay verified against the oracle (Phase 0).
docs/arw6-compression.md describes the curve, how it was measured, its
uncertainty, the structure and x2-scale evidence, why it is not in the
file, and the after-the-fact comparison with the oracle's curve; the
tracker and docs no longer call the output bit-exact against LibRaw.
Co-Authored-By: Claude <noreply@anthropic.com>
docs/superpowers/ held the working spec and plan, which pointed at the
local plan/ folder. The ARW6 module docs now cite the sections of
docs/arw6-compression.md, which gains the facts they relied on from the
spec: the NP field of word 0, the stream header word layout and how the
first half's residual lines are counted.
Co-Authored-By: Claude <noreply@anthropic.com>
`cargo xtask corpus --download` now fetches one full-frame Compressed and
one APS-C Compressed HQ file (about 94 MB) instead of all 12 (about
630 MB); the corpus test checks those two and the lossless-sibling
regression test is gone, since nothing would download those files.
docs/arw6-compression.md keeps the full table with source paths and
SHA-256, says which two xtask fetches and how to fetch the rest with
`curl -L`, and records the CC0 1.0 statement of RawDB's set record
(accessed 2026-10-10).
Co-Authored-By: Claude <noreply@anthropic.com>
The RawDB entries in xtask carry their SHA-256 as the third field, which
`cargo xtask corpus --download` checks; the corpus test keeps only the
sensor sum checks.
Co-Authored-By: Claude <noreply@anthropic.com>
A notched wheel (line/page units, no modifiers) zoomed nothing; only
double-click and pinch/Ctrl+wheel did. Trackpad scrolls still pan.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, model-by-model RAW coverage against Adobe's camera list (docs/raw/), and format, hardware, UI and localization parity docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reset Edits rebuilt a photo without a default-preset look from DevelopSettings::default(),
so a raw imported with capture sharpening 40 and colour noise reduction 25
(DevelopSettings::for_raw) came back with both at 0, and with its embedded lens
corrections off. It now starts from the photo's own built-in defaults
(Photo::camera_defaults, the same as import) and keeps the white balance of its
current as-shot reference, as before. Rendered files are unchanged (their defaults
are DevelopSettings::default()). Reported in #763's notes.
Previews looked more saturated than the exported files on Mac screens. The
pixels are the same: preview and export match to within one level on the CPU
and GPU paths, and exports embed a correct sRGB profile. But wgpu leaves the
window's Metal layer without a colour space for sRGB surfaces, and Core
Animation shows such a layer without colour matching ("If nil, no
colormatching occurs", CAMetalLayer.h). On a P3 screen the sRGB previews
were therefore shown too saturated, while Preview and Photos convert the
exports properly.
While no monitor profile is chosen in Settings > Display, the desktop app now
shows previews through the profile of the screen the window is on
(NSWindow.screen.colorSpace.ICCProfileData, safe objc2 calls), through the
existing display-profile path. It is read again about once a second, so
moving the window to another screen is followed. A chosen profile still wins;
"Use Screen Profile" goes back to it. app.displayProfile reports it as
path: null, screen: true. Other platforms and headless sessions are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The highlights/shadows base (fast guided filter of log luminance, eps 0.35)
followed thin high-contrast detail, so the per-pixel shift flattened twigs,
lines and texture in bright areas (and dark ones under Shadows); Exposure
kept them.
This changes how every existing edit with Highlights or Shadows renders, so
it ships as a new rendering process (docs/process-versions.md):
- Process::V2 (version 2) is added and made LATEST. New photos and Reset get
it; existing edits stay on V1 until Update to Current Process. Settings
saved without the field still load as V1.
- From V2 the base is the same guided filter of a separable median of log
luminance (radius 0.15 % of the long edge, <= 24 px): fine detail takes its
surroundings' shift and keeps its contrast, region steps stay put (no new
halos). V1's base is untouched: local::tone_base_detail is 0 under V1 and
tone_base then is exactly guided_fast(l, sigma, BASE_EPS), as before.
- CPU (local::prepare) and GPU prepare (median_h / median_v kernels, bit for
bit with the CPU median) both branch on it via PlaneSigmas::base_detail.
- The base plane's cache key is now (sigma, process, median radius)
(local::BaseKey), so a warm cache never serves one process the other's
base. plane_at is shared with the GPU crate.
- finish::base_tone: V2 uses V1's tone map.
Tests: V2 keeps twig/local contrast (>= 0.9 of Exposure's) with no halo; V1
plane sigmas have no median and its base equals guided_fast bit for bit; V1
and V2 render alike without Highlights/Shadows; warm-cache renders across
V1/V2 switches equal fresh ones (fails if the key drops the process); GPU
matches CPU on both processes at 720 and 2400 px, cached and not.
Pentax 645D 2401 at 4000 px: main and V1 renders bit-identical (plain,
Highlights -100, Shadows +100). Highlights -100 bright rel. detail
0.095 (V1) -> 0.147 (V2), Exposure -1: 0.124; Shadows +100 dark rel. detail
0.126 -> 0.148.
RENDER_CACHE_VERSION 54 (adjust at merge).
A keyword made from Keywords' + is shown (the section and its parents open); a
press on a header button never starts a section drag; a section dragged to the
top edge scrolls past a pinned header again (only a drag picked up by the
pinned header waits until it has left it). Tests that could not fail are
replaced, and the parity rows say what the code does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Albums' + is no longer a special case of the header: header_button says which
section has a button and whether it acts or opens a menu, and the frame draws
it in the header and its pinned copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every dialog but Import draws its options in a vertical scroll area capped at the
window height minus the title bar, frame and button row (measured on the previous
frame), so on a 1366x768 screen the Export dialog's title and Cancel / OK stay on
screen. Dialogs that fit keep their size. Lists that scroll on their own (What's
New, All Metadata, shortcuts, smart-album rules, credits) get shorter first rather
than scrolling inside a scrolling dialog. Import keeps its resizable window.
An album or photos released on the pinned Albums header went to the row
scrolled under it. Folding from the pinned header keeps the sideways scroll,
its hand-off to the next frame can't go stale, and a section picked up by the
pinned header doesn't start scrolling at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Their confirm does nothing, so they join About, Shortcuts and Settings in the
footer's informational list: one Close, no Cancel / OK. System Info keeps Copy
to Clipboard; Esc still closes them.
Pinned header at the top of the sidebar, hairlines between sections, the fold
chevron in the rows' triangle column, a count on folded sections, fainter zero
counts. From review: a move nobody could see is no move, ui.set refuses
`sidebar` together with `collapsedSidebar` and unknown names in the latter.
Translations and What's New for the sidebar work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
view.sidebarMoveSection; a section with nothing to list stays with a note
instead of disappearing (so every move is one you can see). ui.set reads
`sidebar` strictly and still takes the older collapsedSidebar list; an album
folder's triangle and row can no longer toggle twice in one click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
view.sidebarSection / view.sidebarReset; the same list opens from a section
header's and the My Photos title's context menu. A new album brings a hidden
Albums section back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Albums, Local, By Date, Folders and Keywords are drawn by one section frame from
SidebarLayout (order, hidden, collapsed per section), which is whole whatever a
settings file says. The folded sections of older settings files stay folded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export as JPEG XL from the dialog, CLI, MCP and control channel (format jxl): lossy with
quality or lossless, 8/16-bit, effort fast/normal, progressive, EXIF/XMP boxes; HDR edits
as 16-bit Rec. 2020 PQ. Encoder: jixel (pure Rust, BSD-3-Clause OR Apache-2.0, native
only: it spawns its own threads), wrapped in catch_unwind. Lossy files declare an enum
encoding (sRGB / P3 / Rec. 2020; Adobe RGB and ProPhoto written as Rec. 2020); lossless
files embed the ICC profile.
Decoder fix: jxl-oxide desaturated colours outside sRGB when rendering XYB images to
wider primaries; lossy wide-gamut JXL now renders to linear sRGB primaries and is
converted to Rec. 2020 here. jixel's slowest effort is left out: jxl-oxide misdecodes
its (valid) files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a 2015 MacBook Pro (Intel Iris/Metal), 0.5.0 never opened its window, even with
LIGHTCRAFT_GPU=0: wgpu's indirect-call validation builds an internal compute pipeline when the
window's device is created, Metal's compiler fails on it ("Compiler encountered an internal
error"), the device is lost and eframe exits. That validation only guards indirect draws and
dispatches, which neither LightCraft nor egui issue.
backend::instance_flags clears InstanceFlags::VALIDATION_INDIRECT_CALL for every instance
LightCraft creates (the window's, Gpu::new and the denoise device), unless
WGPU_VALIDATION_INDIRECT_CALL asks for it. Tests: the flag rule, and that the window's instance
descriptor goes without it.
Since #281, probe takes a Sony file as a raw only with raw evidence, and
two layouts have none of the kinds has_raw_ifd looks for: SRF (DSC-F828,
an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data
addressed by SubIFDs). Both were "not a readable photo". For Sony, IFD0
samples of 12 or 14 bits, or model DSLR-A100 with an old-style JPEG IFD0
(compression 6) and SubIFDs, now count too; an 8- or 16-bit camera TIFF,
or an exported A100 TIFF with a SubIFDs thumbnail, stays an image. The public F828 and
A100 samples open through their previews as on main 5436951; 133 other Sony files unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stationary hold recorded one pointer sample per frame and each sample laid
another dab at the same spot, so flow accumulated with hold time and the
feathered edge hardened. brush_dabs now lays no dab for a sample that hasn't
moved from the last one, and the canvas no longer records repeated samples at
the same position. Moving strokes and passes back over an area are unchanged.
While the button was held the Detail view drew one hard tinted circle per
pointer sample, so a fast stroke showed separate dabs; the committed stroke
interpolates dabs a quarter radius apart with feather and flow. The live
stroke now uses the pipeline's dab placement (push_dabs, shared with
brush_dabs), dab falloff and flow accumulation in an incremental screen
buffer (crates/ui-egui/src/brush_live.rs): each frame stamps only the new
samples' dabs and uploads only the rectangle they touched. push_dabs also
caps the dabs of a hostile segment instead of looping without bound.
Return presses OK in Choose Edit Settings to Copy and Paste Selected
Settings when no widget has the keyboard focus (a Tab-focused button or
checkbox takes Return itself, as egui does). Esc already cancelled them;
tests now cover both keys.
A grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which
sits near the start of the file — for a 24 MP Fuji RAF, 5.6 MB of a 56 MB file (measured:
DSCF9492.RAF). The loader read the file whole and threw the sensor data away, so every preview
paid for 50 MB nobody looked at. On a local disk that is merely wasteful; on a network share,
where photographers keep libraries, it is the wait: the same preview took 10 s over a GVFS/SMB
Wi-Fi share against 0.96 s after this change.
- `lightcraft_raw::bounded_preview(bytes)` locates the preview from the header alone and reports
its byte range; `embedded_preview_from_range` extracts it, byte for byte what `embedded_preview`
returns for the whole file (the pointer is read from the RAF header's fixed u32s at 84/88).
- `engine::files::preview_window` reads a 4 KiB prefix, then — because the container reader clamps
a slice to the buffer it was given, so a short prefix reports a plausible but truncated 3948
bytes — re-reads exactly the extent the header declares, and takes the window out of the prefix
rather than reading those bytes twice. `LIGHTCRAFT_PROFILE=1` prints what each preview cost.
- Formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix,
CRW/MRW/X3F scans), a lying pointer, a short read and a file that changes underneath us all fall
back to the whole-file path: a damaged header never costs the user their preview.
- Tests: the header range matches `embedded_preview` byte for byte, with a clamped prefix, a padded
extent, a late pointer, a lying pointer and a non-JPEG window covered; on a synthetic RAF with a
200 KB tail the quick path and the whole-file path produce identical pixels and the read stays
under half the file; `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`) measures a real
file on a share. No raw fixture is committed.
- `docs/raw-preview-reads.md` records the mechanism and the numbers.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
The R5 Mark II's APS-C crop-mode files (5376 x 3574) state an IAD1 active area of x 132..5243,
but columns 132..~256 sit at the black level and the image runs to the right edge (measured on
raw.pixls.us 7883/7884), while their recommended crop, x 272..5359 (5088 x 3392, the camera JPEG's
size), lies on the image. The recommended crop didn't fit inside the stated active area, so the
whole active area was kept: a black band down the left and ~130 image columns lost.
When the recommended crop doesn't fit inside the stated active area, it is now the valid area.
On 7883 the render's high-pass correlation with the camera JPEG goes from -0.13 to 0.99. The
active area and crop of the other 108 decodable CC0 CR3s on raw.pixls.us are unchanged.
RENDER_CACHE_VERSION 49.
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
white_from_data anchored the plateau search on the absolute maximum, so a
handful of stray samples above saturation (Canon EOS 70D CR2: 83 of 20.6M,
up to 14426 vs. a plateau at 13583) made it fall back to 2^bits-1. The
image rendered ~0.3 EV dark and clipped highlights were never seen as
clipped. Skip up to half a plateau's worth of strays when locating the top.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
develop.set / develop.adjust / develop.quickAdjust / develop.resetControl on crop.angle
(MCP set_develop, the control channel, lightcraft-cli render --set) and crop.set {angle}
now turn the crop as crop.straighten does, through the new CropGeometry::with_angle:
kept if it still fits, else the largest crop of its aspect inside the rotated photo.
Before, they left the full rect, and the export took in smeared edge pixels in the corners.
Thai folder and file names drew boxes: the UI fell back only on craft-fonts' CJK faces, so a
Thai face (craft-fonts #15) or the Arabic one already there was never used. fonts::ui_fallback
keeps the CJK faces first, in their per-language order, then appends every face of another
non-Latin script, whatever the interface language (a Latin-only face is no fallback: Inter covers
Latin). theme.rs builds the Proportional, SemiBold and Monospace families from it.
Tests: the fallback order on synthetic faces; with a craft-fonts build that has a Thai face,
every UI family falls back on it, and the shipped Thai and Arabic faces have their script's glyphs.
The interface ships in nine languages but the README only read in English, so a
Chinese reader met the project in a language it does not have to use.
README.zh-hans.md follows README.md section by section (569 lines, 27 headings,
7 tables, 8 code blocks), keeping every link, image, command, shortcut and
identifier byte-identical; only prose, alt text, code comments and table headers
are translated. Terminology follows the shipped interface catalog
(crates/ui-egui/locales/zh-hans.json) so the README and the app agree:
曝光, 白平衡, 颜色混合器, 胶片窗格, 留用, 正方形网格, 垂直校正.
Both files gain a language switcher at the top.
The D4 records no CropArea. Its 4992 x 3292 data keeps 2 columns of optical
black left of the image and 42 right of it (then 8 empty ones), which showed
as purple stripes on both sides; the camera's own 4928 x 3280 JPEG sits at
(8, 6). Green saturates at 15546-15672 while red and blue run on to 16383,
so the data's own top left clipped highlights magenta; one level of 15520
keeps them white without moving the white balance.
Measured on 148 uncompressed 14-bit NEFs from one body by aligning the
sensor data with the embedded camera JPEG.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dimming outside the crop box was four quads, each joining a side of the
photo to the matching side of the box. Aligned, that is the photo minus the
box; rotated, the quads cross themselves and each other, so wedges inside the
box were dimmed and wedges outside doubly dark, growing with the angle (the
reporter's -45° shows it best). The export was never affected: the overlay
alone was wrong.
The dimming is now `outside_crop_mesh`: the photo's rectangle minus the box,
as one mesh of four disjoint half-strips (outside edge i, inside edge i+1,
clipped to the photo). A flat or non-finite box dims nothing.
Tests: a coverage test over several box sizes and angles (every photo point
outside the box in exactly one triangle, none inside the box or outside the
photo), a degenerate-box test, and a headless pixel test at -45° (inside the
box the frame matches the 0° frame; the photo's corner is dimmed), which
failed on the old mesh.
Fujifilm RAFs carry per-shot GeometricDistortionParams (0xf00b) and VignettingParams (0xf010)
in the raw IFD. Derived by measurement against the cameras' own JPEGs and LightCraft's
uncorrected renders: [scale, n knots, n values], scale x n = half diagonal of the recorded image,
last knot = farthest corner, distortion in percent indexed by the source radius with the
frame-filling zoom, vignetting as 100/V gain. They become a WarpRectilinear and a
FixVignetteRadial for 59 validated bodies (same path as the Sony ARW and Panasonic RW2
corrections); unmatched bodies and tables with another scale stay uncorrected.
On 67 body/lens combinations the corrected render's radial shape error against the camera JPEG
falls from a median of 0.72 % to 0.05 % of the half diagonal. The lateral CA table (0xf00f) is
real but is not applied: our demosaic already registers the colour planes and the stored shift
over-corrects. See docs/fuji-lens-corrections.md.
RENDER_CACHE_VERSION 48.
The denoise worker saves the picture, then tears down its rayon pool and
returns before it writes its outcome. A `denoise.pump` in that window saw the
file on disk (`denoise_check_photo` for the selected photo, or a reindex),
marked the photo Ready and still held the job as running: `PhotoState::Ready`
and `denoise_busy()` at once, the activity row up for a photo that said it was
done, and the flaky `a_photo_waits_for_its_turn…` assertion on a loaded
machine.
The disk checks now leave the running photo to its job: `denoise_poll` makes
it Ready in the same pump that clears `running`. A test blocks the worker in
the model loader, writes the picture in its place and shows the photo stays
Running until the worker reports, then Ready and idle together.
`probe_info` (every file an import adds) ran the whole Nikon Huffman
decode on a compressed NEF because `nef::decode` had no header mode and
the masked-trailing-column test wanted the samples: 386 ms per 24 MP
file, the full decode time, where every other format probes in < 1 ms.
The header has no source for those columns (the D5100 and D7000 write 8
masked columns at value 0, compressed and uncompressed alike, with no
CropArea or other maker-note tag), so the probe cannot skip the test.
Instead both modes now judge the active width from the first 128 rows
(`nef::MASK_ROWS`) against the nominal white level: the header probe
decodes only those rows (`nefc::decode_rows`, the stream is sequential;
uncompressed files read only the strips holding them) and the full
decode uses the same rule, so `probe_info == RawImage::info` still
holds. On every corpus NEF/NRW the answer is the same for 8 to 512 rows
as for the whole frame. The whole-image size and truncation checks still
apply in both modes; the "lossy after split" end-of-strip rule is only
checked when the whole strip is decoded.
Release, same files: probe 66 -> 3.4 ms (D5100 lossless), 72 -> 3.2 ms
(D7000 lossy 12), 88 -> 3.1 ms (D7500 lossless 12), 92 -> 3.7 ms (Z f
lossless), 23 -> 1.5 ms (D5100 uncompressed), 54 -> 2.7 ms (B700 NRW).
Tests: a synthetic lossless NEF whose stream is cut off after two
thirds of the rows probes to the same info as the whole file while the
full decode reports the truncation; synthetic uncompressed NEFs with
masked columns in the top rows, lower rows, all or none give the same
active area in both modes. Both failed before the fix. Docs: the
`probe_info` doc comment, docs/parity.md (LR-IMP-FORMATS),
docs/gpu-pipeline.md.
Import a photo, delete it, import the same file again: the import skipped
it as a duplicate "already in the library" while the grid showed it in
Recently Deleted, and the user was stuck (issues #298, #706). The default
for a file whose photo is in Recently Deleted is now to restore the photo,
with its edits, rating and albums (`onDeleted: restore`; `skip` keeps the
old behaviour, `fresh` imports a clean copy). A photo matched by content
whose file is gone is relinked to the imported file. The import review
checks such files, badges them "Will be restored", counts them as "in
Recently Deleted" and starts its choice on Restore them; dropped files
say "Restored 1 photo from Recently Deleted".
Browsing a folder in Local, Auto Import and a Lightroom catalog import
keep skipping: only an import the user asked for brings a photo back.
Issue #705 (Linux, Nikon `.JPG` hidden by the Add Photos dialog) is the
symptom of #342, fixed by e2d35de8 after the 0.4.0 release and shipped in
0.5.0: 0.4.0 handed GTK / the desktop portal `*.jpg`-style globs, which
they match case-sensitively. Every dialog filter now goes through
`add_filter_nocase` (`*.jpg`, `*.JPG`, `*.[jJ][pP][gG]`).
An audit of every other extension match (folder scan, Browse Folder,
drag-and-drop, the CLI, `codecs` sniffing, the XMP sidecar lookup,
raw+JPEG stems, export format by extension) found them all folding case
already. This adds the regression test that proves it: a folder with
`a.JPG`, `b.Jpg`, `c.JPEG`, `d.PNG`, `e.NEF`, an `a.XMP` sidecar and a
`b.Jpg.xmp` sidecar imports all five (both sidecars read) and
`library.browse` shows all five. Docs note what was case-sensitive and
when it was fixed.
crop.drag gains an optional keepRatio param that constrains the drag to
the frame's current aspect even when the lock is off. The crop panel
sends it while Shift is held.
Closes#614
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every rfd dialog is built from one template that has the main window as
its parent (set once in `services` from the `CreationContext`). Without
a parent the xdg-desktop-portal picker on Wayland is a stray top-level
("Failed to associate portal window with parent window"): tiling
compositors put it beside the app, and a fullscreen window hides it
entirely, so Import Photos… looks like a hang. rfd exports the Wayland
surface through xdg_foreign; `FileDialog` is `Clone` and `Send`, so the
template also serves the picker on its own thread (#191). Not set on
macOS (sheet) or Windows (modal), which this does not change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`export_falls_back_to_the_cpu_when_gpu_work_is_lost` asserted on
`lightcraft_gpu::last_fallback()`, one value for the whole process that
every other engine test's GPU render overwrites between the export and
the assertion (out of device memory under parallel load, once "a GPU
render panicked"). It also switched the GPU off for the whole process
through the injected fault and cleared everyone's state with
`reset_failures()`.
A render now reports its own fallback: `lightcraft_gpu::try_render`
returns `Result<Rendered, Fallback>` (`NotTried` when the GPU was not
used, `Failed(reason)` when it was tried and the CPU must render), and
`render` is a thin `.ok()` over it. `media::develop_reporting` passes
the reason on as `RenderResult::gpu_fallback`, an export carries it as
`Exported::gpu_fallback`, and each file in an `app.export` batch result
has `gpuFallback` when its render fell back. `last_fallback()` stays as
the process-wide diagnostic for `ui.inspect` → `perf.gpuFallback`.
A simulated failure (`inject_fault`) is thread-local, and so are its
consequences now: a fatal injected fault stops GPU use on the injecting
thread only (`available()` false, `unavailable_reason()` set there);
`reset_failures()` clears that and nothing else, so neither reaches the
other tests in the process. Real failures still stop the whole process.
The engine test asserts on its own export's reason and checks that
another thread still has the GPU; `crates/gpu/tests/fallback.rs` uses
`try_render` and checks the same; a new batch test checks `gpuFallback`
lands on the file whose render fell back. Docs: gpu-pipeline.md,
control-protocol.md (app.export result), parity.md.
Issue #697 (ILCE-7CM2 lossless compressed M-size ARW "washed out" in 0.4
and 0.5) is the 512-vs-1024 YCbCr pedestal of #535 item 3, fixed on main
by 3100cc1a after v0.5.0. Measured on the reporter's file: 0.5.0 renders
the darkest half of the dusk scene +11 L* and blue against the camera
JPEG, with the lamplight turned white (mean dE 11.8); main is within
+3 L* (dE 8.9). Its darkest 0.1 % per channel sits at 1017/1020/1024,
confirming the 1024 pedestal on a fifth body.
- Corpus: two CC0 raw.pixls.us ILCE-7CM2 files, lossless L and the
compressed "M-size" coding, which is Bayer at 4736x3132 with black 512
(only the lossless M/S codings are YCbCr), so a size-based pedestal
rule would break it.
- Tests: corpus_sony_a7cm2_codings (model, layout, levels, crop, as-shot
gains, header/full agreement) and both files in the #535 data-floor
test.
- docs/parity.md: the ILCE-7CM2 verification on the Sony coverage row.
LibraryLock freed the library by letting its File close. On Unix the
flock lock belongs to the open file description and lasts while any
descriptor on it is open. When another thread spawns a process,
posix_spawn copies the whole descriptor table into the child and
O_CLOEXEC only closes the copies at execve, so a LibraryLock dropped in
that window stayed locked until the child exec'd. Re-acquiring the
library then failed with LockError::InUse(None) ("already open in
another LightCraft program") although nobody had it open. That is the
flake in tests_lock::second_opener_is_refused_until_the_first_lets_go,
whose neighbour lock_held_by_another_process_is_released_when_it_dies
spawns the test binary on a parallel libtest thread.
Drop now removes the owner note while still holding the lock (so it
can't delete the next holder's), then calls File::unlock(): LOCK_UN
through our descriptor frees the lock whatever copies a child still
holds. An unlock error is logged, never a panic; the handle still closes
right after, which releases the lock once every copy is gone, as before.
Tests: the_library_is_free_once_dropped_even_if_a_child_holds_a_copy
holds a try_clone of the lock's descriptor across the drop (the same
open file description a child gets between fork and exec) and
re-acquires; dropping_the_lock_frees_the_library_while_another_thread_
spawns_processes runs a thread spawning /bin/true against two seconds of
acquire/drop cycles. Both fail every run without the fix (the first
deterministically) and pass with it; the flaky pair went from 3/100 to
0/100 failed runs here. Both are cfg(unix): Windows handles aren't
inherited by CreateProcess.
A sibling of spot.findDust for portraits: with the YuNet detector installed, small
round spots on each face's skin that are darker or redder than the surrounding skin
become heal spots (one undo step). The detector's box and landmarks define the skin
(ellipse, skin-colour gate, eye band / brows / nostrils / mouth cut out); a candidate
must stand out from a blurred copy beyond the sensitivity threshold and 2.5x the local
texture, then grow into a small roundish blob. Conservative: faces under 160 px get
nothing, faces with more than 12 candidates (stubble, freckles, pores) get nothing, at
most 6 per face (maxPerFace). Classical, no model beyond the detector.
Tests (synthetic, no model): cheek spots found at their positions, clean face empty,
spots on features ignored, small face ignored, speckled face treated as texture,
hostile boxes and an empty image do not panic. New LR-REM-BLEMISH parity row, Find
Blemishes section in docs/faces.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Heal and clone spots stored as crs:RetouchAreas (Mask/Ellipse, Mask/CircularGradient
or a brushed Mask/Paint target; SourceX/OffsetY as the source's absolute position,
honoured only when the source was set explicitly; Opacity and Feather 0..1) and the
legacy crs:RetouchInfo list become our spots when a sidecar, embedded XMP, XMP preset
or .lrtemplate is read. Width fractions are converted to long-edge fractions with the
target's aspect, like radial masks. Unsupported spot masks are reported as
`Spot: <kind>`. Format established by observing what a sidecar written for a healed
photo contains; clean-room.
Tests: heal with explicit source, clone with automatic source, brushed removal,
circular-gradient spot, unsupported mask reported, portrait-target scaling, legacy list
on its own, and a render test. docs/xmp-interop.md gains a Spot removal section;
LR-META-XMP row updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Settings ▸ Faces let the switch be turned on with no recognition model
chosen or installed: the box ticked, Settings closed without a word, and
recognition was on with nothing to run. Now the box is enabled only with a
chosen, installed recogniser (and a build that can run models), says
"Choose a model below (Use) to start." on hover, and that hint stays under
it on or off; a box left on by an older build can still be switched off.
faces.enable {enabled: true} refuses without such a model, so the control
channel and MCP cannot reach that state either, and removing the last
recogniser switches recognition off instead of leaving it on with nothing
chosen. Installing a model still chooses it and switches recognition on.
Tests: tests_face_models::recognition_cannot_be_turned_on_without_a_chosen_model
and tests_panels::recognise_faces_stays_off_until_a_model_is_chosen (fails
on main at the first click). Docs: faces.md, parity LR-PREF-PEOPLE.
A menu taller than the room under the menu bar scrolls inside an egui
ScrollArea whose floating scrollbar is drawn over the rows' right edge,
where the shortcut text ends. At 1.0x the 2-point handle grazes the last
glyph; at 1.75x the bar is in its wide hovered state with a semi-opaque
background and hides the last character of every shortcut, which reads as
clipping. The scroll content, its clip and the rows are the level's width
in both modes (instrumented), so nothing else needs to move: the scroll
area hides its bar. The up/down strips, the wheel and dragging were already
the designed ways to scroll a menu, like native menus; the native macOS
menu bar does not go through this code.
Test: tests_menubar::a_scrolled_menu_draws_no_scrollbar_over_its_shortcuts
(a 700 pt and a 1400 pt window: no tall narrow rectangle along the level's
right edge, equal widths), via a new HeadlessView::painted_shapes; fails on
main with the bar's track [512, 59]-[514, 660] and handle.
Keep image framing inside the validated 3:2 scope even when DefaultCrop retains native geometry. Cover all 23 Sony fixtures, preserve crop precedence, invalidate old renders, and record fresh Sony reference measurements.
Co-authored-by: Codex <noreply@openai.com>
Album views list library photos only, but album.create {addSelected},
album.addPhotos and album.toggleTarget (B) took the selection as is, so a
browsed Local photo became an album member that never shows: count 1, an
empty album, a cover that can't render.
Engine: the three commands drop Local photos from their targets and report
`skipped`; a selection of nothing but Local photos is an error that says to
Add to My Photos first; album.addPhotos and album.toggleTarget use a new
has_library_selection enabler. album.create still makes the album, minus
the Local photos.
UI: for a Local-only selection the grid's right-click Add to Album is off
with a hover hint, Photo ▸ Add to Album starts with a disabled "Add to My
Photos First" item and its entries and New Album… are off, a folder row's
Add Selected Photos is off, and a drag over an album row shows not-allowed
and toasts the hint on release. A mixed selection runs and toasts how many
Local photos were skipped (drop, context menu, folder row, New Album
dialog). Context-menu album entries are button:contextAlbum:<id>.
Messages added to every locale catalog. Tests: tests_albums_local.rs
(engine) and tests_panels::local_photos_cannot_be_added_to_albums_until_
they_join_my_photos (UI, fails on main). Docs: parity rows LR-LIB-ALBUM,
LR-IMP-LOCAL, MENU-PHOTO-ADDTOALBUM.
quick-xml's own duplicate-attribute check is quadratic. Switch it off and
refuse a repeated name with a per-element set instead, so scan keeps its
behaviour on malformed packets without the quadratic cost.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FixBadPixelsConstant listed every pixel equal to its constant, and the
FixBadPixelsList cap was checked only after a whole rectangle was pushed.
Cap both at the number the list arm already used (1 << 22).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The grid context menu's Add to Album submenu listed only the existing plain
albums, so in a library without one it came up empty. The Photo menu's
submenu already ends with New Album…, and album.create adds the selection
by default; the context menu now has the same entry (after a separator
when albums exist). The submenu and the entry are registered as
button:contextAddToAlbum / button:contextNewAlbum for the control channel.
Test: right_click_add_to_album_offers_a_new_album_when_there_is_none
deletes every plain album, right-clicks a thumbnail, hovers Add to Album,
clicks New Album…, names and confirms it; the album exists and holds the
photo. Fails on main with "no widget button:contextAddToAlbum".
There was no zoom/pan reset on main to fix: what this PR changes is that a
zoomed view with the mask overlay shown is now a canvas-sized frame plus a
sharp window carrying the overlay, as without it, instead of a whole-frame
render at the zoomed size. Tests: the overlay on a window matches the same
pixels of the whole render for radial, refined linear and sky masks in
three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window
with the overlay, keeps zoom and pan, and swaps tiles when the overlay is
hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No
render output change (no cache bump).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests_lock::second_opener_is_refused_until_the_first_lets_go failed now and
then on macOS with InUse(None): the child process that
lock_held_by_another_process_is_released_when_it_dies spawns briefly holds
copies of the test process's file descriptors, lock files included, until its
exec closes them, so a lock dropped in that moment stays held. The three tests
that hold a lock in the test process now take one mutex. Under parallel load:
6 of 400 runs failed before, 0 of 400 after. Test-only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bad remembered value (a hand-edited settings file) made begin_crop fail,
which cancelled every crop drag on an untouched photo for good. Now the value
is dropped with a status message and the drag goes ahead. Regression test in
tests_masking.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/main into #480 and apply the review:
- the Samsung-with-a-Pentax-maker-note -> PEF rule now also needs raw evidence; its test fixture
gets a CFA photometric, and the same RGB file without it is not a raw;
- for the Pentax family the Exif CFAPattern (0xa302) counts as raw evidence, as main's PEF reader
takes its layout from it (uncompressed / PackBits PEFs);
- Samsung's private raw compressions 32769..=32773 count for SRW (NX1 / NX500 use 32773);
- the engine fallback in files.rs is dropped (main's version kept): a corrupt raw without a
preview stays an import error; the engine regression test for camera-authored TIFFs is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/main into #555. The decode_hasselblad doc comment records how the coding rule was
established (first hypothesis recalled by the AI author from training data that includes other
converters, corrected for byte order and confirmed by a black-box search over 17,280 layout
hypotheses on the CC0 files; the code is original). The LR-IMP-FORMATS row on main's text now
lists the decoded FFF and lossless-JPEG 3FR files instead of preview-only. RENDER_CACHE_VERSION
37 -> 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's reconstruction (#607) replaces this branch's rewrite of highlight::reconstruct. Kept from the
branch: develop_binned_masked (clipping decided per sample before averaging) and the denoised twin
using the same mask. New: highlight::reconstruct_masked, #607's reconstruction with the clipped
channels told by the mask (block means as lower bounds), shared with reconstruct_with through a
generic clip test (no slowdown on full-size images), and CameraColourModel::rebuild_highlights_masked
so binned previews keep the colour model's clipped white. RENDER_CACHE_VERSION 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A catalog copied from another computer (e.g. Windows to Linux) records
root folders at paths that don't exist here, so every original was
imported as missing. When a root's absolutePath isn't a folder, use the
relativePathFromCatalog Lightroom stores for roots on the catalog's
volume, resolved against the catalog's folder, and report the remapping
as a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md gains the rule (a TaskGuard for every job that runs off the UI
thread and can take more than a moment; no progress window, panel or
toast of its own), and docs/background-tasks.md says how: kind, label,
progress, units, detail, the three ways to cancel, examples and tests.
Photo Merge's and the face scan's docs no longer describe the old
progress toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/main into #560 (fit_preview / camera-look refactors of #528, #583, #568, #616).
`starting_colour` now tries the per-file camera-JPEG fit (with the camera profile when there is
one) first and uses the spectral-sensitivity matrices only when that fit fails, before the neutral
fallback, so no photo with an accepted JPEG fit changes. Tests pin the order through an injectable
fit; docs, parity, ROADMAP and what's new describe it. RENDER_CACHE_VERSION 37 -> 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Synchronize Folder (#604) shows its scan ("Looking for changes", while
its dialog is open) and its run ("Synchronizing folder") as rows: the
scan's cross stops it and closes the dialog, the run's stops it like
Cancel did (what was done stays, as one undo step; the end says Stopped).
Its progress window is gone. The contact-sheet PDF export (#563), which
went through the export task already, gets a test for its row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only proxies of raws that take the per-file camera look (Photo::relative_wb) go stale: JPEG/PNG/DNG proxies are never rebuilt for the look. The opening scan runs once per LOOK_VERSION (a look-version marker in the smart previews folder), not at every library open; Build Smart Previews still refreshes stale proxies. Each proxy is checked and written under one lock, so the background refresh and library.smartPreviews never write the same file at once. LOOK_VERSION 2: main's DRO / ILCE-7CR camera-look changes (#528, #583, #568, #616).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main (#575, #580) rewrote default_crop. The image crop now keeps main's
order for every Sony body (Sony's crop tags, then DefaultCrop, then the
recorded-size crop), so framing does not change. Only the ILCE-7RM4A
distortion warp takes its centre and normalization from DefaultCrop when
the file has one, which keeps the geometry validated against Sony's
exports (centre x 4783.5 on the A7R IVA sample). The DNG round-trip test
now checks both: Sony's crop on the image, the warp centred on the
default crop. RENDER_CACHE_VERSION 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendered the old and new heuristic over the 24 procedural demo scenes: the
rewrite sharpens edges on blue skies (beach, dunes, canyon), but drops warm
sunset and dark night skies to nothing, punches a hole around the sun, and
selects lavender rows, a flower and lit mountains as sky. It also changes the
render of every existing Sky mask without a process version. Back to main's
heuristic; the masking-view part of this PR stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's SR2SubIFD white balance (#575) already reads the A900's applied
levels (2688 / 1024 / 1024 / 1516, the same 2.625 / 1.480 gains), so the
hard-coded maker-note 0x0020 block at offset 188 is dropped. The centred
crop now goes through #580's recorded_size_crop (a `centred` flag, only
for the packed 12-bit layout); every other body keeps main's framing.
Adds the CC0 A900 sample to the corpus with checks for its white balance,
black level, bit depth and crop. RENDER_CACHE_VERSION 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cross clicked in the frame its task ended no longer puts "no task N"
in the status bar: the row's cancel goes to the engine directly instead
of through the app's command runner, which reports every error there.
The face-scan row test scans demo photos no other scan test uses, so it
no longer joins the scan deadlock of #630 (with one of the existing pump
tests it hung 13 of 20 runs; now 0 of 20).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LR-BEHAV-BG-TASKS now cites activity.list / activity.cancel and lists
every job on the stack; the import and Build Previews rows no longer
describe progress windows and toasts; a what's-new entry.
Fixes the stack not growing when tasks arrive while it already shows a
row (an egui area lays out in last frame's size, and the scroll area took
that as all the room, so later rows were cut off), and adds the Russian
"Quit Anyway".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Find Missing Photos shows a row (a sweep: the amount of work isn't known,
no cross) while it searches; the auto-import folder listing, which runs
every few seconds, stays quiet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a photo's AI Denoise picture is being made or waits its turn, the
stack shows "AI Denoise" with the running photo's tiles as a percentage
and its file name (no cross: what is made follows the Denoise amounts and
Settings). The row goes when the queue is idle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While photos are left to search for faces the stack shows "Finding
faces" with how many are done (no cross: the scan is switched off in
Settings). The engine keeps the scan's peak, so Settings and the stack
agree. Also translates the "Downloading model" label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Downloading the SAM 3, face and AI Denoise models shows a row with the
model's name and the megabytes so far; its cross stops the download like
the Settings button. The denoise download tests get their own scratch
folders (they shared the face tests' names and could delete each
other's files).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The final HDR / panorama merge shows a row with its percentage and stage
instead of the progress toast, and stops from its cross ("HDR merge
cancelled"). The activity tests now let the first renders finish before
they run, and the half-second check tolerates a slow frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inspecting and importing a Lightroom catalog show a row instead of the
progress window; reading the catalog stops from its cross, adding the
photos and saving the index run to the end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Build Previews, Build Smart Previews and Discard Smart Previews show a row
with the photo count and stop from its cross; the start toast goes, the
finish toast stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import review's folder scan (and reading a folder for Local) is a
row in the activity stack: "Looking for photos…" while the folders are
listed, then the files read of the total. Its cross drops the scan at
once, without waiting for the file being read, as the old Cancel did.
The separate Scanning window is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cross sits at the right edge of its row (the name's area kept only
the text's width, so the cross followed short names). Seen in the
running app; a test pins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import (and Auto Import, and reading a folder for Local) registers in
the activity stack with the files done of the total; its cross and
activity.cancel set the import's cancel flag, so nothing new starts and
the result still reads "Import cancelled · N added" as one undo step.
The separate Importing window is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A background export registers in the activity stack with the photos done
of the total and the file in progress; its cross and activity.cancel set
the export's own cancel flag. The separate export panel at the bottom
left is gone; the result toast is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quitting while an import, an export or another task that can be
cancelled is running asks first: Quit Anyway cancels them (then any
unsaved changes still get their own question), Cancel keeps working.
Tasks without a cross (the face scan, AI Denoise) pick up again on the
next launch and don't ask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every task in the engine's activity registry floats as a row under the
top bar once it is half a second old: its name, a progress bar (a sweep
while the amount of work isn't known), the count and what it is working
on, and a cross for the ones that can be cancelled. Three rows, then
"+N more". ui.inspect reports the same list as activity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The registry is reachable as commands, so the UI, the control channel,
the CLI and MCP list and cancel background tasks the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A registry of the background tasks in flight on Session: a job holds a
TaskGuard while it runs (dropping it, also during a panic unwind, removes
the row), adopts its existing cancel flag, and reports done/total in a
unit the frontend words. Groundwork for one progress stack for imports,
exports, preview builds and the other long jobs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.
- highlight::reconstruct_with: the colour of a pale partly clipped pixel
(saturation relative to the colour model's white below 0.2, easing off by 0.4)
fades to the clipped neutral from the first clip on instead of from halfway;
brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
the clipped channels at their clip, for the neutral estimate only), so the first
clip has no step whatever lent the estimate. The rim is built whenever there is
clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
contradiction, a clipped channel counts as the lower bound it is, and a channel
goes over to that from 0.75 of its clip; a channel about to clip is a floor
capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
step where green clips, no green from the foliage, the same beside the branches,
neutral well past the clip); the fringe test compares the sky beside the branch
with the sky away from it, on its way to neutral; a band whose red clips second
next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A render still running on a worker thread while the process exits crashes in the
driver (SIGSEGV in the NVIDIA Vulkan driver on Linux), where neither the panic
hook nor catch_unwind helps: pool workers were detached, the devices are statics
that are never dropped, and nothing on the way out waited for GPU work.
- gpu: every entry into a device (render, device creation, pool trim, denoise
runner and tiles) is counted as work in flight (exit.rs). begin_shutdown()
lets nothing new in; wait_idle(timeout) waits for what is inside.
- preview: JobPool keeps its workers. shutdown(timeout) and Drop discard the
queued jobs and wait, bounded, for the running ones (2 s on drop); never for
the calling thread, and not twice.
- engine: a render job caught by the shutdown gives up instead of rendering on
the CPU, so quitting doesn't wait for it.
- app: on_exit ends with LightcraftApp::shutdown (2 s for everything), after
settings and library are saved; the macOS menu-bar Quit goes through on_exit.
Not changed: the export, merge, denoise and import threads are still detached;
their GPU work goes through the gate, their CPU work is cut off at exit as before.
Tests: pool drop waits / is bounded / survives a panicked job and its own job
dropping it; the gate; GPU renders refused and the device not created after
begin_shutdown; the app's shutdown leaves no render worker. The crash itself
needs Linux + NVIDIA and was not reproduced here (Windows, DX12): 9 of 9 quits
of the app mid-render exit with code 0 in under 0.8 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.
- highlight::reconstruct_with: the colour of a pale partly clipped pixel
(saturation relative to the colour model's white below 0.2, easing off by 0.4)
fades to the clipped neutral from the first clip on instead of from halfway;
brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
the clipped channels at their clip, for the neutral estimate only), so the first
clip has no step whatever lent the estimate. The rim is built whenever there is
clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
contradiction, a clipped channel counts as the lower bound it is, and a channel
goes over to that from 0.75 of its clip; a channel about to clip is a floor
capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
step where green clips, no green from the foliage, the same beside the branches,
neutral well past the clip); the fringe test compares the sky beside the branch
with the sky away from it, on its way to neutral; a band whose red clips second
next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two new catalogs landed alongside PRs that added ~127 strings; the per-language
tests require them in every catalog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loupe read the GPU's texture limit from the frame's raw input
(`i.raw.max_texture_side`). A native host (egui-winit) reports it in the
first frame's input only, so from the second frame on the loupe fell back
to 2048: every window render of a zoomed view was cut to 2048 x 2048 px,
and on a canvas wider than that (HiDPI, 4K, a large window) the strips
beside it showed the magnified whole-frame render. The whole-frame render
and the second-window and compare renders were capped the same way.
Read the limit egui keeps (`InputState::max_texture_side`) instead.
The headless host reported the limit in every frame, which is why no test
saw this: it now reports it once (and again when it changes), as a window
does. Two regression tests: the limit outlives the frame it was reported
in, and at 1:1 on a 2600 px canvas the window covers the canvas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`encode_avif_pq`: linear Rec. 2020 with SDR white at 1.0 → PQ (SDR white at
203 cd/m², BT.2408), BT.2020 NCL YCbCr 4:4:4, 10-bit, with CICP nclx
(9/16/9, full range) and a clli box. ravif fixes the sRGB curve, so this
drives the rav1e + avif-serialize it already uses (no new crates).
Export: AVIF with `hdr` renders the HDR edit in Rec. 2020 and writes PQ.
Photos without an HDR edit export as before, in every format. CoreImage
decodes the file as HDR (same 3.1 % of the frame above SDR white as the gain
map JPEG of the same edit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Light panel: HDR switch, headroom limit and an SDR Rendition flyout
(shown with HDR on); Visualize HDR beside the switch.
- `Overlay::HdrRange` / `view.visualizeHdr`: the SDR render dimmed to grey,
with pixels the HDR rendition puts above SDR white painted in four bands by
stops above it. Rendered on the CPU from the float HDR render.
- Export dialog: "HDR output" for JPEG (gain map) and 32-bit TIFF.
- Parity (Q. HDR 2 ✅ 3 🟡, LR-EDIT-HDR-MODE ✅, LR-EXP-HDR 🟡), README, ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `ExportOptions.hdr` (`hdr` param): photos edited in HDR export as ISO
21496-1 gain map JPEGs (the SDR rendition as the base, the gain map measured
against it as written) or 32-bit float TIFF with highlights above 1.
Other formats and SDR edits export as before.
- Output sharpening and watermarks apply to both renditions.
- `develop.hdr {enabled?, maxEv?}` turns HDR editing on/off and sets the
headroom limit.
Checked on macOS: ImageIO reports both the ISO and the Apple gain map, and
CoreImage's HDR decode of a 3 EV edit reaches 2.95 EV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sensor-clipped pixels are rebuilt at raw decode. The tone map rolls them off to
white at Neutral, so any colour they carry shows only when Highlights, a negative
Exposure or Whites darkens them. They took colour from the nearest unclipped pixel
(dark foliage, lens fringes along branches, pixels demosaiced from clipped samples),
left two clipped channels at their white-balance-scaled clip levels (red or pink),
and were neutral only in camera RGB, not after the colour model (a look fitted to
the camera's JPEG, a camera profile).
- highlight::reconstruct_with: chromaticity only from bright unclipped content away
from edges and from the clip, through a normalised push-pull pyramid; a neutral
estimate blended in where reliable content is scarce at every scale up to a
quarter of the long edge, applied only as far as the pixel lies past the nearest
near-clip unclipped pixels (so a surface still runs into clipping without a
step); clipped pairs keep the estimate's ratio at their clip floors; partly
clipped pixels fade into the clipped neutral on their way from the first channel
clipping to the last; fully clipped pixels become the colour model's neutral.
Pixels without a clipped channel are left exactly as they were.
- engine: the decode colour step as CameraColourModel, with clipped_white, which
keeps the camera's neutral for nearly singular or implausible models.
- Tests in raw (continuity along a ramp through every clip, also as lines 1 to 16
rows thick with no reliable colour, on canvases 32 to 1024 rows), engine
(pipeline renders at Highlights -100, #523's edit and Exposure -2 through the
first clip, over the same sweep; a CC0 ILCE-7RM4 corpus file added to
RAW_SAMPLES) and the GPU equivalence suite.
- Render cache version 27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #535 (item 4).
Pre-2017 bodies without Sony crop tags shot in the camera's 16:9 mode now get
the 16:9 crop of their camera JPEG: width from FullImageSize, anchored left as
before (drops the ILCE-7SM2's 32 padding columns); height from FullImageSize
(ILCE-7SM2) or from an Exif image size of the same width (DSLR-A580, whose
FullImageSize still says 3:2); centred vertically at an even offset, as measured
by registering each camera JPEG on its raw. The aspect now matches the JPEG, so
the camera-look fit runs: ILCE-7SM2 dE 15.2 -> 4.8, DSLR-A580 dE 26.7 -> 3.9.
Only these 2 of 140 Sony decodes change; render cache version 26 (23-25 are
taken by #568, #542, #575).
Tests: crop helper (3:2 unchanged, both 16:9 sources, even offsets, hostile
sizes) and a corpus test of the two crops against their camera JPEGs; both
samples added to the corpus list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`lightcraft_codecs::gainmap`:
- `compute` measures a gain map (luminance or RGB, log2, block-averaged)
from an SDR and an HDR rendition; `apply` rebuilds the HDR rendition.
- ISO 21496-1 metadata: writer (fixed denominator) and reader (also the
common-denominator form; refuses unknown versions, backward maps and zero
denominators). Adobe `hdrgm` XMP reader and writer, per-channel values as
rdf:Seq; Apple's HDRGainMap/HDRToneMap description of the same map.
- `encode_jpeg`: base with CIPA DC-007 MPF index (gain map typed 0x050000),
the ISO marker and Container XMP merged into the user's packet, followed by
the gain map JPEG. `read_jpeg` / `is_gain_map_jpeg` find and decode the map.
Also: gain map images are no longer picked as embedded thumbnails (an Ultra
HDR JPEG could show its greyscale gain map in the grid). The JPEG APP-segment
building is shared with `encode_jpeg`. Fuzzed with the other decoders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `DevelopSettings.hdr`: enabled, headroom limit (`max_ev`) and SDR-rendition
offsets (brightness, contrast, highlights, shadows, whites, clarity), with
control specs (`hdr.maxEv`, `hdr.sdr*`) and copied with the Light group.
- `ToneMap::hdr` / `display_hdr`: the SDR curve through the midtones, with
highlights lifted up to `2^max_ev`; identical to the SDR tables at peak 1.
- `OutputDepth::F32Hdr`: unclamped linear output up to the peak. The finish
stage carries the peak through highlight desaturation, gamut mapping,
encoding, curves, grain and LUT profiles; with HDR off every branch is the
SDR code path, bit for bit.
- `settings_for`: every non-HDR render (CPU and GPU) uses the SDR rendition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Uncompressed 16-bit words are read in the other byte order only when the
file's order leaves more than 1% of sampled words at or above
1 << max(bits, 14) and the other order leaves none. Measured on the CC0
DSC-R1 (raw.pixls.us 3221). Crop and white balance (SR2SubIFD) are not
addressed. RENDER_CACHE_VERSION 31 (adjust at merge).
Single-strip ORFs at 12.8 bits per pixel are 16-byte blocks: ten little-endian
12-bit samples in bytes 0..14 plus a zero pad byte. Main routed them through the
XZ-2 12-bit word path (stripes, magenta cast). New branch before it; the exactly
12.0 bpp XZ-2 branch is unchanged. Layout measured on CC0 files (log and scripts in
data/testing/retry-olympus-e300 and tools/retry-olympus-e300).
RENDER_CACHE_VERSION bumped to 30 (open PRs use up to 29; adjust at merge).
- #540's crs::Target replaces the raw flag: #597/#604's folder sync and sidecar_op and #533's
process tests pass the photo's target instead of a bool.
- #568's DRO test fits through #528's fit_look (proxies now return the clipped mask).
- #541 (French) and #564 (Ukrainian) gain the 15 keyword format strings #578-#592 added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI snapshot test and the headless resize test sent Cmd+Plus to change egui's
interface scale; with the keys handed to View > Zoom they no longer did. A
ui.zoomFactor control request (0.5-3, documented) sets the scale explicitly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A label added by another PR (Decrease Rating, Display Profile, …) no longer fails the
Ukrainian test; it shows in English until translated, as for the other languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto was a percentile rule that set Exposure about 1.4 EV higher than
Lightroom's Auto on the same photos (mean +1.81 against +0.44 EV) and
barely used Shadows: on 24 Sony raw photos its results were further from
Lightroom's Auto results (median dE76 22.0) than leaving the photo
unedited (7.9), about 18 L* too bright.
Auto is now Lightroom's shape of rule with values chosen for our results:
- Exposure = -0.58 - 0.41 * the scene's log-average luminance (EV from
middle grey, scene-linear, under the photo's white balance, so it does
not depend on the base tone curve), clamped to -2..3 EV. The slope is
Lightroom's: its Auto's chosen Exposure against our log average, r -0.92;
- Highlights -66 and Shadows +52: Lightroom's own average Auto values;
- Contrast +68 and Vibrance +30 (our scales; our base tone is flatter);
Whites, Blacks and Saturation 0.
The exposure offset, Contrast and Vibrance were fitted jointly so our
renders match Lightroom's Auto exports on the 16 photos whose starting
look Sony's DRO doesn't brighten (#568 changes the other 8). Held out one
photo at a time, each fold fitted from its own training photos only:
median dE76 6.38 on those 16 (8.28 unedited, 18.81 with the old rule),
12 of 16 closer than unedited; 7.70 on all 24 (7.92 unedited); on the 8
DRO photos 9.39 (7.00 unedited), their brighter start showing through.
Freeing Highlights and Shadows too (5 fitted values) did no better held
out (6.51) and left them poorly determined across folds.
The log average skips pixels that aren't finite (a damaged decode) through
the proxy's resampling and white balance; with no valid pixel, Exposure 0.
Values are black-box observations of Lightroom (the settings its Auto
chose and its exports), as in #514. Auto only applies when asked for, so
existing edits keep their look; HDR merges with Auto Settings use the
same rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a fully loaded test run a single queued click could arrive while
the dialog was still settling into place and miss the box. The helper
now clicks again at the box's current place until it is ticked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Canon's SensorInfo left border is not where light starts: on every corpus
body 10-36 columns left of it already hold image (EOS 6D: masked columns
0-70, border 84), and the black level was averaged over all of them. The
6D read 2591 instead of 2047 (4 % of the range), so CR2s rendered dark with
crushed shadows; 40D +141, 5DS R +131, 5D Mark II +73. The masked run is
now measured from the data: columns whose mean stays within 1/256 of the
range of the leftmost columns', minus a two-column margin.
Against Lightroom Classic (EOS 6D still life, 2000 px, ΔE00): base 7.63 ->
5.23 (p95 24.7 -> 11.3); mean over 86 edit variants abs 9.13 -> 6.19,
effect 3.09 -> 2.75.
RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ORF decoder supplies the as-shot white balance (ImageProcessing
WB_RBLevels) and no colour matrix, like ARW, NEF, RW2, RAF, CR2/CR3, PEF
and SRW, but ORF was left out of camera_preview::file_local_look and the
catalog's relative_wb, so it rendered with the neutral fallback matrix:
flat, grey and dark. It now gets the same guarded fit to its camera JPEG
and white balance relative to the as-shot look. Corpus (held-out RMS
against the fallback, all accepted): E-1 0.126 -> 0.014, E-400 0.087 ->
0.022, XZ-2 0.061 -> 0.023; compressed ORFs still open from their preview.
Against Lightroom Classic (CC0 E-1 kit, 2000 px, ΔE00): unedited render
11.25 -> 8.30; mean over 86 variants abs 12.10 -> 9.48, effect 3.44 ->
2.80. Every other kit's unedited render is byte-identical.
RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relink check counted only the photos still to relink, so when some
moved files were gone again the bar stopped short of its total. The
module docs now name the writes that stay on the UI thread after each
step (the durable save; auto-written sidecars), and a test that only
showed the work checks files again is named for that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session::sidecar_naming builds the catalog's sidecar owners (a pass over
every photo) for each photo it is asked about, so readying a synchronize
(on the UI thread) with M sidecar updates in an N-photo library cost
M·N — seconds for thousands of updates in a large library. The folder
scan had the same pattern. Session::sidecar_namings works them out once
for a list of photos; both use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each readies its files against the library as it was when it started,
so with the synchronize in the background, dropping the same folder on
the window (or Auto Import) while it ran could add the same files twice.
Imports (the review, dropped paths, Auto Import) wait while a folder is
being synchronized, and Synchronize waits for a running import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end of a run threw away execute_fn's result, so a step that went
into the catalog but couldn't be saved (a full disk) went unmentioned,
and a run stopped with Cancel, or ended by a panic in its worker, with
nothing done toasted "The library is up to date with this folder."
The toast now leads with Stopped / Stopped by an error, adds Not saved
with the reason (both logged, and the toast stays longer), and only
says "up to date" when the run finished with nothing to do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the work in the background, File › Open Library during a run sent
its remaining steps into the newly opened catalog, where the same photo
ids are other photos: removals, relinks and sidecar reads hit unrelated
photos. SyncCommit now remembers the library it was readied for
(library_identity, as the Lightroom import job does) and drops steps for
any other, saying so in the report.
An Undo (or an edit) made between two of the run's steps was merged
into the "Synchronize Folder" step, so undoing it undid that earlier,
unrelated step too. The run's steps are merged only while they are
contiguous on the history; otherwise each stays its own step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click is queued input: under a loaded test run it could land after the
stale confirm had replaced the dialog, on a box that was disabled while
scanning, so the choice the test checks was never made.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Synchronize used to run folder.synchronize on the UI thread: the import
of the new files, the existence checks and the sidecar reads all made
the window wait. Confirming now only takes the dialog's scan and hands
its SyncWork to a worker thread; the dialog closes at once, a progress
window ("Synchronizing… n / m", Cancel) shows while it runs, the steps
are committed between frames through execute_fn (auto-write, durable
save, panic guard) and the whole run is one undo step, with a toast
saying what was done. Cancel stops before the next piece; what was done
stays. A stale scan is still refused and made again. Headless `busy`
now waits for the scan and the run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SyncJob::start readies a synchronize from the session without any
file-system call; SyncWork::run does all of the file work (the import's
probes in batches, whether moved and missing files are still as the scan
found them, reading the sidecars) and hands over SyncSteps; SyncCommit
applies each to the catalog without touching the disk and finish()
makes them one undo step. The app runs the work on a worker thread;
folder.synchronize runs the same halves in turn. Progress (done/total)
and cancel go through SyncProgress, one task from start to end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sensor-clipped pixels are rebuilt at raw decode. The tone map rolls them off to
white at Neutral, so any colour they carry shows only when Highlights, a negative
Exposure or Whites darkens them. They took colour from the nearest unclipped pixel
(dark foliage, lens fringes along branches, pixels demosaiced from clipped samples),
left two clipped channels at their white-balance-scaled clip levels (red or pink),
and were neutral only in camera RGB, not after the colour model (a look fitted to
the camera's JPEG, a camera profile).
- highlight::reconstruct_with: chromaticity only from bright unclipped content away
from edges and from the clip, through a normalised push-pull pyramid; a neutral
estimate blended in where reliable content is scarce at every scale up to a
quarter of the long edge, applied only as far as the pixel lies past the nearest
near-clip unclipped pixels (so a surface still runs into clipping without a
step); clipped pairs keep the estimate's ratio at their clip floors; partly
clipped pixels fade into the clipped neutral on their way from the first channel
clipping to the last; fully clipped pixels become the colour model's neutral.
Pixels without a clipped channel are left exactly as they were.
- engine: the decode colour step as CameraColourModel, with clipped_white, which
keeps the camera's neutral for nearly singular or implausible models.
- Tests in raw (continuity along a ramp through every clip, also as lines 1 to 16
rows thick with no reliable colour, on canvases 32 to 1024 rows), engine
(pipeline renders at Highlights -100, #523's edit and Exposure -2 through the
first clip, over the same sweep; a CC0 ILCE-7RM4 corpus file added to
RAW_SAMPLES) and the GPU equivalence suite.
- Render cache version 27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import reused the review's probes for every file it had seen, even
one deleted (or rewritten) since: such a file was added as a photo of a
file that isn't there. A cached probe is now used only while the file
is there with the same size (checked in prepare_files, on the import's
worker thread; the web's files keep their probes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_sidecar_op = read and parse the packet (file I/O) + sidecar_op
(build the op from what it says). The split lets a worker thread do the
reading while the UI thread only builds and commits the op. No change in
behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a stale scan was made again, the dialog came back with the default
choices, so a ticked "Remove missing" or "Read metadata" was silently
dropped before the next click. The choices now carry over. A saved
dialog without `relink_moved` reads it as on, the engine's default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scan was current only while the folder's photo records were the very
same; the face search adding regions in the background replaced them,
so confirming could keep failing as stale while it worked through the
folder. A photo now counts as changed only when something synchronizing
depends on changed: its file, its place in the library, or what a
sidecar is compared with (regions aside).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moved-file matching only knew photos with a content hash, one per hash:
a photo taken over from a Lightroom catalog (no hash on record) that was
renamed still showed as missing with its file as new, so Remove missing
+ Import new swapped it for a bare copy; a Photo › Duplicate (hash
"<h>:dup<id>") never matched; and of two missing files with the same
content only the first was found.
Hashes now match by the content they stand for, each missing file
claiming one file; a photo with no hash matches by file size and
dimensions, only when exactly one missing file and one unclaimed file
agree. A Local record listed as new no longer carries its
"duplicate"/"existing" marks in the scan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
High Efficiency NEFs (HE / HE★, Z 8, Z 9, Z 6III, Z f) keep compression
34713 but carry a JPEG XS-based codestream (SOC + CAP, FF10 FF50) and no
maker-note 0x0096 table. They fell through to "Nikon compressed NEF
without a linearization table", so the Preview only notice gave Z f users
no hint why their raw wasn't decoded or that Lossless compressed NEFs work.
Detect the codestream before asking for the table and report "Nikon High
Efficiency NEF" with the workaround. Add a synthetic regression test, CC0
Z f HE + Lossless samples to the corpus (raw.pixls.us) with a corpus test,
and list HE NEF among the format gaps in parity.md and ROADMAP.md.
No decoder: the format has no public specification and the existing
decoders (LibRaw, rawler) are LGPL and derived from Nikon's binaries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011bbmJTxYJyYqKfNH8uXRC6
The docs claimed only a sidecar written here before auto-write was
turned off could be mistaken for an update; in fact the library only
knows when a photo came in or was last developed, so any later rating,
label or metadata change made here can be. Says so, with the headless
fixed clock, the sidecar naming fallback and the walk's limits; the
parity row and what's new also cover relinking moved files, Local
files, offline folders and the `disk` guard. Adds the test that a
sidecar the library auto-wrote is never an update.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dialog's "Scanning… n / m" counted only the files probed, so it
could sit at m / m while the library's photos were still being checked.
SyncProgress counts both halves (its `files.cancel` still stops the
whole scan).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scan went stale on any catalog change anywhere (a background import,
a rating elsewhere), and folder.synchronize then scanned again inline:
on the UI thread, and acting on results the person never saw. A scan
now stays current while the folder's own photos are the same records
(Session::take_folder_changes compares them by identity). The dialog
asks with `scanned: true`, which refuses a stale scan instead of
redoing it; the dialog then stays open and scans again in the
background. Agents that just call folder.synchronize still get a fresh
scan.
The app dropped session.folder_changes every frame no dialog was open,
so an agent's folder.scanChanges never reached its folder.synchronize
in the running app; it now only lets go of a scan its dialog made.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Highlight reconstruction rebuilds clipped channels from the chromaticity of
unclipped pixels nearby. Three changes to that colour:
- an unclipped pixel counts by its brightness ((m / 0.8 clip)^4, at most 1),
filled coarse to fine by push-pull, so dark wires across a bright cloud no
longer tint it (the iPhone frame of #548 turned cyan at Highlights -100);
- pixels within 6 px of clipping (partly demosaiced from clipped samples, 25 %
greener than the sky on that frame) give no colour unless nothing else does;
- the rebuilt colour is drawn toward neutral at its brightest channel as more
channels clip ((n - 1 + s) / 2, s rising as the next channel nears its clip).
Binned previews keep each channel the mean of its block and mark blocks with a
clipped sample in a mask (develop_binned_masked, reconstruct_masked), instead
of taking the block maximum: one clipped specular sample no longer becomes a
green speckle at 500 px (HUAWEI PRA-LX1, WAS-LX1A). The denoised twin uses the
same mask. Camera-colour fitting keeps the old binning.
RENDER_CACHE_VERSION 26 (adjust at merge).
The steps to merge were counted from the undo history's length, which
stops growing at its 1000-step limit: with a full history nothing merged,
or only the last part was relabelled "Synchronize Folder" while the
import stayed behind it. Session now counts committed steps
(Session::commits) and Synchronize merges by that count.
Once the import is in, a later part that fails is listed in `failed`
instead of aborting with the import left as a separate step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file only browsed in Local was counted as already in the library and
silently dropped; it is now new, and synchronizing brings it into the
library, as an import does.
A photo whose file was renamed or moved within the folder showed as
missing while its file counted as a skipped duplicate, so "Remove
missing" sent the photo and its edits to Recently Deleted and imported
nothing. The scan now matches a missing photo's content hash among the
folder's files and lists it as moved; synchronizing relinks it (on by
default, "Relink photos whose file was renamed or moved"), in the same
undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A folder that isn't there (moved whole, or on a disk that isn't
connected) used to scan as "every photo missing", one tick away from
moving a whole disk to Recently Deleted. The scan (on its worker, so an
asleep share never stalls the UI) now reports it as offline and both
commands refuse it; the dialog says so. A whole disk or a folder holding
disks needs `disk: true`, as Remove Disk from Library does; the disk rows
pass it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scan_with ran the import scan beside the photo checks in a thread scope
on every target; on wasm32 a thread can't be spawned and Scope::spawn
panics, so Synchronize Folder… crashed the web build. There the two run
one after the other, like the other file work in the engine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right-click a folder (or a disk other than the startup disk) in the
sidebar's Folders section ▸ Synchronize Folder…: the dialog opens at
once and scans on a worker thread (with progress), then offers Import
new photos / Remove missing photos / Read metadata updates with their
counts (Lightroom Classic's defaults: import only). Synchronize runs
folder.synchronize on exactly the scan shown; closing the dialog
cancels a running scan and lets go of the result. New strings are
translated in every shipped language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scan waits on the disk, not the CPU, so its two halves now run at
once: the look for new files (which probes on its own threads) beside
the checks of the library's photos (missing file, newer sidecar), which
run on several threads like the import probes do. A cancelled scan stops
checking. Results keep the photos' order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
folder.scanChanges keeps its result in the session; folder.synchronize
uses it (once) while it is of the same folder and the catalog has not
changed since, so a scan made on a worker thread is not repeated on the
UI thread and files that arrive after the dialog was shown are left for
the next synchronize. Otherwise it scans again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scan compares a library folder (and the folders inside it) with the
disk without changing anything: new files (the import scan's duplicate
rules), missing photos (the Missing Photos rule) and metadata updates
(XMP sidecars saved after the photo came in or was last edited here
that say something the library doesn't). It runs without the session
(SyncInput + scan_with) so the app can scan on a worker thread.
folder.synchronize imports the new files in place and, when asked,
moves missing photos to Recently Deleted and reads the updated
sidecars, all as one "Synchronize Folder" undo step. Only folders the
library holds photos in are accepted, never the startup disk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rows below the split row use the existing lossy Huffman path; from the split
row on every pixel is a (bits - 4)-bit word: a complete prefix code for the
difference category followed by the top bits of the T.81 extra bits, dequantised
to the centre of the kept bin, with the usual two-left predictor and an
unclamped running value. The rule was derived by black-box analysis of the 11
CC0 samples (all decode to the same samples as the analysis decoder).
The decoder checks that the fixed-rate section ends within the last byte of the
strip (whole rows plus 0..7 padding bits) and otherwise keeps refusing the file
(preview only), so a file that does not follow the rule is never decoded wrongly.
RENDER_CACHE_VERSION 28 (main is 22, open PRs use 23-27): adjust at merge.
LRC-LIB-LIBFOLDERS lists folder.label and the Set Color Label menu, with
its limits (no filter or sort by folder label; labels don't follow a
folder moved outside LightCraft). What's new describes the feature and
catalog format v4. The roadmap notes folder records as the first step
towards Classic's folder-id model and when the next step would pay off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes. folder_records_follow now drops records left at the
destination even when the moved folder carries none, so an unlabelled
folder renamed onto an old path no longer inherits that path's label.
folder.label {label: none} works once the folder's photos are gone (a
label can no longer get stuck); setting a label still needs photos.
Adds tests for both, undo/redo of a labelled move, and labels replayed
from the journal without a snapshot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
heic-rs reconstructs iPhone HEVC tiles bit-exactly (checked against libde265), but
its colour stage was 2-27 code values off libheif on every iPhone photo: it ignores
the HEVC VUI and assumes BT.709 limited range. lightcraft-heif now converts YCbCr
itself (nclx, else VUI, else H.265 defaults; nearest chroma, bilinear for odd clap
offsets, as libheif), inherits tile ICC/nclx on grids, follows libheif's imir axis
and clap rounding, and outputs 16-bit RGB. Measured on 30 real and synthetic
files: within 1 code value of libheif (pillow-heif 1.8 / libheif 1.23).
Also: HEIF header reader for import (no pixel decode), embedded thumbnail for small
decodes, nclx/VUI colour hint (shared CICP mapping with PNG), clear 4:2:2/4:4:4
Unsupported error, Format::not_decodable as the one source of "why not" (import
reports "HEIC/HEIF support isn't included in this build"), `.heif` imports.
Release packaging and Nix build with the heif feature, as whats-new promised.
lightcraft_heif::testdata writes lossless (PCM) synthetic HEICs for the tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right-click a folder or disk row in the sidebar's Folders section ▸
Set Color Label ▸ a colour or None (folder.label). A labelled row shows
the label's dot before its count, and its spoken name includes the label.
The colour items are shared with the photo menu (grid::label_items):
each is now a control-channel widget (photoLabel:/folderLabel:<colour>),
and choosing None closes the photo menu like choosing a colour does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
folder.label {path, label} gives a library folder a colour label (or
takes it off) as one undo step; only folders the library holds photos in
are accepted. library.folders reports each row's label. folder.rename and
folder.move commit the folder records with the photos' relinks, so the
labels of the folder and the folders inside it go along and undo/redo
carry them back and forth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Library folders can now carry what the library knows about them beyond
their photos: FolderRecord (for now a colour label), kept in
Catalog.folder_records under the folder's identity (folder_key) and
changed through Op::SetFolderRecord, one undo step. An empty record is
never kept; a path that names no folder is refused.
folder_records_follow builds the ops that carry a folder's records and
those of its subfolders to its new place on rename or move (dropping
stale records left at the destination), so they can be committed with
the photos' relinks. FolderNode reports the label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ and SP-570UZ files say
uncompressed 12-bit with many strips whose byte counts sum to exactly
width x height x 1.5. main refused them because the 12-bit branch wanted a
single strip, so they fell to "Olympus compressed ORF" and opened
preview-only. The samples are packed MSB-first, the strips hold two fields
(even output rows, then odd ones) with one gap at the seam, and the last row
of the second field sits right after the last strip. Accepted only when all of
that holds exactly. CFA comes from the Exif tag, white balance from maker-note
entries 0x1017/0x1018, black 0. RENDER_CACHE_VERSION 27 (adjust at merge).
A keyword with a line break in its name split the export toast in two.
Names left out now show escaped (Line\nbreak).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export now leaves out only what it must, and its counts are exact:
- A name starting with a byte-order mark is left out. On the first
line, the reader would take the mark for the file's own.
- A synonym in braces is written, since it reads back. An empty
synonym, or one that repeats another in a different case, is
skipped quietly, as the reader would drop it anyway.
- keyword.export counts and checks for Capture One the keywords it
actually wrote, now returned with the text. It used to guess from
the lines, so a name starting with a brace was missed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What's New now says that an unreadable list names its line or asks for
UTF-8, and that export names any keyword it had to leave out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Se añadieron 1 palabra clave" and "1 Stichwort/Stichwörter" didn't read
right. The counts now follow a label ("Palabras clave añadidas: 1",
"Stichwörter hinzugefügt: 1"), which reads right for any number, as the
Portuguese toasts already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list that couldn't be written, for example to a folder that's gone,
used to fail silently from the menu. Its error now shows as a toast, as
Import Keywords…'s already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like the other menu commands, Auto-Tag from Tracklog stamped its toast
with a throwaway context whose clock starts at zero. A while into a
session, the toast had already expired by the time it was drawn. It now
uses the app's context. Lightroom catalog import gets the app's context
too, so its background work repaints the window it belongs to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list saved as UTF-16, as Windows' Notepad can, now says to save it as
UTF-8 instead of only "not UTF-8 text". Text passed to keyword.import is
capped at 16 MB, like a file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list format can't hold some names. A name in brackets or braces
would read back as a left-out keyword or a synonym. A line break or a
leading space would split it or break the indentation. A keyword nested
deeper than 64 levels won't read back either. These keywords, and those
inside them, are now left out of the file. keyword.export names them as
`unwritable`, and the toast says which.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading a list looked each line up in all the lines before it, and
importing looked each keyword up among every photo's keywords: a
40,000-line list into a 20,000-photo library took nearly two minutes.
Both now use an index built once, and the same import takes about a
second.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Classic Mac line ends (a lone carriage return) are lines: a whole
file used to become one keyword holding them.
- Indenting with spaces is refused at its line: the levels are tabs,
and space-indented files (hand edits, editors that turn tabs into
spaces) used to come in flat without a word.
- Control characters in a name, and "|" in a synonym, are refused.
- A keyword repeated in another case keeps its first spelling for the
keywords inside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity row covers File ▸ Import / Export Keywords… and the apps
that share the format, and lists what stays open (IPTC keywords in
exported files, hierarchy from other apps' XMP fields); What's New
explains it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The File menu (beside the presets) imports a keyword list file
(Lightroom Classic's, Capture One's, Photo Supreme's .utf8) and exports
LightCraft's, with native file dialogs off the UI thread like the
other file commands; the desktop app gives the synchronous ones too.
Import says how many keywords it added and how many gained synonyms;
export says how many it wrote and names the keywords Capture One won't
import (; , < or > in a name).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UI's menu commands made their toasts with a fresh egui context,
whose clock starts at 0, while the app checks a toast's end against its
own clock: once the app had run a few seconds, every such toast (Auto
Advance On, presets imported or exported, the keyword painter, …) was
over before it was drawn. They use the app's context now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `keyword.export {path?}` writes the keyword list file (Lightroom
Classic's format, which Capture One, Photo Supreme and Bridge read),
or gives its text, and names the keywords Capture One's importer
refuses (none of ; , < > may be in a list it reads).
- `keyword.import {path | text}` reads one (Photo Supreme's Formatted
Vocabulary File too) as one undo step → {added, updated}. A line it
can't read is named and nothing is taken; a file larger than 16 MB or
not UTF-8 is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photo Supreme's Formatted Vocabulary File (Data ▸ Export to Formatted
Vocabulary File) turns out to be Lightroom Classic's keyword list
format: plain text with Windows line ends, a top-level category in
square brackets, keywords a tab further in at each level. A test with
a file of that shape pins it down; a real export of 118 keywords read
and wrote back line for line. (Photo Supreme reads Lightroom keyword
files too: Data ▸ Import from Lightroom Keywords file.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A smart preview keeps the colour and tone curves the camera-look fit gave it, so a
fit change (#499, #528, #583) left existing previews with the old look. They now
record LOOK_VERSION in the header; an older one is rebuilt from its original when the
library is opened or Build Smart Previews runs, and kept as it is while the original
is offline.
Lightroom Classic's keyword list files (Metadata ▸ Import / Export
Keywords): a keyword a line, those inside it one tab further in, a
synonym in braces on a line of its own under its keyword, a keyword
left out of export in square brackets.
- `Catalog::keyword_list_text` writes the whole tree that way, by name.
- `keywords::parse_keyword_list` reads one: Windows line ends, a
byte-order mark and blank lines are fine; a line it can't read (two
levels down at once, a synonym of nothing, a "|" in a name, nesting
past 64 levels: a file is input) is an error naming its line.
- `Catalog::import_keywords_ops` adds the keywords the library doesn't
have, with the file's attributes and in the library's spelling of
the levels it has; those it has keep their attributes and gain the
file's synonyms. One undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Set name" (Edit Keyword Set, Save Keyword Set) read 标签集名称 /
標籤集名稱, the colour label sets' term; it is 关键字集名称 /
關鍵字集名稱, as the rest of the dialog says. "Edit Set…" says what it
edits: 编辑关键字集… / 編輯關鍵字集….
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Edit Set… on Recent Keywords saves a new set (`new: true`): a name
another set has is refused, where it used to replace that set.
- The dialog checks names before saving and says why in the user's
language ("There is a keyword set “…” already"; Recent Keywords is
built in), instead of showing the engine's message.
- A set deleted while its dialog was open is saved again, as a new set,
rather than refused with no way out but Cancel.
- Save as a new set (Lightroom Classic's Save as New Preset) keeps the
edited set and saves the edits under the new name.
Russian gets "Recent Keywords", which showed in English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving compared set names by any letter's case while using, deleting
and the current set compared ASCII case only: "été" saved over "ÉTÉ"
but couldn't use or delete it. Every set command (and Recent Keywords)
now compares names one way, as keywords are compared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving Recent Keywords as a set under the name of an existing one
(Edit Set… on Recent Keywords) replaced that set's keywords without a
word, while renaming onto a taken name was refused. `keyword.saveSet`
takes `new: true` for a set that must be new, and refuses a name
another set has.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`keyword.saveSet {name, replace}` without `keywords` took the current
set's keywords, so renaming a set that wasn't current gave it another
set's nine. The keywords default to the renamed set's own now (the
current nine only for a new set), and an empty `replace` is no rename.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nine fields took their width from what the dialog had while it
found its size: the first two columns came out squeezed ("ceremony"
showed "ceren") and the third wide. They are now equal columns of a
set width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Keyword Set menu gets Edit Set… (Lightroom Classic's Edit Keyword
Set): the set's name and its nine keywords, one field per ⌥ key, typed
in place. Editing Recent Keywords and naming them saves a new set and
makes it current; editing a named set renames it in its place. An
empty field is an empty slot, shown as an idle button so the others
keep their keys. Return in any field saves; a set without a name says
so and the dialog stays open. Russian gets "Set name", and the label
check covers the new labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For Edit Keyword Set (Lightroom Classic's), `keyword.saveSet`:
- keeps each slot in its place: an empty one ("") is an empty slot, so
the keywords after it keep their ⌥ keys (empty ones were dropped and
the rest moved up); trailing empty slots go, nine at most;
- holds each keyword once, whatever its case: a second one leaves its
slot empty;
- with `replace`, renames that set in its place among the sets and
keeps it current; a name another set has is refused.
⌥ on an empty slot does nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A DNG can state a white level the sensor never reaches (an iPhone 15 Pro Max Bayer DNG states 4095 but clips at 3567), so clipped highlights were not recognised and white balance tinted them magenta. On decode, samples that form a plateau clearly below the white level, with nothing above it, are raised to the white level. RENDER_CACHE_VERSION 25 (needs adjusting at merge).
German says "Es werden keine Stichwörter exportiert", the natural word
order; Japanese names the view "書き出されるキーワード" (the keywords
that will be exported) rather than the vaguer "書き出される内容".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic's Keywording box has three views; the box had two.
The switch now offers Keywords, & Containing (the selection's keywords
with the keywords containing them, flat and read only) and Will Export,
the two read-only views cached per selection. The label check covers
the box's other labels passed through variables too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The box emptied itself on Esc, on top of the shared field's own undo of
the edit: text left there by an earlier edit (ended by clicking away)
went too. Esc now gives back what the box held before the edit, as
every text field does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`chips_stay_on_one_line` held by construction once chips were drawn at
a fixed height with their × on their own line. It now checks that every
chip is on one line inside the panel and that none overlaps another,
with a keyword longer than the panel among them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drawn as one widget, a chip and its × lost the names a button gave
them: screen readers met unlabeled buttons. The chip is now named after
its whole keyword (even when cut short on screen) and the × "Remove
“…”". Both answer under ids made from their keyword rather than their
place in the row, so a chip's open menu stays with its keyword when the
chips change underneath it (an undo, a sync).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A photo listed twice in the selection counted twice, and an id the
library doesn't have counted in "of": the chips, Will Export and the
Keyword List's tick boxes now count the distinct selected photos.
- The chips looked each keyword's spelling up by scanning every photo
(178 ms with 20,000 photos and 2,000 keywords selected, at every
change while selected): they now take the keyword list's spelling
(`Catalog::listed_path`) or the first one seen, 26 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chip too long for its row was cut with the file-path shortener, which
keeps the start: "Places › Portugal ›…" lost the keyword's own name and
the asterisk of a partial one. `chip_label` now cuts from the front
("…Lisbon › Belém tower *"). A chip also had two tooltips (its whole
name, and how many selected photos have it); it has one with both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`keyword.toggleFromSet` checked whether every target photo had the
keyword by ASCII case, while the set's buttons and the rest of the
keyword code match any letter's case: with "ÄRZTE" and "ärzte" on the
photos the button showed on, but ⌥N added the keyword (a no-op) instead
of taking it off, so it could never be toggled off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keyword commands act on the selected photos, or on the active photo
when the selection is empty (`Session::targets`), but the Keywording
box, the keyword set's buttons, the suggestions and the Keyword List's
tick boxes read the selection only: with just an active photo the box
showed nothing while typed keywords went to it. They now all use the
photos the commands use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyword rows of the parity tracker describe the box and the chip
menu; What's New explains the chips, Will Export and what follows the
selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chip was a frame laid out in what was left of its line: one that
didn't fit wrapped its own text, down to a letter a line, and pushed
its × to the next line. A chip is now one widget measured before it is
placed (its name cut short to fit the row), so the row moves it whole
to the next line; the Will Export names alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suggestions were worked out from the active photo's keywords: a keyword
it had wasn't suggested though other selected photos lacked it. They
now leave out only the keywords every selected photo has, and build on
those for keywords used with them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Keyword to paint" gets the Cut / Copy / Paste / Select All menu and
Esc, and Return starts painting as the Paint button does. It stays
fixed while painting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic's Keywording box can show the keywords that will be
exported rather than those entered. A Keywords / Will Export switch
under the box now does the same for the selection: the names exported
files carry as the keyword tag options say (a keyword left out of
export goes, parents and synonyms come), one only some of the photos
carry marked with an asterisk. Read only; cached per selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nine buttons showed "on" when the active photo had the keyword,
while their ⌥1–⌥9 toggle acts on every selected photo. A button is now
on when every selected photo has its keyword, and marked with an
asterisk when only some do, as the chips are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It gets the Cut / Copy / Paste / Select All menu (paste a list of
keywords from elsewhere), Return gives the keywords to every selected
photo as before, and Esc gives up what was typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chip menu offered Delete Keyword, which takes the keyword off every
photo in the library, beside "Remove from Photo". It now acts on the
selected photos only: Add to All Selected Photos (for a keyword only
some have), Remove from Selected Photos, Show Photos with Keyword, and
Edit Keyword Tag… (the Keyword List's dialog, for renaming too).
Deleting a keyword from the library is the Keyword List's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic 15.6 imports an XMP packet's channel curves only when it holds
ToneCurvePV2012 and all three of ToneCurvePV2012Red/Green/Blue; any partial set
(master + one or two channels, channels without the master) imports without
channel curves. #416 read them whenever the master curve was present.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chips at the top of the Keywords panel showed the active photo's
keywords, while adding and removing applied to every selected photo:
with several selected they misreported what the selection had. They
are now the keywords of every selected photo, each once whatever its
case (`panels::keywording`, cached per selection), and one only some
of them have is marked with an asterisk ("On 1 of 2 selected photos").
Only the × removes a keyword, from every selected photo: a click on
the chip itself used to remove it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #535 (items 4 and 5).
- Pre-2017 bodies without Sony crop tags shot in the camera's 16:9 mode now get
the 16:9 crop of their camera JPEG: width from FullImageSize, anchored left as
before (drops the ILCE-7SM2's 32 padding columns); height from FullImageSize
(ILCE-7SM2) or from an Exif image size of the same width (DSLR-A580, whose
FullImageSize still says 3:2); centred vertically at an even offset, as
measured by registering each camera JPEG on its raw. The aspect now matches
the JPEG, so the camera-look fit runs: ILCE-7SM2 dE 15.2 -> 4.8,
DSLR-A580 dE 26.7 -> 3.9 (vs main 88ea1a8).
- The DSC-R1's SR2 (maker-note FileFormat 1 0 0 0) stores encrypted samples and
decoded to noise; it is now Unsupported in full and header mode and imports
preview-only with that reason.
- Only these 3 of 140 Sony decodes change; render cache version 26 (23-25 are
taken by #568, #542, #575).
- Tests: crop helper (3:2 unchanged, both 16:9 sources, even offsets, hostile
sizes), synthetic Sony file with FileFormat SR2 vs ARW, corpus test of the
two crops and the SR2 refusal; three samples added to the corpus list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity row said Person shapes exported files: it is stored only.
It now also covers the list without a selection, the default parent's
reach (typed keywords too) and keywords' hierarchy on import. The
synonyms' doc no longer says search finds them, which it doesn't yet.
What's New follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With nothing selected a tick box still offered "Add to Selected
Photos", and while a filter holds the levels open the triangle lit up
on hover though a click does nothing. Neither does now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toast said every dropped photo got the keyword, those that had it
already included. It now counts the photos that didn't have it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic puts new keywords typed for photos inside the keyword
marked Put New Keywords Inside This Keyword; only + did here. The
Keywords panel's box now does too (`Catalog::typed_keyword`): a new
name goes inside the default parent, while a keyword the library has,
or a path typed whole, stays as typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Russian had no "Person": the option showed in English (it reaches
`tr` through a variable). A test now checks that every label the
Keyword List passes that way is in every language's file.
- The merge confirmation's button said "Merge", which some languages
translate as merging photos (HDR, panorama): it says "Merge Keywords".
- Traditional Chinese uses 匯出 for Export, as everywhere else in the
file, not 轉存; "Export Containing Keywords" means the parent
keywords in both Chinese files (上層 / 上级), not "included" ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Keyword List ended a keyword drag only while it was drawn: with
another panel opened mid-drag, the drag lingered until the list came
back. `keyword_list::drag_feedback` now runs every frame, like the
album drag's: the drag ends with the button's release wherever it is,
or with Esc. A press on a tick box that moves no longer picks the
keyword up. The test that a keyword can't be dropped inside itself
now checks that no move was even tried.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a rename (Edit Keyword Tag), a drag or a merge made in the list,
the picked keyword still named the old path (the highlight went and −
was greyed out for a keyword that was there) and the renamed levels
closed. The pick and the open levels now follow the keyword, and the
levels containing it open, so a keyword dropped into another stays in
sight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #535 (item 1). Stacked on #542 (SR2 black levels); supersedes its
positional keystream tables.
- White balance is WB_RGGBLevels (0x7313), the gains the camera applied:
the raw IFD's plain copy when present, else the copy in the encrypted
SR2SubIFD, which every ARW from the DSLR-A200 on carries (identical to
the plain one on all 57 CC0 files that have both). The maker note's
Tag2010 gains stay only as a last resort.
- 14 bodies had no white balance and rendered green whenever the camera
look was rejected (A450 ... A700, NEX-3/5/C3, SLT-A33/A35/A55,
ILCE-6001, UMC-R10C). On others Tag2010 missed the applied gains by up
to 100 % (ILCE-3500 at 5600 K: R -31 / B +101 % against the camera
JPEG's neutral pixels, now +4 / +0 %; ILCE-7S on Shade -9 / +15 ->
-1 / +1 %; SLT-A57 -25 / +26 -> -6 / -3 %).
- New vendor/sr2.rs decrypts the whole SR2SubIFD, found black-box:
exiftool -v5's decrypted dump as known plaintext gives the keystream
(key 11 22 33 44, the same at every position in 126 CC0 ARWs from 97
bodies, 15 layouts); an exhaustive search over lag pairs found
k[n] = k[n-63] ^ k[n-127] on 32-bit words, so the first 127 words
generate the rest, matching all 126 files (5.2 MB of keystream).
- The SR2 black level (0x7310) is now read through the same decryptor,
replacing #542's head and per-position keystream tables: one SR2
decryption path. Identical results on all 131 Sony files (every
value #542 decoded; the 51 it could not are the files' plain 0x7310).
Unit tests pin the keystream against answers recorded without the
generator: every byte #542 and #148 had recovered, the SHA-256 of the
whole 62112-byte keystream recovered from the CC0 DSLR-A700 sample, and
the encrypted WB_RGGBLevels of four CC0 files (one per layout) with the
levels ExifTool decrypts.
- Sweep (4f350c4 -> this): gains equal ExifTool's decrypted
WB_RGGBLevels on all 100 decoded CC0 CFA files; on 83 files with
>= 200 JPEG-neutral pixels, within 10 % of the camera's applied gains
71 -> 83, over 10 % 5 -> 0. Accepted fits unchanged (max 0.15 dE);
forced fallback median dE 18.50 -> 18.06, neutral-area cast on the 22
changed files 8.8 -> 1.2.
- Corpus test for five CC0 samples (A500, A33, A700, ILCE-3500 5600 K,
ILCE-7S Shade; three added to RAW_SAMPLES); the pre-2017 test's gains
move to WB_RGGBLevels (RX100 III, RX100, ILCE-7RM2: within 2.6 % of
the old Tag2010 values in Auto). Synthetic tests for decryption,
directory parsing, source precedence, black level through the full
DNGPrivateData chain, and hostile input; #542's layout tests (incl.
the A700 trap) now run through the decryptor.
- Render cache version 25.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In Create / Edit Keyword Tag only the name field answered Return: typed
synonyms followed by Return did nothing. Return in Synonyms now creates
(or saves) the keyword too, as a dialog's default button does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no photo active the whole right panel said "No photo selected",
so the library's Keyword List disappeared: keywords couldn't be made
or organized before choosing a photo. The Keywords panel now shows the
list without a selection (its tick boxes wait for one), under a note
that selecting photos gives them keywords.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Keyword List took a drop wherever the pointer was inside a row's or
the title's rectangle, even where the panel had scrolled it out of
sight: a keyword released over the app's top bar, where the list's
title was hidden, went to the top level. Drops now need the pointer on
the visible part of the panel too; photos dropped from the grid alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of #535 (items 2 and 3).
- SR2SubIFD black level: decrypt the directory's first entry (always the
black level, 0x7310) to find where its value sits, and decrypt it with the
keystream known for that position (1638, 2166, 2418, 2510). The fixed read at
2510 gave 354-365 on the DSLR-A450/A500/A550 and 975 on the DSLR-A700, where
ExifTool and the data floor say 512 (A700 render: dE 20.1 -> 8.8,
dL* -9.8 -> 0.0). Keystreams recovered black-box (plain 0x7310 and exiftool -v3
as known plaintext), consistent over 115 ARWs from 97 bodies, 15 layouts.
- Downsized lossless M/S ARWs (linear YCbCr): black 1024 and white 16895, i.e.
the recorded 512 plus 512, measured against same-scene full-size codings on
12 files from 4 bodies (offset 1015-1052, scale 0.98-1.02). The 512 pedestal
lifted shadows by ~3 % of white; M/S renders now match their full-size twins
(7RM5 M dE 6.7 -> 3.1).
- Only these 16 of 140 Sony files change; render cache version 24.
- Tests: synthetic SR2 blocks for every known layout (incl. the A700 trap),
YCbCr black/white; corpus test of black levels against the data floor;
A500 and A700 samples added to the corpus list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every row worked out its tick box from every selected photo's keywords,
every frame: with 20,000 photos selected a frame took seconds. `Ticks`
now counts each keyword over the selection once, cached by library
revision and selection, and each row looks its count up: 2.8 ms a
frame for 20,024 selected photos. The check that the picked keyword
still exists reads the cached tree instead of scanning every photo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fitted with `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131,
September 2025 to September 2026, Standard creative style) and their
in-camera JPEGs; aggregate colour statistics only.
Without it, 8 of the 131 photos fail the fit gates and open with the
neutral matrix (dE76 6-26 against the camera JPEG). On 26 withheld photos
(profile fitted to the other 105) mean dE76 goes 4.07 -> 3.29 and both
withheld rejections are accepted (26.0 -> 6.1, 6.2 -> 5.5); the 24
withheld photos whose own fit was accepted are about unchanged (12
improve, mean 3.06 -> 3.08, worst regression 1.8). With the final profile
all 131 are accepted. The nine public raw.pixls.us ILCE-7CR codings stay
accepted (mean 1.59 -> 1.99).
A corpus test checks the public lossless-L sample's fit keeps exactly the
built-in profile's matrix and table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename, merge and delete answered `changed` with every change they
made, so with the keyword list it no longer meant photos (renaming a
keyword no photo has said 2). `changed` is photos again, and `listed`
counts the keyword list's changes (what Purge reports).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The YCbCr M coding's pedestal is corrected separately (#535, item 3), so
this test no longer depends on which change lands first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three CC0 raw.pixls.us files of one ILCE-7CR (61 MP) scene: lossless
compressed L and M (subsampled YCbCr) and compressed ARW2. A raw corpus
test pins model, colour-filter layout, black level, white level, as-shot
gains and default crop, and checks that the lossless and compressed
mosaics agree inside the crop (green block correlation 0.9976). An engine
corpus test checks each gets a fitted camera look, not the neutral
fallback. All nine public codings decode and fit (mean dE76 1.59 against
their camera JPEGs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suggestions came from the photos' keywords only, so a keyword created
in the Keyword List was never offered while typing. Those only the list
has are suggested too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Editing a keyword without changing it listed it (when it wasn't) and
made an undo step. The attributes are now set only when they change
something: a keyword only photos have, renamed with the default
attributes, stays unlisted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating "TRAVEL|Rome" next to "travel|italy" listed it as typed, and
the tree, which takes a level's spelling from the list first, showed
"TRAVEL". A new keyword now takes the library's spelling of each level
it shares with a keyword there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`photo.setMeta` stored keywords as given: " beach " showed in the tree
as "beach" but couldn't be found there (Edit, Move, Delete said there
was no such keyword), and duplicates were caught by ASCII case only.
Keywords are now stored cleaned (`a | b ` is `a|b`, empty ones are
dropped), each once whatever the case of any letter, and removing one
matches it the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exports now write names to `dc:subject` and paths to
`lr:hierarchicalSubject`, as Lightroom Classic does, but import and
sidecars read only `dc:subject`: an exported `travel|Italy|Rome` came
back as three keywords, Rome, Italy and travel. They now take the
paths of `lr:hierarchicalSubject` and the names of `dc:subject` that
aren't levels of those paths (`keywords::from_file`); without paths,
the names as they are, so LightCraft's own sidecars read as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`keyword.move` worked out where the keyword landed from the parent's
spelling in the library, and fell back to the top level when the
parent didn't exist yet, while the catalog made that parent: the
keyword filter stayed on the old path and the grid showed nothing. It
now lands the filter where the catalog puts the keyword.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Put New Keywords Inside This Keyword" was a session preference that
followed renames by hand: undo and redo never moved it back, so after
undoing a rename it named a keyword that no longer existed and the
next new keyword silently made that parent again. It is now a mark on
the keyword in the catalog's keyword list (`new_keywords_inside`, one
keyword at most): it moves with its keyword (into a merge too), goes
with it, comes back with undo and redo, and is kept with the library.
Setting it is one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging `a|b` and `a|c` into `a` moved the listings one at a time: the
`a|b|c` listing landed on `a|c` before that one left for `a`, merged
into it, and its attributes were lost. Every moving listing now leaves
first, then each lands. Each keyword also moves by the closest keyword
merged (`a|b` before `a`), so the order they are given in doesn't
change the result; the keyword filter follows the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tree and the counts leave out photos in Recently Deleted (and those
only browsed), but `keyword_path`, `has_keyword` and purging read every
photo: a keyword only a deleted photo had couldn't be created again,
wasn't purged, and could lend its spelling. They now read library
photos only, as the tree does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The LRC-LIB-KEYWORDS row lists the Keyword List, its commands and what
isn't there yet; LR-LIB-KEYWORD no longer says keywords can't be
dragged. What's New describes the list, its dialogs, drag and drop and
the export options.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the Keyword List:
- dragging a keyword onto another nests it there, with the keywords
below it and its photos; dropping it on the list's title (which
says "Drop here for the top level" during the drag) takes it to the
top level;
- a keyword is never outlined, nor dropped, inside itself or one of
its children;
- where a keyword of its name is already, a dialog asks before merging
the two;
- photos dragged from the grid onto a keyword get it.
The dragged keyword's name follows the pointer; the drag ends with the
button's release, wherever it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Right-clicking a keyword offers Create Keyword Tag Inside it, Edit
Keyword Tag, Put New Keywords Inside This Keyword (the list marks that
keyword with a dot), Add to / Remove from Selected Photos, Show Photos
with Keyword, Purge Unused Keywords and Delete Keyword.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Events" of a listed "Events|Weddings" is in the tree, but
`has_keyword` and `keyword_path` only looked at photos and the listing
itself: it couldn't be renamed, moved or made the default parent. They
now find a keyword through the listed keywords below it as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The + button opens Create Keyword Tag: a name, synonyms, "Put inside"
the keyword picked in the list (or the default parent), "Add to
Selected Photos", and the keyword tag options (Include on Export,
Export Containing Keywords, Export Synonyms, Person). Return creates
it; a name that is taken says so and keeps the dialog open.
- Double-clicking a keyword opens Edit Keyword Tag with its name and
attributes; a new name and options apply in one undo step.
- The − button deletes the picked keyword after saying how many photos
lose it.
The name takes the focus once when the dialog opens: the one-field
dialogs' `dialog_field` takes it back every frame, which would steal it
from Synonyms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reuse the landed current-view deletion selection logic for photo.deletePermanently, retaining survivors or choosing the next neighbour and the previous at the end. Preserve catalog-only deletion and existing undo/persistence behavior.
Refs #441, #451, #545.
Previews were always sRGB, which wide-gamut monitors show oversaturated.
Settings > Display > Choose Profile... (and the UI command
app.displayProfile) now shows photos through the monitor's ICC profile,
matrix/TRC or LUT-based.
- codecs: DisplayProfile (moxcms): the display's primaries (colorant tags,
or measured through the A2B tables of a LUT-only profile), the proxy ->
device correction (per-channel curve tables for matrix/TRC profiles, a
CMS transform for LUT profiles) and sRGB <-> display transforms.
- pipeline: DisplaySpace / RenderRequest::display: the per-pixel stage
renders into the display's primaries and gamut maps into its gamut
(CPU and GPU: only to_out / out_luma change). Soft proofing's display
gamut warning uses the display's gamut.
- engine: RenderJob::with_display; display::present converts sRGB
results (thumbnails, cached previews, embedded JPEGs). Histograms and
the preview caches stay sRGB; exports are unchanged.
- ui: the loupe, Before, Compare, hover and second window render for the
display; everything else is converted on the render workers. Settings >
Display tab, ui.json setting, file picker opening in the system's ICC
folder.
Tests: profile parsing and transforms (sRGB, P3, gamma 2.2, a synthetic
LUT-only profile, corrupted files), display renders equal 8-bit renders
into the matching output space, sampled histograms, and a headless UI test
of the whole flow. docs/display-profiles.md describes the design.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj
The Keywords panel ends with Lightroom Classic's Keyword List: every
keyword of the library, those without photos too, as a tree with photo
counts (`panels::keyword_list`).
- A filter box keeps the keywords whose name holds the text, with the
keywords containing them opened down to them.
- The triangle opens a level (kept with the UI state).
- The tick box says whether every, some (a dash) or none of the
selected photos have the keyword, and gives it to all of them or
takes it away.
- The arrow shown on hover shows the photos with the keyword.
- A click picks a row, for the actions that follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`is_under` and `dedupe` compared names by lower-casing them, but other
checks used `eq_ignore_ascii_case`: changing only the case of "Ärzte"
was refused as a clash with itself, and moving a keyword where it is
wasn't seen as staying put. Every comparison now uses
`keywords::same`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui's zoom_with_keyboard was left on, so at the end of every frame it took the
same keys as View ▸ Zoom In / Zoom Out / Zoom to Fit for its browser-style
interface zoom. On macOS the native menu bar got the keys first; on Windows and
Linux the whole UI scaled. theme::apply turns the option off for every context
(desktop, headless, tests).
Headless tests that pressed Cmd+Plus / Cmd+Minus to exercise the interface
zoom's viewport handling set the zoom factor directly; the CLI snapshot test
that did the same is dropped (the unit tests cover the mechanism). New
tests_zoom_keys.rs: the shortcuts step the photo zoom while the zoom factor
stays, and Ctrl+wheel over the photo zooms it in the Edit view.
Export wrote each keyword's `a|b` path into `dc:subject` and nothing
into `lr:hierarchicalSubject`. It now writes what
`Catalog::export_keywords` gives: names flat in `dc:subject` (with the
keywords containing them and synonyms, as their options say), full
paths in `lr:hierarchicalSubject`, and nothing of a keyword left out
of export. `export_metadata` takes the catalog for the keyword list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `keyword.create {name, parent?, attributes…, addToSelected?}`: a
keyword inside a parent with its attributes, given to the selection
at once; one undo step.
- `keyword.edit {keyword, name?, attributes…}`: what isn't given keeps
its value.
- `keyword.move {keyword, parent | null, merge?}`: onto a taken name
it says so, and merges only with `merge: true`.
- `keyword.purgeUnused`, `keyword.info` (attributes, listed, count).
- `keyword.setDefaultParent` (Lightroom Classic's "Put New Keywords
Inside this Keyword"): new keywords go inside it unless `parent` is
given (null = the top level). It is kept with the library, follows
its keyword's rename or move, and goes when the keyword is deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sony's Dynamic Range Optimizer (factory default Auto) brightens the darker
regions of the camera JPEG, not the raw. The tone curve fitted to that JPEG
carried the brightening into the raw's starting look: on 8 ILCE-7CR photos
with DRO on, LightCraft rendered +6.4 L* from Lightroom's default (median
dE76 7.5), against -0.2 L* (3.6) for the 16 with DRO off.
- lightcraft_raw::embedded_preview_dynamic_range_optimized reads Sony
maker-note tag 0xb025 (every documented value: 0 off; 1-3, 8-12 and
16-23, i.e. up to Lv8, on). It was present on all 264 Sony raws checked
(98 bodies); 0xb04f only says on/off and is missing on older bodies.
- With DRO on, the look keeps the colour fitted to the JPEG and its tone
curve is lowered, knot by knot, to Sony's curve without DRO wherever that
is darker (assets/camera-tone/sony-dro-off.json, pooled from 12 CC0
raw.pixls.us ARWs of 10 interchangeable-lens bodies; sources and
derivation in the file and assets/ATTRIBUTION.md). The second-last knot
is lowered only as far as keeps the shoulder past the last knot at or
below the fitted curve, so the result is never brighter anywhere.
Compacts that map raw values darker keep their own curve.
- DRO off, unknown and other makers: unchanged (byte-identical renders on
71 + 35 photos). Render cache version 23.
DRO-on photos now land at +0.8 L* from Lightroom (median dE76 3.3), band by
band like the DRO-off ones. Docs: camera-preview-colour.md, parity row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a keyword was renamed or merged, the keyword filter followed it by
cutting the old name off by bytes: names match whatever their case, and
where a letter's case changes its length (ẞ / ß) the cut could land
inside a letter and panic (`ẞẞA` → `Road` with the filter on `ßßa|é`).
It now uses the catalog's level-based `reparent`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Catalog::export_keywords` applies Lightroom Classic's keyword tag
options: a keyword left out of export goes nowhere; otherwise its name
and the keywords containing it that are exported themselves (unless
"export containing keywords" is off) and its synonyms (unless "export
synonyms" is off) go out flat, for `dc:subject`, and its full path for
`lr:hierarchicalSubject`. Each name once, whatever its case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyword list's own actions, each one undo step:
- create a keyword with its attributes, and tag photos with it at once;
one that exists already (listed or on a photo) is refused;
- move a keyword (with those below it) inside another or to the top
level; never inside itself or its children, and onto a name that is
taken only when merging is allowed (`CatalogError::KeywordExists`
otherwise, so the UI can ask);
- edit: rename its last level and set its attributes together; a taken
name is refused, merging being its own action;
- purge: take off the list the keywords no photo has.
Typed in any case, keywords keep the spelling the library has
(`keyword_path`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming a keyword cut the keywords below it at the byte length of the
name as typed. Names match whatever their case, but some letters change
length with it (ẞ is three bytes, ß two): `STRAẞE` → `Road` turned
`straße|nord` into `Roadnord`, and a cut inside a letter would panic.
The levels below are now kept whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming or moving a keyword takes its listing and its listed children
along, photos or not; a keyword only the list holds can be renamed too.
Merging keeps the target's attributes and gathers the synonyms (a
target not listed yet takes the merged keyword's attributes). Deleting
takes the keyword and those below it off the list. Each is still one
undo step: the list's changes join the photos' in the batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keywords existed only as `a|b|c` strings on photos: one couldn't be
created before a photo had it, nor a parent made to organize under.
The catalog now has a keyword list (`Op::SetKeyword`, format version
4): a keyword listed on its own is in the tree with no photos, its
parents too, and carries Lightroom Classic's keyword tag options
(`KeywordInfo`: synonyms, include on export, export containing
keywords, export synonyms, person), exported by default. Undo restores
what was listed, as it was written. Version 3 libraries open and are
rewritten as 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compression 32767 with BitsPerSample 12 and one strip of exactly width x height x 1.5 bytes is plain packed 12-bit data: two pixels per three bytes, least-significant-bit first (measured on the DSLR-A900 sample by same-colour neighbour smoothness, 12x smoother than the other orders). RGGB per the file's pattern tag (checked against the camera JPEG), black 128, crop = FullImageSize centred in the frame, white balance from the plain maker-note block. Any other strip size stays Unsupported. RENDER_CACHE_VERSION 22 -> 24 (adjust at merge).
A raw whose camera look fails the acceptance gates fell back to the neutral
rendering: dark (9-22 L* below the camera JPEG), about a third of its
colourfulness, hues from the generic camera = sRGB matrix. Before falling
back, fit a partial look: the quantile tone curve (registration-free), the
chroma curve and, on a scene with enough colour, the photo's own matrix at
half weight (no hue/saturation table). It is used only when it cuts the
held-out error to below 0.7 of the neutral fallback's (linear and encoded)
and the JPEG's luminance ranks the pixels like the scene's (Spearman >= 0.5).
The full look's gates and search are unchanged. Render cache version 24.
New Album, Rename Album, New Smart Album, Merge Keywords and the text
prompts (rename a preset or a version, …) had Rename Keyword's bug:
their field took the focus back on every frame, so egui never reported
the focus Return gives up, and Return did nothing; only OK confirmed.
All six now use one `dialog_field`: a shared text field that keeps the
focus except while its own menu has it, opens with its text selected
and reports Return. They get the Cut / Copy / Paste / Select All menu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It gets the Cut / Copy / Paste / Select All menu and opens with the
name selected, so typing replaces it. Return now renames: the field
took the focus back on every frame, so egui never reported the focus
Return gives up and only the OK button renamed. The field still keeps
the focus, except while its own menu has it; Esc still cancels the
dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Title, Caption, Alt Text, Extended Description, Copyright, the usage
terms, the creator and the place fields get the Cut / Copy / Paste /
Select All menu. Esc now gives an edit up: it used to save the typed
text, since the field saved whenever it lost the focus. The caption and
other multi-line fields keep taking Return as a new line and save on
leaving; the shared field gains `rows` for them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What's New and the KEY-CLIPBOARD row overstated the Windows / Linux
fix: Ctrl+V reaches the app only while the system clipboard holds text
(egui-winit drops it otherwise), and it never worked before, so not
"again". The text field's module doc says what it depends on, that its
widget id must be unique on screen, and that new text boxes use it; MCP
`click` lists `button`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no text field focused, `ui.clipboard` (and MCP `clipboard`) sent
the clipboard event to the app's shortcuts: it pasted edit settings on
Windows, Linux and in headless runs, and did nothing on macOS, whose
menu bar takes ⌘V. It is now refused there; agents run `develop.copy`
and `develop.paste` instead. The docs also say that cut and copy write
the system clipboard in the desktop app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui-winit also sends a paste for Windows' Shift+Insert (and a cut for
Shift+Delete): read with the Shift held, it matched ⇧Ctrl+V and opened
Paste Selected Settings. A clipboard event with no Ctrl/⌘ held is now
the plain command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Select-on-focus selected everything on the press that took the focus,
after egui had placed the drag's anchor there: a drag then selected
from the start of the text. With a press, the field now waits for the
release and selects everything only after a plain click, as a
browser's address bar does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A field hidden while its menu had the focus (its panel collapsed, its
slider turned off) came back with that state: it reported a spurious
Away when shown again, kept the text from before an edit that was cut
short, and would have replayed a menu choice picked on its last frame.
A field not drawn in the previous frame now starts afresh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Response::has_focus` is false while the window is in the background,
so switching to another app mid-edit forgot the text from before (Esc
then gave back the half-typed text), a select-on-focus field selected
everything again on coming back, and `editing` went false: a slider's
typed value applied and closed. The field now follows egui's own focus,
which the window's doesn't change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Return with the menu open left the edit hanging (the field gave up the
focus, taken for a click in the menu) and it ended later as Away: a
name field that submits on Return never submitted. It now ends the edit
as Return and closes the menu. Esc with the menu open, or with the
focus in the menu, cancelled the whole edit; it now closes only the
menu, as native menus do, and the field takes the focus back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The field's menu closed on any click, a greyed-out Cut or Copy too, and
the press had already taken the focus: the edit ended as if the user
had clicked away (a slider applied its half-typed value). The menu now
closes only on a choice or a click outside it. The test that said Cut
and Copy need a selection could not fail (egui copies nothing from an
empty selection anyway); it now checks the greyed-out items.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parity row (🟡: three fields use it so far, the rest still use
egui's own) with the summary refreshed, and What's New for the menu,
Esc and Ctrl+C / Ctrl+V on Windows and Linux.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui-winit turns Ctrl+C / Ctrl+X / Ctrl+V (with any other modifiers)
into clipboard events and drops the key press, so outside text fields
Copy Settings, Paste Settings and ⇧Ctrl+V (Paste Selected Settings)
never fired there; macOS was spared because its menu bar takes ⌘C / ⌘V
first. The shortcuts now read those events as the keys they were, as
the keymap editor already did when recording a shortcut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It gets the Cut / Copy / Paste / Select All menu, and Esc in it now
clears the selection as Clear does, as the code meant: it checked the
field's focus on the Esc frame, which egui has already taken away, so
Esc only left the field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The value typed after clicking a slider's number gets the Cut / Copy /
Paste / Select All menu (`sliderField:<control>`), and using the menu no
longer closes the field: it used to close as soon as anything took the
focus. Selecting the old value and Esc now come from the shared field;
`TextFieldResponse::editing` says an edit is under way, the menu
included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Search Photos gets the Cut / Copy / Paste / Select All menu, and Esc
gives back the search from before the edit (and filters by it again).
The field gains the builder options it needs (no frame, font, colour,
alignment, margin) and `text_field::id` for ⌘F to focus it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ui.click` took `button`, `ui.clickWidget` ignored it: MCP `click
{widget, button: "right"}` passes it on, and silently left-clicked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ui.clipboard {action, text?}` does what the system does for ⌘X / ⌘C /
⌘V in the focused text field (egui-winit sends clipboard events, not
key presses, so `ui.key` with ⌘C could not), and `ui.inspect` reports
`copied`, the text the UI last put on the clipboard. MCP has the same
as the `clipboard` tool when the app is attached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`text_field::TextField` is a standalone text field for every text box in
the app:
- right-click for Cut, Copy, Paste and Select All, shortcuts shown,
acting on the selection the right-click would otherwise move (the
browser build has no Paste item: a page cannot read the clipboard on
request);
- Esc gives back the text from before the edit, also after using the
menu;
- the caller learns how an edit ended (Return, focus away, Esc) and
decides what that means; using the menu is not leaving the field;
- optionally everything is selected when it takes focus;
- an edit cut short when the field goes away is forgotten.
The menu replays egui's own Cut and Copy events and asks the host for a
paste, so passwords, character limits and undo work as egui does them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text the UI copies lands in `HeadlessView::clipboard`, and a cut, copy
or paste request (`ViewportCommand::RequestCut/Copy/Paste`) comes back
as that event on the next frame, as egui-winit does on the desktop. UI
tests can now check copying and pasting in text fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "photo(s){}" printed "photos(s)s"-style text: the plural suffix now follows the noun ("photo{}"),
as the Spanish catalog does; smart previews repeat it ({0}).
- Menu triangles (▸) in translations replaced by → (the shipped fonts have no ▸ glyph; test
translated_catalogs_do_not_use_missing_menu_triangles).
- "{:.0} %" -> "{:.0}%" so the Merging progress test matches every language.
- Translate the two strings main added since the branch (Camera local tone mapping).
- README, docs/localization.md and the LR-BEHAV-LOCALE parity row list French.
- Test: French plural formatting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A raw without colour matrices of its own now gets DNG ColorMatrix1/2 and
ForwardMatrix1/2 for its model when the model is one of the 52 cameras
in rawtoaces-data (Academy Software Foundation, Apache-2.0): 22 Canon,
11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names its
alias list gives (Rebel T3i and Kiss X5 for the EOS 600D; bodies it
lists as sharing a sensor). tools/spectral_camera_table.py fits, under
Standard Illuminant A and D65, a white-preserving 3x3 to the measured
spectral sensitivities over rawtoaces-data's 190 training reflectances
and writes crates/raw/src/spectral_table.rs. No number comes from Adobe
or from a raw decoder. lightcraft_raw::spectral looks a camera up by
Exif make and model; the DNG colour model does the rest.
camera_preview::starting_colour takes the first available of: the
file's own matrices, a camera profile for the model (fitted to the
JPEG for tone), the spectral matrices, the file-local JPEG fit, the
neutral fallback. A profiled model whose profile fit fails takes its
spectral matrices. White balance stays relative to the as-shot look for
every raw without matrices of its own, so Photo::relative_wb and the
controls are unchanged; calibrate still pools covered models.
Tests: one per precedence step (synthetic), the table's lookup, aliases
and DNG shape, and a corpus test of the order on ILCE-7M4 (profile),
EOS 5D Mark III and D5100 (spectral), EOS 7D (own fit) and a DNG. The
ILCE-7RM2 and GFX100S corpus tests now expect the spectral matrices.
Attribution in assets/ATTRIBUTION.md and NOTICE. RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Demo scene pixels were generated inside `OnceLock::get_or_init` on the
caller's thread, and the render runs on rayon. A rayon worker that waits
for rows another worker took runs queued jobs on top of itself; when one
of those asked for the same scene, it waited in `get_or_init` on the
generation further down its own stack, and every other request for that
scene waited behind it, forever. The faces scan's pool threads hit it (two
tests_face_recognize tests sat in `Once::wait` for 30 minutes); any rayon
worker loading demo photos could.
Each (scene, size) slot now has one generation at a time: the first caller
renders, with no lock held, and later callers wait on a condvar for that
generation's outcome. The render never needs a thread that waits:
- a rayon worker (the faces scan, merges) renders on its own thread alone,
without rayon (`Scene::render_fit_serial`, the same pixels), so it never
runs other jobs mid-render, and the work stays within its pool and that
pool's limits (scan pace, LIGHTCRAFT_FACE_THREADS);
- other callers (the render workers, plain threads) render in parallel on a
pool of rayon's default size (RAYON_NUM_THREADS, else the available
parallelism), whose threads only render; where it can't start, they
render alone too (no rayon, so no deadlock). Generating a scene never
touches the global rayon pool (asking its size would start it, which
panics where its threads can't start).
The scan's first render of a demo scene now takes 1 to 2.3 s on one
thread instead of 0.5 to 1.3 s on its light pace's two (M3 Max, 2560 px
preview source); interactive loads are unchanged.
A generation number gives each waiter the outcome of the generation it
waited for, even when a retry starts first. A render that panics is an
error for its callers (`scene_pixels` returns a Result) and the next
request tries again. No duplicates, no unbounded threads.
Tests (each waits with a timeout, so a deadlock fails instead of hanging):
rayon workers keep asking a 4-thread pool for a scene while it is generated
(24 replies counted, at least 4 overlapping; times out at 120 s on the
OnceLock code); a scene is rendered once however many ask (the first
version of this fix rendered 5 copies at once); a rayon worker renders
alone on its own thread; callers wait for the generation running; a failed
generation is an error and the next request retries; a retry never changes
the outcome waited for (state-level, and a racing retrier: 60 of 60
waiters got the retry's pixels on the previous version); in a process
whose global pool failed to start, a scene is still generated; the serial
render is the parallel one, pixel for pixel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A catalog written before the lens data was read at import (RW2/RWL from
v0.4.0) has none stored, and the lens correction stays off. Reload now
stores the fresh probe's lens data (new journaled, undoable
Op::SetEmbeddedLens; catalog format 4) and, for photos still at import
settings, switches the correction on as import does. Edited photos and
photos with a default-preset look keep their settings.
The readout and the slider said "+2.50" where the field said "2.50";
it now writes the angle through crop_angle_label too. The test that
said the readout's "0.00°" matched the slider at rest (it shows "0")
says what it does match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity rows cite panels/right.rs, where the field lives, and say
that it applies on Return or leaving it, that Esc keeps the old angle
and that it takes a decimal comma; the Angle slider row points to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zh-hans says 单击 and zh-hant 點擊 almost everywhere ("Click to clear
these filters"); the new "Click to type a value" used 点击 and 按一下.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui's own parser dropped "-3,25" silently, while the Straighten value
takes it (issue #322). The field now reads a typed angle through the
same `typed_value`: a decimal comma, a trailing degree sign, and an
angle past ±45° held at the limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing the crop tool in the middle of a rotation (Return, Esc) left the
rotate gesture set, as only the canvas's drag-stopped cleared it. Once
the rotation pointer and angle readout were derived from it, reopening
Crop showed both everywhere, over the side panels too, until the next
drag. A crop gesture with no button held is now over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The box stops at the photo's edge, so a move or resize drag can carry
the pointer outside it, where the rotation glyph replaced the move or
resize cursor and said the drag rotated. The drag's own cursor now
holds until it ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Esc applied the typed angle: egui takes the focus away on the Esc frame
and parses the leftover text on the next one, when Esc is no longer
pressed. The field now remembers the Esc for that frame, as the
slider's typed value already keeps the old value on Esc. Leaving the
field another way still applies the angle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The field applied every keystroke: an undo step and a render per key,
and half-typed angles (-3° on the way to -3.25°). It now applies on
Return or when focus leaves, as one undo step, and ignores changes that
leave the angle as it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The readout was bounded by the photo's rect, so rotating in the margin
beside a narrow photo pinned it to the photo's edge, far from the
pointer (the earlier commit said "kept on the canvas" but used the
photo). It is now bounded by the canvas. Placing it also no longer
panics on NaN or empty bounds (f32::clamp panics on NaN).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The issue asks for a numeric field for an exact, repeatable rotation.
Typing into the Straighten value worked, but it was hidden behind a
hover hint. The Crop panel now has an Angle field under Straighten (a
drag value in degrees, −45° to 45°, two decimals): click and type, or
drag it. A drag is one undo step, like the slider's
(begin / endInteraction). "Angle" is translated in all seven locales.
Headless tests: typing -3.25 into the field sets exactly -3.25°; a
six-step drag on it adds one undo step (it added five before the
interaction was wrapped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- With ⌘ held a drag draws a level line, but outside the crop box the
pointer showed the rotation arrow (the crosshair was set, then
overwritten in the same frame). The crop tool now leaves the
straighten crosshair alone while ⌘ is held and nothing is rotating.
- The angle readout ran past the canvas (and the window) near its
right or bottom edge. It now flips to the pointer's other side and is
kept inside the canvas (readout_rect).
- The readout showed the angle from before this frame's rotation. It is
now drawn after the drag is applied, with the angle just set.
Headless tests: ⌘ held outside the box gives the crosshair and no
glyph; rotating at the canvas's corner keeps the readout on the canvas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every slider's value already takes a typed number when clicked, which
is how to enter an exact crop angle (#534), but nothing said so.
Hovering a slider value now shows "Click to type a value", translated
in all seven locales. A headless test hovers the Straighten value and
finds the hint painted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging outside the crop box rotates the photo, but the pointer there
was egui's generic Alias arrow, so nothing showed that a drag would
rotate (issue #534).
- Outside the box, and throughout a rotation drag, the system cursor is
hidden and a curved double arrow (new code-drawn Icon::RotateDrag) is
drawn at the pointer on a dark disc, on the tooltip layer
(`cropRotateCursor`). No system cursor has a rotate shape.
- While rotating, the angle is shown next to the pointer
(`cropAngleReadout`), formatted like the Straighten value
(crop_angle_label, reusing widgets::shown_value), every zero as
"0.00°".
- Headless tests: the cursor outside vs inside the box, the readout
during a drag and gone after it, the label format, and typing an
exact angle into the Straighten value (which already worked; now
guarded).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For the crop tool's rotation pointer tests (#534): Headless::last_cursor
is the pointer cursor the last frame asked for (what a real host would
show), and painted_text lists every text the last frame painted,
tooltips included. An import test already did the latter inline. Both
let UI tests check what a person sees.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
browse_a_local_folder compared the browsed folder with backslashes replaced
by slashes, but library.browse keeps the platform's separators; compare
paths instead. A breadcrumb now opens the path cut from the original string,
so a UNC share keeps its leading \ and a bare drive opens its root (C:\)
rather than the current folder on that drive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Added a function to check if an overlay supports window rendering. Updated overlay handling in various contexts to ensure compatibility with new overlay support logic.
A Custom white balance from a Lightroom catalog or XMP sidecar was taken as absolute Kelvin for every
raw. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF; 6500 K
means as shot) then read a small tweak as a large blue shift: ΔE 22 against Lightroom's export on the
photos of issue #510.
The crs mapper now takes a `Target` (preset, rendered, raw with a measured illuminant, relative-WB raw)
instead of a bool. For relative-WB raws, and rendered files with only `Temperature`, a Kelvin value is
converted to the same mired shift from the packet's `AsShotTemperature` / `AsShotTint`. Without that
reference (Lightroom catalogs keep none for Custom photos) the photo stays As Shot and the import
report lists `Temperature, Tint` as not carried over. Import, sidecar read and catalog import decide
the target the same way through `lightcraft_catalog::relative_wb_format`.
Tests: shift from the issue's numbers (3650 → 3578 K lands at ~6275 K), the As Shot fallback with its
report entry, target selection per file, and the catalog mapper's Custom-WB case.
Each photo's develop settings record the rendering process they are
interpreted by (`DevelopSettings::process`). There is one process, V1, the
pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU
renders of procedural scenes, and the saved settings JSON and hash64, are
byte-identical to main).
Settings saved without the field load as V1 (a field-level serde default),
new settings get the latest (`Default`), and V1 is left out when saving, so
files and preview-cache keys stay as they were. A damaged value (anything
but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the
rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the
latest process, section and slider resets don't; virtual copies inherit
it; copy / paste / sync / Auto Sync / presets don't carry it; a number from
a newer LightCraft is kept and renders with the newest known process. The
process is not an edit (Edited badges, import-look checks). The base tone
map is chosen per process in one place (`finish::base_tone`, shared by CPU
and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves
selected photos on an older process to the latest as one undo step. Its
menu enablement follows the selection; a call that names photos (`ids` /
`id`) is validated on those instead, through a new opt-in
`CommandSpec::explicit_targets` (execute checks enablement before the
handler sees `ids`). `develop.get` reports the process. The rule and how to add V2:
docs/process-versions.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
proxies() fitted the decoded camera JPEG into the sensor proxy's box,
which keeps the JPEG's own aspect and can round a pixel short: an
ILCE-7RM4's 1616x1080 preview, decoded at 384x257, fits a 192x128
profile proxy as 191x128, and collect_pairs rejected the mismatch. So
`lightcraft-cli calibrate` skipped every file from this camera and no
profile could be written.
Resize the JPEG to exactly the sensor proxy's size instead; proxies()
has already rejected references whose aspect differs by more than 2%.
Where fitting already landed on the proxy's size the result is
identical (fit is resize at a rounded size), so working renders don't
change: 34 real ILCE-7RM4 ARWs render bit-identically. On 923 files
from one trip calibrate goes from 0 to 843 accepted.
Refs #515
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: the problem-message example ("rule 2.1: rating is
0–5, not 9") predated the rating check's rewording ("no rating 0–5 is
9"); two doc comments still said Album rules take a plain album; and
What's New described the Album rule's old flat list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: the field menu mixed languages. The new fields (Copy
Name, In a Stack, File Extension, Video Duration, Keyword Count, People
Count, Shutter Speed, Cropped) and four group headings (Keywords &
People, Description, Camera Info, Develop) were English in every
locale. Russian also lacked fifteen older labels (Pick Flag, Any
Searchable Text, Filename, Color Label, Has GPS…).
All are translated, following each locale's existing terms (Stapel /
Pila / スタック / 堆叠 for stacks, Цветовая метка as in Russian's
other colour-label messages). The i18n test now requires every field
and group label in every language, as it does for choice values and
problem texts. pt-br's album search reads "Pesquisar", like its other
searches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review:
- Every non-number "between" got date pickers, so a text or choice rule
written with "between" (by an agent; the check marks it) offered
calendars that would write dates into it. Only date fields get them
now; a test checks a "Title between" rule.
- An "in the last" N stored as text ("7") showed as 30, and changing
only the unit wrote 30. It is now read as the catalog reads it.
- take_escape's comment no longer claims later egui passes see the key
press (they don't); keying it to the frame just lets it lapse.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: while the rule dialog was open it ran the rule check
several times, the full query for the live count (sorting every match
only to count them) and rebuilt the album list with a loop walk per
album, on every repaint, including pointer moves over a picker. The
sidebar re-checked every smart album's rules on every frame.
Caches::rules_view keeps the dialog's problems, count and album list
until the catalog revision, the rules, the language or (for "in the
last…" rules) the minute changes. Caches::smart_album_problems keeps
the sidebar's ⚠ problems per catalog revision. Both count their scans,
like the album counts, and a test checks that unchanged frames reuse
them and that rule and catalog changes don't.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review:
- Cost: ancestors and paths were found by scanning every album for each
row, which grows with the square of the album count (about 1.1 s for
20,000 albums). An id→position index built once per call or frame
makes it cost the folder depth. A test lays out and searches 20,000
albums in nested folders well under its 1 s limit.
- Focus: clicking a folder, or pressing Enter when nothing can be
picked, took the keyboard from the search box, so typing went
nowhere. Both hand it back; a headless test types after a folder
click.
- Scrolling: the highlighted result was scrolled into view every frame,
fighting the mouse wheel. It now scrolls only when ↑ / ↓ moved to it.
The headless harness stops rendering once input is handled, so this
snap-back can't be reproduced there; a test I wrote passed with and
without the fix and was dropped rather than kept as a false guard.
- Folder rows carry widget info (name, open or not) for assistive
tools. Dead code (an unused stack) and a test assertion that couldn't
fail are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: photo.setCaptureTime quietly skipped photos whose
shifted date would leave years 0000–9999. It reported success, with
the photo missing from `captured`. Such a shift is now refused for the
whole selection with how many photos it concerns, and nothing changes.
A date that doesn't read at all is still skipped, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: I had refused a time zone or fractions of a second
in rule dates on the premise that capture times are stored local and to
the second. They aren't. Imports write DateTime::to_iso, which carries
EXIF SubSecTime and OffsetTime ("2026-08-14T10:00:00.250+02:00"), so a
rule like "is 2026-08-14T10:00:00.250" did match those photos and was
wrongly refused; saved albums holding such dates would have shown ⚠
too.
rule_date now accepts, after whole seconds, a fraction (1–9 digits)
and a zone (Z or ±hh:mm). Anywhere else ("2026-08-14Z") they are no
date. Issue::DateWithZone and its translations are gone; such values
are NeedsDate. The test matches a photo captured with a fraction and a
zone, and the docs no longer say zones are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: loop detection only followed Album rules. A smart
album's own album field (Filter.album, as set by the filter bar or
album.setRules {rules: {album}}) was ignored, so
"album.setRules {id: A, rules: {album: A}}" was accepted and A became
silently empty. On main it would have recursed to a stack overflow; the
guard already stops that.
Filter::albums_tested covers both, and album_reaches follows it.
Catalog::album_filter_problem reports a loop through the album field;
smart_album_problems (sidebar ⚠, albums.list) and album.setRules
(including fromView) use it. A problem with no rule position displays
without the "rule …:" / "#…" prefix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: library.filter and album.setRules (without replace)
merged a change onto the current rules and re-checked all of them.
Once a rule stopped checking later (the album it tests was deleted, a
custom label renamed), every later change was refused: the search box,
keyword clicks and the filter bar silently stopped working, since the
UI doesn't show those errors, and partial edits of the album were
refused.
Only rules a change sets ("ruleSet" present) are checked now. Rules
that went stale still show in the dialog and the sidebar's ⚠ until
they're fixed. A regression test deletes the album a filter rule tests,
then changes the rating, edits the album, and still gets refused for a
bad new rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: the loop guard bounded the depth of smart albums
testing smart albums, but not repeated work. Each album tested by the
next one twice ("all of: album is X(k-1), album is X(k-1)") doubled the
cost per level, so 25 levels meant 2^25 evaluations per photo, enough
to freeze the sidebar's counts. Any agent or catalog file could build
such a chain.
rules::smart_album_holds remembers each smart album's answer for each
photo during one outermost question, and forgets them when it ends (a
drop guard, however it ends). A regression test counts a 26-deep
doubled chain in well under its 2 s limit; before the fix it ran for
minutes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flat dropdown doesn't scale to the hundreds of albums and smart
albums a library grows. crates/ui-egui/src/album_picker.rs is a
reusable picker:
- A button opens a popup with a search field, focused on open.
- With nothing typed it shows the sidebar's tree in the sidebar's order
(entries_from uses Catalog::album_children_by_parent): folders with
painted disclosure triangles and folder icons, album / smart-album
icons. Folders are closed except those holding the current choice.
- Typing lists the albums whose "Folder / Name" path holds every word,
in any case, names starting with the query first. ↑ / ↓ move, Enter
picks, Esc closes just the popup.
- Albums that can't be picked stay visible but greyed, the reason on
hover, instead of vanishing.
- Pure, unit-tested helpers: path_label, ancestors, visible_tree,
search, set_depths. Widget ids albumPicker / albumPickerSearch /
albumPickerFolder / albumPickerItem.
The rule editor's Album value uses it. The album being edited and
albums that would loop back are blocked with a reason ("This is the
album you're editing", "It tests this album, so testing it back would
loop"). This also removes the stray divider the old dropdown drew above
"Smart Albums" when no regular album preceded it. New strings are
translated in all seven locales, and the strict i18n test lists them;
the unused "Smart Albums" heading is dropped. Headless tests: browsing
the tree, typing with ↑ / Enter, greyed loops.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file-path rule test and a sidebar long-name test used a real person's
name as their example. They now use "Lake Como Wedding-Day" and keep
what they check: a multi-word folder name, word order that must not
match, and a hyphen a space must not match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: a loop saved anyway doesn't "match nothing". Inside it, the
album being evaluated counts as empty, so an "isn't" rule on it matches
every photo; the sidebar marks it. Also: Album rules take plain or smart
albums (not "plain" only), the picker lists both, an album that is
smart is no longer a problem, and a broken sentence in the parity row
is mended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: album.setRules with fromView saved the current view
unchecked. Showing smart album A ("Album isn't B") and updating B from
that view made B test itself, which the rule check is meant to refuse.
The view is still saved as it is, but a loop for the album being
updated is refused (Issue::AlbumLoop), and the sidebar's menu item
shows the reason instead of ignoring the result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the walk through smart-album references kept its seen-set
in a Vec, which is quadratic over long chains. The loop test's comment
now says what a saved loop does: inside it, the album being evaluated
counts as holding no photos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summaries showed an Album rule's id ("album isn't 9"): the sidebar
tooltip, the "Rules:" filter chip and albums.list's rulesText for
agents.
RuleSet::describe_with(&Catalog) and Filter::describe_with name the
album ("album isn't “Excluded Photos”"). One that is gone shows as #9
(rules::album_name). The engine's rulesText and Rules chip use them, and
i18n::rules_label takes the catalog for the translated summaries.
Tests at all three levels: catalog (nested groups, a missing album),
engine (rulesText), UI (German summary).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Album rule's list offers smart albums under a "Smart Albums"
heading (translated in all seven locales). It leaves out the album
being edited and any smart album that tests it, since that would loop
back (album_choices, Catalog::album_reaches).
- The dialog checks rules for the album being edited
(RuleSet::check_for), so a loop set by other means is marked under its
row, OK is disabled, and confirm refuses it.
- Esc with any popup open in a dialog (a dropdown, a menu, a date
picker) closed the dialog with its edits. The dialog now claims Esc
for an open popup before drawing (widgets::take_escape), so only the
popup closes. New headless test for a dropdown, next to the
calendar's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Album rule may now name a smart album: "Keywords contain travel"
and "Album isn't Excluded Photos" (red or rejected) leaves out whatever
Excluded Photos matches, as its rules change. Smart albums used to be
refused for this.
Loops:
- RuleSet::check_for(cat, owner) refuses an Album rule that would make
the owner include itself, directly or through other smart albums
(Issue::AlbumLoop). Catalog::album_reaches walks the references with
a seen-set; RuleSet::albums_tested lists them. album.setRules checks
for the album being edited, and smart_album_problems reports a loop
saved anyway.
- Matching never recurses without end: album_contains evaluates a smart
album's rules through rules::evaluating, a thread-local stack of
albums being evaluated with a 32-deep cap. An album already on it (a
saved loop) holds nothing, and the stack is restored by a drop guard
however evaluation ends. A test builds A → B → C → A and checks that
every album returns and reports the loop.
The issue "can't test a smart album" is replaced by the loop issue in
all seven locales. The sidebar counts already refresh while any smart
album uses "in the last…", so an album testing one is covered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- Esc with the calendar open also closed the rule editor and lost its
edits. The shortcut layer runs before the UI, and Esc's command (Back)
closes any dialog. Shortcuts now leave Esc to an open popup (any
menu or calendar). The picker marks Esc as handled for the frame
(widgets::take_escape / escape_taken; the mark lasts the whole frame,
since egui may run more than one pass), and the dialog's own Esc
check defers to it. New headless test: Esc closes only the calendar.
- Paging wrapped at the ends (‹ on January 0000 showed December 0000).
step() is now a pure function that stops at years 0000 / 9999; year
pages are aligned blocks of twelve, and the last one holds 9996–9999.
View::open clamps a caller's year and month, so a huge `today` can't
overflow.
- PickedDate::parse reads a time after the day only if it is one
(T10, T10:00, T10:00:00), as the rule check does; "2026-08-14Tjunk"
is no longer shown as a valid day.
- Today is marked in the month and year views too (holds_today).
- Documented: ids must be unique among the pickers on screen; the
picker picks dates, not times (picking a day replaces a typed time).
The i18n test also covers Year / Month / Day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: "2026-08-14Z" (a zone after a date with no time) was
reported as "needs a date" rather than as the time-zone issue it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each date field in the smart-album rule editor has a DatePicker beside
its text field. It opens on the rule's date at its precision (or on
today, from the session's clock, passed in rules_editor::Env), and
writes what is picked as 2026, 2026-08 or 2026-08-14. Typing still
works, and opening never changes a rule. In a "between", each date's
picker stops at the other, so the two stay in order.
Headless tests pick a day, a month and a year, move between months and
year pages, keep a "between" in order from both ends, and open an
unreadable date on today without rewriting it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/ui-egui/src/date_picker.rs: a calendar button that opens a popup
to pick a year, a month or a day. egui_extras' DatePickerButton was
considered and left: its weekday names, month names, "Week", "Cancel"
and "Save" are hard-coded English, it only picks whole days, and it
needs a Save click and the jiff crate.
- PickedDate { year, month?, day? } keeps a value's precision (2026
means the whole year): parse / iso / precision / first_day /
last_day. days_in_month, a Monday-first month_grid and in_bounds (any
day of the value in range) are pure and unit-tested.
- DatePicker::new(id, &mut Option<PickedDate>) with precisions(), min(),
max() and today(). It opens on the value at its precision (else
today, on days) and returns a response that is changed() when a date
is picked. Out-of-range cells are disabled.
- It follows the UI language: Year / Month / Day, month titles and
labels from the date headings (date_group_label), and weekday headers
(Mo…Su) translated in all seven locales with "Pick a date". A test
requires them.
- Widget ids for tests and agents: datePicker:<id>,
datePickerPrecision:<p>:<id>, datePickerPrev/Next:<id>,
datePickerCell:<iso>:<id>.
- A code-drawn Calendar icon in icons.rs (original work, covered by its
attribution entry).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some date values passed the check but never matched. "2026-10-01 10:00"
(a space, not T) and "…T10:00:00+02:00" (a zone) were accepted, while
the matcher compared them as written against capture times stored as
"2026-10-01T10:00:00".
rules::rule_date is now the one reading for the check and the matcher:
2026, 2026-08, 2026-08-14, then T10 / T10:00 / T10:00:00, with a space
working as the T, and a real date and time (25 o'clock and minute 61 are
refused too). A time zone or fractions of a second can never match a
local, whole-second capture time. They get their own issue,
DateWithZone, translated in all seven locales, instead of matching
nothing silently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- Keywords and People say "are empty" / "aren't empty", but their
empty-text hint said "is empty". New issues NeedsNamesOrEmpty /
NeedsNamesOrNotEmpty quote their operators, translated in all seven
locales with each locale's own label (sind leer, están vacíos, が空…).
- Russian had none of the 20 smart-album operator labels, so its
editor showed every operator in English and the new hints named
«пусто», which wasn't on screen. All 20 are translated now, and a
test requires every operator label in every language.
- Japanese and Chinese join a field and its problem with the full-width
colon their catalogs use ("タイトル:…").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
i18n::problem_text shows a problem as the field's label and the issue
in the current language ("Titel: braucht einen Suchbegriff (oder „ist
leer“)"); problem_line puts the rule's position first ("#2 Title: …")
for the sidebar's tooltip. The editor's row notes and the sidebar use
them.
All 20 issue texts are translated in the seven locales, each quoting
its own operator names ("ist leer", «пусто», 「が空」), with あり / なし
for Japanese yes/no. A test requires every issue to be translated in
every language and checks English and German output.
Also corrects the zh-hant sidebar tooltip to name the menu as it is
(編輯智慧型相簿…).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Problem carried only an English sentence with the field's id
("`title` needs something to look for…"), which the editor showed
as-is in every language.
Problem now also has `field` (None for a group) and `issue`, an Issue
kind with a short English text for translation catalogs (Issue::text,
Issue::ALL). The English `message` and the `rule 2.1: …` Display stay
as they were for agents and logs. A test covers the field and issue of
each kind of problem and that each issue reads differently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A smart album whose rules no longer check shows ⚠ before its name
(`albumProblem:<id>`). Its tooltip adds "Some rules need fixing (Edit
Smart Album…):" and each problem under the rule summary, in one
tooltip. i18n::problem_text is the one place problems become display
text. The new line is translated in all seven locales.
Also: the album menu's Edit Smart Album… built the dialog itself and
skipped RuleSet::upgrade. It now goes through dialog.smartAlbum like
every other way in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rules are checked when they are made, but a saved smart album can go
bad later. The usual case is a rule testing an album that is then
deleted; it silently matches nothing.
Catalog::smart_album_problems(id) checks a saved album's rules as they
are now (RuleSet::upgrade, then RuleSet::check). It is empty for a sound
smart album, a plain album or no album. albums.list reports `problems`
for every smart album, e.g. ["rule 1: no album 8"].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule editor renamed an album (album.rename) and then set its rules
(album.setRules) as two commits. If the second failed (refused rules
before the dialog checked them, or a save error), the album kept the new
name without the rules, and undo needed two steps.
album.setRules takes an optional `name` (blank keeps the album's) and
commits the rename and the rules as one Op::Batch: both or neither, one
undo step. The dialog edits through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the number spinner was fixed, but the "in the last" spinner
still clamped a stored N on first draw ("0 days" became 1, hiding the
problem and saving a different rule; 20,000 became 10,000). Values
that weren't an {n, unit} object or a two-item "between" were replaced
by the defaults as soon as they were drawn.
- In the last: shown as stored ({n, unit}, any case, or a plain number
of days) and written only when the N or the unit is changed, with
clamp_existing_to_range(false).
- Between: anything but a pair is shown as the default pair but kept
until one bound is edited, so the check marks it. A slice pattern
replaces the [0] / [1] indexing.
- Album picker: reads the id as the catalog does; names albums with
their folder ("Trips / Rome") and sorts them by name.
- Opening an album's rules applies RuleSet::upgrade.
- New tests: drawing leaves five unreadable values as stored and marks
the three that can't match, and an edit with bad rules doesn't rename
the album (neither OK nor confirm). The parity note no longer
overclaims.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- rules::album_rule_id reads an Album rule's id as a whole number,
written as 3, 3.0 or "3". The check, the matcher and the editor now
agree; the picker showed a valid "1" as unset.
- A folder holds albums, not photos, so an Album rule naming one matched
nothing. The check now refuses it ("is a folder; choose an album in
it").
- Smart albums saved while Album was a number field could use ≥, ≤ or
between, which the matcher always read as "isn't". Since Album became
is / isn't only, editing or filtering with them was refused.
RuleSet::upgrade rewrites them to the "isn't" they meant; merge_filter
(album.createSmart / setRules) and library.filter apply it before
checking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dialog showed only the first problem, in place of the match count,
without saying which rule it was about. OK stayed clickable, and an
edit renamed the album before its rules were refused.
- Each rule with a problem gets the message right under its row, in the
caution colour; inside groups too, and on an empty group's frame. The
rules_editor::Env carries the problems from RuleSet::check, and each
row finds its own by path. Markers register `ruleProblem:<salt>`.
- The summary reads "Fix the marked rules to save this album." and OK is
disabled while any problem remains.
- confirm_dialog refuses problems before anything changes (not even the
name), and the control channel's ui.dialog.confirm keeps the editor
open on that error, as the button does.
- Album is its own field kind: is / isn't only (it was a number field
that offered ≥ and between). It picks its album from a list of the
plain albums (`ruleAlbum:` / `ruleAlbumItem:<id>:`) and starts with
none chosen ("choose an album"). album.ruleFields reports kind "album".
- Found while testing: the number editor's spinner clamped a stored
value outside its range as soon as it was drawn, so "Rating is 9"
quietly became "Rating is 5". It now uses
clamp_existing_to_range(false): the range limits dragging, and a
stored value stays as it is, marked by the check.
New labels (Choose an album…, No albums yet, Fix the marked rules…) are
translated in all seven locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: view_rules merged the filter bar's settings onto a smart
album's rules through merge_rules, which now also checks the rules, and
fell back to the bare album rules on any problem. A smart album testing
an album that was later deleted therefore lost the filter bar (e.g.
Rating ≥ 3) from Create Smart Album from Filter and the dialog's count,
without a word.
merge_filter does the merge alone; merge_rules is merge_filter plus the
check, for album.createSmart / setRules. view_rules uses merge_filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of the value checks (false rejections and one silent
mismatch):
- In-the-last units: one table (unit_secs) for check and matcher. Any
case, singular or plural; no unit means days. An unknown unit is
refused and now matches nothing, where the matcher used to read it as
days, so "week" meant one day.
- Rating: refused only when no rating 0–5 could meet the rule, tested
with the matcher's own comparison ("is 9", "≥ 7", "between 6 and 9").
"< 6" is broad but valid. Message: "no rating 0–5 is ≥ 7".
- Catalog::label_from_name compares with Unicode lowercase, as rules
do, so a custom label "Été" is found as "été".
- Empty text hints at the operator that fits: "isn't empty" for
isn't / doesn't contain, "is empty" otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RuleSet::problems only checked that a field and operator exist (and,
lately, yes/no values). Any other value was accepted and silently
matched nothing or everything: "Rating ≥ abc", a date of "banana" or
"2026-13", "Color Label isn't pink" (everything), "Title contains ''"
(everything), "in the last 0 days", an album that is gone.
RuleSet::check(&Catalog) -> Vec<Problem> replaces it. Each Problem has
the rule's position (path [1, 0]; shown as "rule 2.1: …"), so the
editor can point at the row. It checks:
- numbers (rating 0–5), shutter speeds (1/250, 2)
- dates: 2026, 2026-08, 2026-08-14 or a full time; real dates only
- between: two bounds; dates in order (2026-08-14 … 2026-08 is fine)
- in the last: N > 0 and a known unit
- choices: the ids, the flag's picked / rejected, a colour label's
custom name (from the catalog), copyright-status spellings
- text and keyword / people rules: something to look for, or "is empty"
- album: exists and is a plain album
- no empty groups (an empty rule list at the top stays "all photos")
album.createSmart, album.setRules and library.filter report every
problem. The editor's date defaults move from "" (which, as "between",
matched every dated photo) to the current year, and its test now
requires every default to be valid, except text and album rules, which
wait for input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crash fix. photo.setCaptureTime turned `shift` / `hours` into seconds
with a saturating cast and added them with plain `+`. A shift of 1e30
seconds overflowed. Only the last-resort panic guard caught it,
reporting "failed unexpectedly: attempt to add with overflow". A large
but not overflowing shift reached the catalog as a date like
-114079456599-08-23.
A shift that isn't a number or is more than 10,000 years either way is
now refused up front ("the shift is more than 10,000 years") and
nothing changes. The regression test requires that message, so a
caught panic no longer passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crash fix. A huge or negative "in the last N" from an agent or a saved
file ({"n": -1e30}) panicked with "attempt to negate with overflow" in
debug builds. Release builds wrapped instead, and the rule matched the
wrong photos.
- last_secs: a zero, negative, NaN or unreadable N is None (the rule
matches nothing, either way round). Spans are capped at 10,000 years,
which means "ever": every dated photo is in it and none is outside it.
- dates::shift_iso: checked_add, and a result outside years 0000–9999
(which iso_seconds can't read back) is None. It never overflows,
whatever the seconds.
Regression tests cover both and panicked before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Megapixels used the original width × height, while Long Edge, Short
Edge and Aspect Ratio measure the photo as shown (oriented and
cropped). One rule set could disagree with itself: a 24 MP photo
cropped to a 4000 × 4000 square had a long edge of 4000 but still
counted as 24 MP. Megapixels now uses Photo::shown_size too, so that
crop is 16 MP. Rotation doesn't change it.
Behaviour change: a smart album with a Megapixels rule can now leave
out heavily cropped photos it used to include.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the values now あり / なし, the labels "Has Edits" (編集あり) and
"Has GPS" (GPS情報あり) read "edits: yes … none". They are only
used as smart-album field labels, so they become 編集 and GPS情報 ("編集 …
あり / なし"). The untranslated In a Stack and Cropped become スタック and
切り抜き. A test requires every yes/no field to have a Japanese label
without あり / なし.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
localization.md explains "<context>|<English>" catalog keys and the one
context today (smart album value); localization-ja.md notes あり / なし.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yes/no rule values used the shared "Yes" / "No" messages, which
Japanese translates as the answers はい / いいえ. As the value of a
condition ("編集あり … いいえ") that reads oddly; あり / なし ("has" /
"has none") is natural.
Messages are keyed by their English text, so the same English word
can't carry two Japanese translations. A small context lookup fixes
that: tr_ctx_in tries "<context>|<source>" first and falls back to the
plain message. Only ja.json adds "smart album value|Yes" / "|No"; the
shared Yes / No (はい / いいえ) stay as they are elsewhere, and every
other language keeps its plain Ja / Sí / Да. A test rejects contextual
keys that name no real value, so a typo can't silently fall back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the editor rewrote a yes/no value that was neither yes nor
no (written by an agent or an older version) to true on every frame, so
the dialog never showed the problem and OK silently saved "Yes",
turning a rule that matched nothing into one that matches. The dropdown
now shows such a value as written and changes it only when the user
picks Yes or No. Until then the dialog reports the problem and OK
refuses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: album.createSmart and setRules refuse an unknown field, a
missing operator or a yes/no value that is neither yes nor no, but
library.filter applied such a rule set and showed an empty grid. It now
returns the same problems as an error and leaves the current filter in
place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: an agent sending a float (1.0) was refused while 1 was
accepted. Any number equal to 1 or 0 now reads as yes or no; other
numbers (2, 0.5, -1) are still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yes/no fields (Has Edits, Cropped, Has GPS, Virtual Copy, In a Stack,
Best of Similar Shots) showed "true" / "false" in the editor and
summaries, and Russian had no translation for either. Matching read the
value with as_bool().unwrap_or(true), so an agent's "false" (a string)
or any other non-boolean counted as yes, while the summary said
"false".
- catalog: bool_value is the one reading. true / "yes" / "true" / 1 /
no value mean yes; false / "no" / "false" / 0 mean no (strings in any
case). Anything else matches nothing, and RuleSet::problems reports it
("`edited` is yes or no, not \"maybe\""), so album.createSmart and
setRules refuse it. bool_label gives "Yes" / "No"; describe() reads
"has edits is yes".
- ui: i18n::bool_text for the editor's dropdown and translated
summaries (Ja / Nein…, existing keys in all locales). The editor
stores the value back as a plain boolean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the treatment choice "Color" reused the shared "Color" key,
which zh-hans and ru translate as the colour noun (颜色, Цвет) for the
Color panel, giving "Treatment is colour". The colour-label choice
"None" was masculine in Spanish (Ninguno) next to the feminine
"etiqueta". They are now "In Color" and "No Label", translated in all
seven locales (彩色, Цветное, Sin etiqueta…).
Also moves display_label_gaps_are_reported's doc comment back onto it;
the new choice test had been inserted between them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choice values showed their rule ids in English ("Copyright Status is
publicDomain", "Treatment is monochrome"), and Russian had no
translation for any of them. Kind::Choice now carries (id, label) pairs
like the operators: rules still store and accept the ids, and people
see the labels.
- catalog: choice_label(field, id); RuleSet::describe uses the label
("copyright status is public domain").
- engine: album.ruleFields keeps `choices` as ids and adds
`choiceLabels` {id: label}. A test pins the ids, since the new tuple
type would otherwise serialize as pairs.
- ui: i18n::choice_text is the one path for the editor's value dropdown
and translated summaries. Aspect labels are "Landscape (wide)" /
"Portrait (tall)", because the existing "Landscape" / "Portrait" keys
mean scenery and people. Seven locales gain Video, Copyrighted, Public
Domain, Unknown, the aspect labels and Square (ru also Raw). A test
requires every choice label to be translated in every language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The field-menu UI test now clicks a group row instead of hovering it,
guarding against the combo popup closing on that click (touch input,
fast clicks). default_values_make_valid_rules now checks each default
value's shape (none, pair, choice, number, a real shutter time) as its
earlier commit message claimed; before, it only checked operators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: `.hidden` reported the extension "hidden", so it matched
"File Extension is hidden". A name whose only dot leads has no extension
now. Photo::is_cropped uses the existing CropGeometry::is_identity
instead of comparing against a default crop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parity.md: LRC-LIB-SMARTCOLL lists the 47 fields by menu group and what
Classic has that is still missing. LR-LIB-SMARTALBUM no longer claims
"match-all only, no rule editor". What's New describes the grouped menu
and the new fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The any-text rule already searched the city and country but skipped the
state / province. It now also searches the alt text, the people on faces
and a virtual copy's name, so it agrees with the dedicated fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New smart-album fields, each in its menu group:
- Source: Copy Name, In a Stack
- File: File Extension (case and leading dot ignored), Video Duration
- Description: Alt Text
- Location: City, State / Province, Country (Location still matches all)
- Size: Long Edge, Short Edge, Aspect Ratio (landscape / portrait /
square within 1 %), measured on the photo as shown (oriented and
cropped)
- Develop: Cropped (crop or straighten), Treatment (color / monochrome)
Photo::extension, Photo::shown_size and Photo::is_cropped hold the
domain rules. A crop rectangle that is NaN or outside the frame is
clamped, so hostile settings can't produce NaN sizes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Camera Info gains Shutter Speed, compared in seconds and written as the
camera shows it: "≤ 1/60" finds faster exposures, "between 1/1000 and
1/125" works, and a value that isn't a time matches nothing. The editor
takes it as text (1/250, 0.5, 2").
The shutter parser moves from engine export into the catalog
(parse_shutter_seconds) so export and the rule share it. A new
rules-editor test checks that every field × operator default value
makes a valid rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Smart albums can test how many keywords a photo has ("2 or more", "2 or
fewer", "exactly 2" via the number operators), who is in it (People: the
names on face regions, with the keyword operators) and how many people.
Photo::keyword_count counts a hierarchical keyword once and ignores case
and blank entries; Photo::people is the domain rule for "people in a
photo" (named face regions, trimmed, case-insensitive, pets and unnamed
faces excluded), now shared by the existing person filter. Keyword and
people rules share one name-list matcher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule editor's field dropdown shows Rating, Pick Flag, Color Label and
Any Searchable Text at the top, then a separator and one submenu per
field group instead of one long flat list. The group holding the current
field is shown in bold. Picking a field from a submenu closes the menu
and keeps the operator only when the new field has it. Widget ids
ruleField / ruleFieldGroup / ruleFieldItem let the control channel drive
it, and group names join the i18n display-label gap report.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FIELDS is now listed in field-menu order, with TOP_LEVEL_FIELDS (rating,
pick flag, colour label, any searchable text) followed by FIELD_GROUPS
(Source, File, Date, Keywords & People, Description, Camera Info,
Location, Size, Develop, Assisted Culling). field_group / field_label
look a field up. album.ruleFields reports each field's group so agents
see the same structure as the editor. Rule ids are unchanged, so saved
smart albums keep working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lossless JPEG scans whose SOS gives predictor selection value 8 (which T.81 leaves undefined) are read as Hasselblad codes them (ljpeg::decode_hasselblad): 32-bit little-endian words read from their most significant bit, no byte stuffing; each row in pairs of samples, both Huffman categories before both differences; category 16 is followed by 16 ignored bits and means -32768; each sample is the previous sample of its column parity in the row plus the difference, modulo 2^16, both starting at 32768 on every row. Only the layout measured is accepted (one component, 16 bits, no point transform or restarts, even width); other layouts and selection values above 8 stay Unsupported. The rule was measured on 14 CC0 files with the ljpeg-explorer tool. RENDER_CACHE_VERSION 23.
scale_patch interpolated every JSON number as a float, including the
integer grain seed; the fractional seed failed to deserialize, merged()
failed and apply_partial returned the photo unchanged, so a preset with
the Grain group (in the default copy groups) did nothing at any amount
other than 100 %. Integer fields are now discrete like strings and
booleans; float settings and integer literals in preset files still
interpolate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interactive installer now walks Welcome -> Destination Folder ->
Ready -> progress -> Completed (from #79). Destination Folder is WiX's
InstallDirDlg: Program Files\LightCraft by default, Change... browses,
and WixUIValidatePath refuses a folder that can't be used.
The chosen folder is written to HKLM\Software\LightCraft\InstallFolder
and read back (into PREVIOUSINSTALLFOLDER, then INSTALLFOLDER after
AppSearch unless given on the command line), so a major upgrade stays in
the user's folder instead of moving back to Program Files. Silent and
passive installs show no dialogs and take INSTALLFOLDER=....
packaging-lint fails if lightcraft.wxs loses the folder page, the
remembered folder or the completion page's launch action.
Tested with wix 5.0.2 (x64, arm64, x86 build) and msiexec /qn: upgrade
from 0.4.0 stays in Program Files; a custom INSTALLFOLDER install, an
upgrade without it (stays), an upgrade with another INSTALLFOLDER (moves
there), and uninstall (files, folder and registry value removed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interactive MSI installs now walk Welcome -> Ready to install -> progress
-> "Completed the LightCraft Setup Wizard" (Finish) with "LightCraft has
been installed" text and a checked-by-default "Launch LightCraft"
checkbox. Failure and cancellation end on WiX's standard FatalError /
UserExit pages; silent (/qn) and basic-UI (/passive) installs show no
dialogs and launch nothing.
- lightcraft.wxs: custom UI set = WixUI_Minimal without the licence page
(MIT OR Apache-2.0 asks nothing of installers), maintenance dialogs for
repair/remove, ARPNOMODIFY. Launch uses the Util extension's
WixUnelevatedShellExec via Explorer (runs as the signed-in user, never
elevated), Return="ignore", only when NOT Installed and the box is ticked.
- package.ps1: add WixToolset.UI/Util.wixext 5.0.2 to the global cache,
pass -ext ... -culture en-us, and generate plain side/banner bitmaps
(brand blue / white) so the MSI carries no WiX sample artwork.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1c5feb7ab3)
A clipped raw pixel no longer records the scene's colour (one channel held at
the clip level turns a white sky magenta or blue through the matrix while the
camera JPEG keeps it white). As training pairs such pixels pulled the fitted
colour matrix toward grey and made the chroma curve remove colour at their
display luminance: on the CC0 Z 6II forest sample every chroma sample above
display luminance 0.36 was clipped, and the curve cut colourfulness there to
13-19%.
The proxies now carry a per-pixel clip mask (any covered sensor sample at or
above 0.99, aligned with the CR3 framing). The search on all pixels still
decides whether a photo gets a look and which attempt gives it; only that
attempt is refitted without the clipped pixels, and the look on all pixels is
kept when the refit misses the gates, so no photo loses its look or switches
attempt. Photos without a look get one when the search without the clipped
pixels finds it. Camera-profile pairs leave clipped pixels out too.
Render cache version 20 -> 22.
preset, background, resize, subfolder, conflict, tiffCompression and
dngCompression were in the export tool's schema (and so passed the new
argument check) but were dropped before reaching app.export.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
std::process::exit skipped eframe's on_exit (ui.json, close_library) and the
may_close check, so changes still in memory could be lost on Cmd+Q.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch ran its photos strictly one after another: each render is
row-parallel inside, but a photo's decode, its PNG/TIFF/WebP encode and
the wait on a GPU render are mostly serial, so a 32-core machine averaged
~9.5 busy cores on the CPU path and 1-2 with the GPU.
run_batch now keeps export_parallelism() photos in flight (cores / 4,
1..=4; one on wasm32) on scoped lane threads plus the calling thread.
Each holds an estimate of its working memory from a new export_gate
(twice the memory budget, separate from the decode gate it nests over),
so full-size 24 MP exports (~1.2 GB each) run two at a time by default.
Lanes run at most `lanes` photos past the last one placed, which also
bounds the finished files waiting for their turn. Paths, conflict
handling, original protection, writes, results and progress stay in
batch order (Placer), so the files are the same as one at a time; a
panic in a lane becomes that photo's error.
render_bench's batch scenario now goes through export_batch. 8 x 24 MP
JPEGs, 32 cores + RTX 4080 Laptop: CPU 3.87 s -> 2.35 s, GPU 4.21 s ->
1.82 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The earlier text quoted the 13 investigation files (held-out RMS within 0.005); the whole CC0 archive shows a worst increase of 0.013 and 7 files that need the conditional-median fallback.
With a CropAngle, Lightroom's CropLeft/CropTop and CropRight/CropBottom
are the top-left and bottom-right corners of the tilted crop on the
stored image, and its angle turns the other way to LightCraft's. The
import treated the edges as an axis-aligned rectangle in the rotated
frame and kept the angle's sign, so straightened crops came out the
wrong shape and size.
Carry the two corners through the orientation and then LightCraft's
straighten rotation (angle = -CropAngle, mirrored orientations flip it
back), using the stored image's aspect from fileWidth/fileHeight. On a
real catalog every one of 223 straightened crops now matches the frame
of Lightroom's export, and the content difference falls to that of
unstraightened crops.
Refs #483
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quantile matching fits the held-out pixels slightly worse than the conditional-median fit it replaced. On 7 of 1023 archive files (compacts, bridge cameras, early Micro Four Thirds) the look passed the acceptance gates only with the old curve, and the quantile curve lost it. The search now runs with the quantile fit first (all pixels, away from edges, with and without the camera profile) and, only when no look passes, runs again with the earlier conditional-median plus isotonic fit, restored as fit_tone_conditional. The gates are unchanged, so no file loses its look relative to the previous fit, and the quantile curve is used wherever it passes. Tests: the flat-step test stays; a synthetic photo whose JPEG carries per-pixel noise misses the gates with the quantile fit and passes with the fallback, and the order of the two is checked.
crs crop edges are normalized to the stored (unrotated) image, while
LightCraft's crop rectangle lives in the oriented frame. Catalog import
applied them unchanged, so crops on portrait photos (EXIF 6 or 8) landed
along the wrong axes.
Read Lightroom's orientation code (Adobe_images.orientation: AB, BC, CD,
DA and the mirrored BA, CB, DC, AD) and map the rectangle into the
oriented frame with Orientation::map_norm_rect; a mirrored orientation
also mirrors the straighten angle. The code comes from the catalog, so
this works while the originals are still missing at import time.
Refs #483
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Explorer only selects a file when the path after /select, is quoted. Rust's argument quoting wraps the whole "/select,C:\My Photos\a.jpg" switch in quotes as soon as the path has a space, so Explorer opened Documents. Build the switch as /select,"<path>" (backslashes, verbatim \\?\ and \\?\UNC\ prefixes stripped) and pass it with raw_arg on Windows. The library folder tree's context menu now shows the platform's reveal label too, and the Russian catalog gains "Show in Explorer" and "Show in File Manager".
Lightroom Classic catalogs store the crop edges and straighten angle in
their develop settings but, unlike XMP, never a HasCrop flag. The shared
crs mapper only applies a crop when crs:HasCrop is True, so every crop in
an imported catalog was dropped and reported as unmapped.
Infer HasCrop in the catalog path when any crop field is present. The XMP
path is unchanged: there HasCrop="False" with crop fields means the crop
is switched off.
Refs #483
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-photo look fitted its tone curve as the camera JPEG's median per
scene-luminance bin followed by isotonic pooling. On lens-corrected, sharpened
JPEGs of busy scenes neighbouring bins disagree by more than the gap between
them, so pooling produced flat steps: 13 of 31 segments on a CC0 Z 6II sample,
flat segments in 9 of 13 fitted Nikon Z / ILCE-7 samples. Each step maps a
range of scene tones to one output tone, which renders as posterised patches
with hard edges (issue #475, also #211, #454), and the conditional medians
flatten both ends of the curve.
Quantile matching (sorted scene quantiles against sorted JPEG quantiles) is
increasing by construction and insensitive to misregistration. On the same 13
files no fit has a flat segment, all still pass the gates, held-out RMS within
0.005 of before. Regression test: a misregistered synthetic JPEG.
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds first. LightCraft ships none, so it picked up another
program's copy; one without dxil.dll beside it (e.g. DXC 1.7) warns that
the DXIL is unsigned, wgpu treats that as a compile error, its
indirect-validation pipelines fail, the device is lost and the window
never opens ("Parent device is lost", exit code 1).
Every wgpu instance (window and both compute devices) now uses
lightcraft_gpu::backend::backend_options(): wgpu's env options with the
DX12 compiler pinned to FXC (d3dcompiler_47.dll, part of Windows), unless
WGPU_DX12_COMPILER asks for another.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-strip Sony ARW layout was accepted when the raw strip was 1.00-1.25x width x height bytes. The DSLR-A200, A230 and A350 sample files carry strips of 1.06x, 1.23x and 1.04x, passed that test and decoded as colour noise. Every file that decodes correctly (108 in the test archive) has a strip of exactly width x height bytes, so the layout is now accepted only then; the rest report "Sony ARW version 1 / packed compressed variant" and open from the embedded preview like the A290/A380. Part of #461 (not a decoder). RENDER_CACHE_VERSION 16 -> 19.
Renders a raw's DNG ProfileGainTableMap (Apple ProRAW's local tone mapping)
when the photo's Profile option "Camera local tone mapping" is on
(`profile.camera_local_tone`, off by default: Lightroom Classic renders
ProRAW without it). Built on #427's parser; supersedes the decode-time
applier of the first version of #273.
- raw/gaintable: GainTableMap::gain / evaluator (GainEval) per DNG 1.7.1:
bilinear between tables with edges replicated, input clamp(weights .
(R,G,B,min,max))^gamma in linear ProPhoto after the baseline exposure,
index input x MapPointsN clamped to the last point. Saturating cell
math; maps whose sizes don't match their gains aren't evaluated.
SourcePlacement: where a developed source sits in the active area.
- pipeline/local_tone: the gain per output pixel in the scene-linear
stage, before the white balance, mapped back through the frame (crop,
user orientation, warp) and the EXIF orientation. lin_needs_cpu routes
the stage to the CPU when it's on (CPU and GPU match); lin_key includes
it. SourceInfo::local_tone carries the map from the decoder.
- develop: Profile::camera_local_tone (omitted from JSON when off, so
existing settings and their hashes are unchanged); Profile row checkbox
when the source has a map; translations.
- RENDER_CACHE_VERSION 17. Docs: parity row and What's New as an opt-in.
Off renders pixel-identical to main on two iPhone 15 Pro ProRAWs (12 and
48 MP). On adds ~60-75 ms to the scene-linear stage at 1536x2048 on a
2-core VM, the same for both files (cost follows output pixels).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFqow2P1EtDxt6nRjjQoTA
Each RAW_SAMPLES entry now carries its SHA-256 (computed from the file as downloaded; the ProRAW value was only a comment). cargo xtask corpus --download hashes every file, new or already present, deletes any mismatch so the next run fetches it again, and fails listing the actual and expected hashes. A unit test keeps names unique and hashes well-formed.
The 512 floor and its ColorDataVersion 3 keying rested on one file.
Versions without a known black-level position now fall back to black 0
for the white level, so the 40D sRAW renders with slightly raised blacks.
The Huffman-compressed Pentax files (compression 65535) assumed BGGR from
the array origin. The K10D, K200D, K2000 and every body from the K-3 on are
RGGB, so red and blue were exchanged before demosaicing. They now use the
Exif CFAPattern from the first sample of the stored array, like the other
compressions; BGGR stays the fallback for files without the tag. Extends the
CFA test to compression 65535. RENDER_CACHE_VERSION 17 to 18.
An odd-height adaptive QP map (EOS R7: 4732 sensor rows, 1183 QP rows)
was rejected as unsupported, so R7 C-RAW files opened from their embedded
JPEG. The map decodes to the exact end of its entropy stream; the last
row of the second-level high-frequency bands has no partner row and its
QP is used alone. Even-height maps never reach this rule, so the six
verified M50/R100/R8 fixtures are unchanged.
Checked on paired R7 RAW and C-RAW captures (ISO 100 and 6400): all
six C-RAW files decode, developed images match their RAW counterparts
visually, and ISO 6400 sensor means agree within 0.1%.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs6roPby7THvteijZwptQt
The pattern was shifted by the image-area origin for compression 1 and
32773. The *ist D (area origin 19, 11, tag RGGB) fits RGGB from the array
origin in a mosaic-level fit against its camera JPEG, so the shifted reading
exchanged red and blue. The six 32773 files have an even origin and keep
their layout. Adds a hand-made-bytes test with an odd area origin for both
compressions.
1.Added Simplified Chinese font support for web and desktop builds.
2.Kept Inter as the default font, with Chinese used only as a fallback.
3.Verified Chinese glyph tests and a macOS universal build.
Signed-off-by: supper_yijie <syj794844854@163.com>
Samsung SRW raws whose raw IFD (private compression 32769 or 32770) stores the samples uncompressed are decoded from the sensor data: 12-bit samples packed two in three bytes (bit order told apart from the samples; NX5, NX10, NX11 and NX20, NX200, NX210, NX1000, NX1100) and 16-bit little-endian words (EX1, WB2000). CFA layout from the raw IFD's CFAPattern; framing, white balance, black and saturation levels from the maker note (value offsets relative to the note; the vectors are stored offset by its EncryptionKey); a one-direction Exif picture size crops to the centre of the framing; black from dark rows or columns when the file states none; saturation plateau below the stated level wins. Compressed codings (32770 with fewer bytes, 32772, 32773) stay preview only with that reason. srw is imported from folders; SRW takes the file-local camera look. RENDER_CACHE_VERSION 17.
sRAW and mRAW store subsampled YCbCr in a lossless-JPEG frame cut into
slices of whole MCUs. Reuse the subsampled lossless decoder added for
Sony M/S ARW with Canon's per-component sequential prediction, re-chop
the stream into image planes, and convert to linear white-balanced RGB
with chroma gains measured per file against the camera's own IFD2 RGB.
Render cache version 17.
Compression 32773 is accepted in the PEF reader only when every strip has exactly the packed size of its rows at the declared bit depth, and is then read as uncompressed MSB-first samples. Any other 32773 size stays unsupported. Uncompressed PEFs take their colour filter from the Exif CFAPattern as seen from the image area origin. A Samsung-branded TIFF whose maker note has the Pentax layout is probed as PEF. RENDER_CACHE_VERSION 16 to 17.
The unknown-tab error now names every Settings tab (it missed AI Denoise after the
faces merge). lightcraft-sam3-worker (#359) only builds its server off wasm32, so
`cargo xtask wasm` passes again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every demo-photo source load evaluated the procedural scene from scratch. A 24 MP demo
original costs ~200 CPU-seconds (AlpineLake, the photo the Detail tests open), and #323
added ~30 loupe tests that each load it at 1:1, Before and After separately. On the 4-vCPU
FreeBSD VM the ui-egui lib tests went from 1198 s to 3046 s, and the tests' 120 s/300 s
waits expired (loupe windows, fixture thumbnails starved of the shared rayon pool, the demo
detail snapshot).
Demo scene pixels are now generated once per (scene, size) and shared: concurrent loads
wait for one generation (Before/After at 1:1 no longer generate the original twice) and
the most recent results are kept up to half the memory budget. Locally, pinned to 4 CPUs,
the ui-egui lib suite now finishes in 155 s; without the change it had done 231 of 338
tests after 25 minutes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
### Summary
When selecting "Quit LightCraft" from the native macOS menu bar, the application occasionally crashes with an `EXC_BAD_INSTRUCTION` (SIGILL) error report during shutdown.
### Root Cause
1. The native menu action triggered `ctx.send_viewport_cmd(egui::ViewportCommand::Close)`.
2. Forcing an explicit viewport close via the command queue while macOS and AppKit are initiating their own termination sequence creates a race condition with `winit`.
3. This conflict during native window, view, and observer teardown leads to an illegal instruction fault.
### Solution
- Replaced the manual `ViewportCommand::Close` trigger on `QUIT` with a clean `std::process::exit(0)`.
- This bypasses the problematic `winit`/AppKit teardown race condition on macOS, allowing the application to shut down instantly and cleanly without generating crash reports.
One config-dir helper (engine::config::config_dir; camera_profiles re-exports it) with both
the face-models and denoise-models defaults; hosts set up both model folders; Settings has
Faces and AI Denoise tabs; both command sets registered; both model dialogs; locale entries
from both; one [features] table per app manifest; Cargo.lock regenerated by cargo; the
DecodedSource cache cost counts both segmentation mattes and the denoised twin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TIFF raws that describe a full-resolution CFA IFD with DNG's tags (levels, crop, AsShotNeutral) but carry no DNG version tag are read by the DNG reader (RawFormat::CfaTiff, dng::decode_as). RGGB when the file states no CFAPattern. Lossless JPEG scans with a predictor selection value above 7 are rejected with that reason and stay preview-only. .3fr and .fff are imported from folders. RENDER_CACHE_VERSION 17.
Decode every JPEG XL tile with wide (32-bit) modular buffers: the
modular_16bit_buffers header flag does not hold on real lossy files (68
of 108 tiles failed with narrow buffers).
Lossy (XYB) tiles are taken from the decoder as linear sRGB primaries
and restored to the declared primaries with a plain 3x3 matrix, instead
of the decoder's gamut-mapped, [0, 1]-clipped conversion. Lossy tiles
whose declared encoding cannot be restored faithfully (ICC profile,
non-linear transfer function, non-D65 white point, intensity target
above 255 nits, single plane) are unsupported. Lossless output is
unchanged.
A JPEG XL tile that fails to decode is now an error for the image
(the file falls back to its embedded preview) instead of a zero tile.
Render cache version 17.
The worker binary gets the never-crash deny list and forbid(unsafe_code) like
every production crate root. docs/ai-masks.md spells out who can reach the
unauthenticated loopback listener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the docs/parity.md conflict, keep the HEIC rows. `cargo xtask ci` gets
a heif step (clippy + tests for lightcraft-codecs with --features heif), since
the workspace build leaves the feature off. The Cargo.toml comments no longer
say official builds enable it; the web one notes that wasm32 aborts on panic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Canon maker note's AspectInfo (tag 0x009a: aspect code, cropped width,
height, left, top) holds the rectangle of the shot, measured from the
recommended image crop. Use it as the CR3 default crop, so 3:2, 4:3, 16:9 and
1:1 shots develop in the shape the photographer chose. The tag is ignored when
it is missing, does not fit, or the recommended crop itself is inconsistent.
RENDER_CACHE_VERSION 15 -> 16. The M50 C-RAW corpus sample (raw.pixls.us 2663)
was shot at 1:1 and now develops at 4000 x 4000.
Port the original Escape return and last-person shortcut behavior, keeping current translations and a no-wrap chip. The existing headless regressions and all seven quality gates pass with the hosted fonts on the refreshed recognition dependency chain.
Generated with Codex.
Keep current upstream UI behavior and the later detector contract checks. Validate candidate models before replacing working weights and records, with a synthetic regression that failed before the fix. Remove the unrelated GPU equivalence-test relaxation. All seven quality gates pass with the pinned hosted fonts.
Generated with Codex.
Photo > Detect Faces runs the YuNet detector (a small pure-Rust interpreter over the ONNX graph, no
runtime dependency) and adds the faces it finds as unnamed face regions in one undo step. The detector
and the recognition models are opt-in downloads: Settings > Faces shows each model's licence and
training-data note, Download stays disabled until the terms are accepted, and the file is fetched over
https with lightcraft-fetch from the model's own repository at a commit-pinned address, checked against
its recorded size and SHA-256 and then installed. Detect Faces offers the detector's download the first
time it is used. No model file is part of the repository (the real-model tests read LC_YUNET_MODEL; two
ignored tests download from the real hosts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recover the unpublished denoise work against current upstream. Preserve later opt-in model setup, shared Rust HTTPS downloads, pending-photo resume and an Amount-independent switch. Keep original RAWs unchanged and share the current color finish between ordinary and denoised sources.
Bound public tiling inputs, refuse corrupt-cache exports, and retain synthetic/real-model coverage. Include two Windows path spelling fixes in existing tests. No model weights, face code, media or Tract runtime are included.
Validation: cargo xtask ci (all seven gates); installed RawNIND CPU/GPU agreement 1.38e-6; native CPU preview/export on a public 24 MP Sony RAW, unchanged original SHA-256.
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Generated with Codex.
Keep upstream tests and the fixed SAM worker, refresh the workspace version and parity summary, and route the model picker through the case-insensitive native filter helper. Carry the shared translated font-symbol correction; validation includes the pinned hosted fonts and all seven CI gates.
Generated with Codex.
The model list marked the YuNet detector as bundled with LightCraft and installed, but no model ships
inside LightCraft (the weights are opt-in downloads or files the user adds). Drop the notion: a
model is installed when its folder is, it can be removed like any other, and the licence notice no
longer says it is bundled.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An off-by-default "Recognise faces" toggle and the face model list in Settings,
with a one-drop install: pick a .onnx (Add a model file…) or drop one on the
window. The engine inspects it (recognised by hash, or described from its
shape), a dialog shows its licence, whether commercial use is allowed, what it
was trained on and what LightCraft had to assume, and Install stays disabled
until "I accept" is ticked. "Get…" opens a model's own page in the browser:
LightCraft never fetches a model itself. A file it cannot use says why.
The models folder is shared by the app, the CLI and the MCP server
(`engine::config`, `$LIGHTCRAFT_FACE_MODELS` overrides `<config>/models`), so a
model installed from one is there for the others. A session has none until a host
asks (`with_default_face_models`), which keeps tests hermetic.
Nothing runs a model yet, and the tab says so. Headless tests cover the whole
flow (the dialog, refusing until accepted, installing, a file it cannot use).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`faces.models.list / inspect / install / remove / select` and `faces.enable`, all
reachable from the UI, CLI, control channel and MCP. Models live in a folder the
host provides (`Session::face_models_dir`): one subfolder each with the model, its
manifest and a record of what the user accepted, plus settings.json.
- install needs `acknowledged: true` (the user has been shown the licence), copies
the file under a .part name, checks the copy's SHA-256 against the original, then
renames; a failure leaves nothing behind.
- ids are validated before they become folder names (and a manifest that disagrees
with its folder is ignored); remove only ever deletes a folder that has a model
manifest. A manifest id can no longer be `.`, `..` or start with a dot.
- inspect describes any .onnx without writing anything; unusable files are an
answer ("unsupported" with the reason), not a crash.
- the list says plainly that running a model is not built in yet.
`faces::synthetic` makes a minimal valid ONNX for tests (and the later self-check).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
New L1 crate `lightcraft-faces`, the base of a one-drop model install:
- `ModelManifest` (role, licence with a commercial yes / no / unknown flag and a
plain-language notice, provenance, input preprocessing, output, thresholds)
with strict validation: sizes capped, numbers finite and sane, https-only
addresses, bounded text. A manifest from any source is untrusted.
- `known`: models recognised by SHA-256 (YuNet, SFace, AuraFace v1), with what is
and is not known about their licences and training data.
- `onnx`: a streaming, hostile-input-safe protobuf probe of a model's graph
inputs and outputs. It reads a 248 MB model in about 4 ms and never loads the
weights; truncation at every length and random corruption never panic.
- `suggest`: known hash -> its manifest; an unknown model shaped like a face
embedder -> a draft manifest with the assumptions spelled out; anything else
-> a plain reason it is not supported yet.
- `hash`: streamed SHA-256.
Checked against the real model files with an opt-in test (LC_FACE_MODELS). It
found an old exporter listing every weight as an input, now handled. Running a
model (tract) is not part of this crate yet.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
a_window_job_crosses_the_wire_and_renders_the_same_window compared the worker's
CPU render with the engine job's render bit for bit. On a machine with a GPU the
engine job renders there, so the test failed by 1 LSB (on main too); without a
GPU (CI) both run on the CPU. It now checks both sizes and a maximum channel
difference of 3, the bound docs/gpu-pipeline.md documents.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In NRW data version 0104 the u32 at byte 0x20 is the black level in sample units: 200 where the darkest samples of the B700, P950, P1000 and P1100 sit, 0 for the A1000 whose samples reach 0. Used when the maker note has no 0x003d.
Raw-IFD flag 0xf006 = 1 marks RAF samples packed MSB-first into 32-bit little-endian words at any depth; 12-bit files with it (FinePix S1, X-A10, SL1000) were read as an LSB-first stream. Nikon uncompressed 12-bit strips of exactly 1.5 bytes per pixel in a file whose maker note has an NRW data block (COOLPIX B700, P950, P1000, P1100) use the same words in file byte order; without the block (E5700, E8400, E8800) they stay an MSB-first byte stream. Shared unpack_words32_msb and Packing::Words32Msb. RENDER_CACHE_VERSION 15 to 16.
A custom white balance on a raw with a colour matrix (DNGs, and other raws
without a file-local look) re-develops the pixels for the chosen white
through the file's own colour model: lightcraft_raw::color::rebalance
re-evaluates the camera transform at the new white and maps the as-shot
development onto it, so a neutral lit by that white renders neutral and
saturated colours move as the camera's matrices say (DNG spec ch. 6, as
Lightroom does). The decoder passes the colour tags and the white the
pixels were developed for in SourceInfo::camera_color; other photos, and
relative white balances, keep the Bradford adaptation. The eyedropper and
Auto invert the same model (color::neutral_white), so a picked grey card
renders neutral.
The Tint sign is main's (#349): unchanged here. Render cache version 15.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ColorData::baseline_sharpness holds the file's BaselineSharpness (raw IFD
or IFD 0; zero, negative, non-finite or above 16 is ignored) and the DNG
writer writes it back, so a conversion or smart preview keeps it. Merge
outputs leave it unset. Rendering doesn't use it yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read DNG 1.6 semantic masks (SemanticName, SemanticInstanceID,
MaskSubArea; any coding the tile/strip reader decodes, e.g. ProRAW's
lossy-JPEG matte) with lightcraft_raw::semantic_masks, map them onto the
developed image, and carry Apple's sky / portrait / skin / hair / teeth /
glasses mattes in SourceInfo::mattes (SourceInfo is no longer Copy).
Sky, Subject, Background and People components sample them through
orientation, warp (Warp::frame, as geometry.rs) and crop like the image,
so preview = export, instead of the heuristics; files without mattes
render as before. Malformed mask IFDs are skipped, and only the first
MAX_MASKS mask IFDs are decoded, so a file full of them costs bounded
work. The render cache version is bumped once, in the white-balance commit.
The corpus test reads corpus/raw/dng-apple-iphone12pro-proraw.dng, the
name `cargo xtask corpus` downloads it under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parse ProfileGainTableMap (DNG 1.6) and ProfileGainTableMap2 (DNG 1.7):
both versions, DNG 1.7 precedence (version 2 in IFD 0, then the raw IFD;
version 1 in the raw IFD, then IFD 0), u8/u16/f16/f32 gains, gamma.
Malformed tags (wrong size, zero or huge grids, non-finite or negative
gains, bad spacing or gamma) are ignored, never allocated for.
Byte order: the specification gives this tag no byte order of its own,
unlike opcode lists, so the payload is read in the file's byte order with
a big-endian fallback; a wrong-order read can't pass the size checks.
The module docs quote the DNG 1.6 "Byte Order" rule.
The map is kept in ColorData, written back by the DNG writer (version 1,
or version 2 when its gamma isn't 1) and dropped from merge outputs, whose
geometry no longer matches the capture's active area. It is not applied
to the render: Lightroom Classic renders Apple ProRAW without it.
Supersedes the parser of #273.
The CC0 iPhone 12 Pro ProRAW from raw.pixls.us joins the corpus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compression 52546 tiles and strips decode through jxl-oxide (new
default-on `jxl` feature of lightcraft-raw): bare codestreams and
ISO-BMFF containers, 1 or 3 planes, 8-16-bit integers scaled back
exactly from the codestream's own bit depth, 16-bit floats. Edge tiles
may be coded at their nominal or in-image size. Headers are checked
against the IFD in header-only probing too. A single-chunk JPEG XL
preview IFD (RGB or grey) is offered as the embedded preview.
The decoder's memory is bounded by the tile: 16 bytes per sample (three
times that for a lossy grey tile, which the decoder expands to three
channels) plus 64 KiB; jxl-oxide 0.12 was measured at <= 10.3 bytes per
sample for lossless and <= 13.5 per colour sample for lossy tiles. A
codestream needing more (e.g. extra channels) is an error.
Lossless tiles are checked bit-exact on synthetic CFA and LinearRaw
files (zune-jpegxl as a test-only encoder); lossy tiles are unverified
on a real file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It replaces the strip under the tree, which could be scrolled out of view
and shifted the rows when it appeared; the header is always visible and
nothing moves.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dropping on the edge of a row places the album there (album.reorder) with
a line showing where; a folder's middle still means inside. A closed folder
opens only when the drop would go inside it. Sort Contents A–Z and Sort
Albums A–Z (album.sort) appear once a folder is ordered by hand.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Place an album or folder before a sibling of its kind (also in another
folder), or last of its kind, in one undo step; album.sort goes back to
by name for one folder.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Album.order and Op::SetAlbumOrder; album_children lists folders first, then
by place, then by name. Moving an album drops its place and undo restores it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A folder under the pointer is outlined when the drop is allowed and opens
after resting there; a drop zone under the tree takes an album back to the
top level. The move is album.move, so it is one undo step.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Show a dialog's error and keep it open instead of closing it, open the
folder menu from the triangle too, draw a triangle only on folders that
hold something, reveal even when the section is folded, reuse the
translated Create … labels, and fix a misplaced doc comment.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The dialog.new* commands take the folder to create in, the dialogs
remember it, and the Albums tree opens the folders down to the new row.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- With no window (a mask or point-colour overlay, Visualize Spots, soft
proofing, a window refused for a far read) the loupe renders the whole
frame at the size it is drawn. While a slider drags that meant the
photo's every pixel each draft frame at 1:1 and beyond (24 MP per tick
where 2.5 MP was rendered before #351; mask overlays are on by
default). A drag now drafts at most the canvas size, as the windowed
path does; at rest the zoomed frame is rendered as before.
- The Preview size hint in German, Spanish and Russian; the replaced
'Largest long edge…' text removed from those catalogs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The desktop app keeps logs/lightcraft.log in its settings folder (#286)
and a failed start names it (#370), but a user still had to find the
folder by hand. The host now hands the UI its log file (Services
.log_file) and Help ▸ Open Log Folder (app.openLogFolder) reveals it
through the existing reveal service: Finder and Explorer select the
file, Linux opens the folder. Off where no log is kept (the web,
--memory, LIGHTCRAFT_NO_PREFS). Translated in de, es, ja, pt-br, ru,
zh-hans and zh-hant; parity row MENU-HELP-LOGFOLDER; What's New and
README note it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With Shader Cache Size disabled in NVIDIA Control Panel, the driver walks
the whole system drive inside D3D12CreateDevice (traced by the reporter
to NvMemMapStoragex.dll!TotalDiskUsage), before the window's first
frame; Driver Default fixes it (confirmed in #374, PhotoCraft too). The
launch-argument walk found on the way was fixed in #394.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The desktop showed rfd's synchronous dialogs on the UI thread, so the
window stopped answering the compositor while one was up: GNOME marked
LightCraft "not responding", the File menu stayed drawn and the control
channel went quiet. Services.picker lets the host start a dialog
elsewhere and answer through a channel; a command that needs a path
asks for one and returns, and runs again with the answer when the
dialog closes (pick.rs). Import Photos, Import from Folder, Open
Library, Import Lightroom Catalog, Find Missing Photos, the preset and
curve-preset imports and exports, Auto-Tag from Tracklog and Locate
Missing File use it; hosts without a picker (the web, tests) keep the
synchronous dialogs. ui.inspect lists the waiting commands as
fileDialogs. Same cause as storytold/photocraft#574.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A full-size load asks the decoder for the largest box (`SourceLevel::Full` is
`usize::MAX`, cast to `u32::MAX`). `jpeg::decode` then computed `2 * u32::MAX` to
decide on DCT scaling: an overflow panic in debug builds (caught, so every
full-size JPEG load failed with "decoder panicked"), and a wrap that happened to
give the right answer in release. The comparison saturates now, and the engine
converts its `usize` edge with `u32::try_from` instead of a truncating `as`.
Found decoding camera JPEGs (no JFIF/EXIF header) at full size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic reads `ToneCurvePV2012Red/Green/Blue` only together with
`ToneCurvePV2012`: a packet with a channel curve and no master curve leaves the
photo unchanged there (rendered on a chart: the channel curve alone has no
effect, with an identity master curve it applies). Imports now do the same.
The preset-packet test helper uses made-up as-shot values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XMP presets usually say LensProfileEnable=0, which turned optics.lens_profile
off and with it the DNG-embedded WarpRectilinear / FixVignetteRadial
corrections (iPhone ProRAW and other files that carry them). Lightroom
applies a file's built-in corrections whatever that switch says (our
inference), and LightCraft's profile corrections are only those embedded
ones, so crs import now carries over only an enabling switch. The Enable
Lens Corrections checkbox and the amount sliders keep working as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XMP presets carry fields that describe the preset or the photo rather than
an adjustment (Cluster, SupportsAmount2, RequiresRGBTables, SortName,
Description, CropConstrainToWarp, OverrideLookVignette, AsShotTemperature /
AsShotTint). preset.import listed them as settings that couldn't be carried
over; so did an HDREditMode of 0 and PointColors slots that are all -1.
They are now treated as bookkeeping; an HDR edit, a used Point Color slot and
unknown adjustments are still reported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clippy 1.98's manual_chunks lint fails `cargo xtask ci` on main at psd.rs:170/191/201
(chunks_exact with a constant size). The fixed-size chunks also drop the per-sample
indexing. No behaviour change; the codecs tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CR2 and PEF decode without a camera colour matrix, like ARW, NEF, RW2,
RAF and CR3, but camera_preview::file_local_look (and the catalog's
relative white balance) left them out, so they rendered with the neutral
fallback matrix: the camera preview showed first, then a flat,
desaturated render replaced it. They now use the same guarded fit and
relative WB; a fit that fails the gates keeps the fallback, so a file can
only stay as it was or move closer to its camera JPEG. Not measured on
the corpus here (raw.pixls.us is blocked from this environment).
RENDER_CACHE_VERSION 15 re-renders cached CR2/PEF thumbnails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
egui keeps a popup inside the window, so a menu taller than the room
under the bar (Photo, on a short window) was slid up over the bar: the
titles vanished under it, and the pointer left on the title rested on
the first row and opened its submenu by itself. Every level now gets
the room it has below the bar (a submenu: from its row down, or up to
the bar when egui opens it upward) and scrolls inside it, with wheel,
drag and ▲/▼ strips that scroll while the pointer rests on them, as in
PhotoCraft's menu bar (storytold/photocraft#337). Menu levels and
submenu rows are registered for the control channel and tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A review measured 1 GB of GPU stages for a Before/After at 1:1 on a 2800 px
canvas against a 400 MB budget, with nothing bounding it. The open photo's window
and before caches are now cut (the before side first) past four times the budget,
the whole-frame view's never; idle caches still go at the budget. The one shared
device copy of a big original is reported (sharedSourceBytes in the renderer's
memory report) and a misleading comment about it is corrected.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
At first open of Before/After the two window jobs were asked for together, each
decoded its own 24 MP copy of the original (a review saw two decodes and three
uploads for one photo) and the shared device copy was swapped between them. The
Before's window now waits until the After's job has handed the original to the
session (region::defer_before_window, Session media has_source); later windows
of both share the one copy.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The window of the Before view alone was clipped to the wipe's left half while the
picture drawn was the Before on the whole canvas, leaving the right half the
low-resolution stand-in. The clip follows the effective mode.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A review showed the wipe test asserted on a hand-written list of labels (deleting
the stand-in draw calls still passed) and nothing tested the clip at the call
site (replacing it with the canvas still passed). The tests now read the meshes
the frame painted: the Before window comes after the stand-in and stays inside
its side of the line, and side by side each window is painted inside its pane.
Both mutations of the code now fail them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A review measured the drag at 100 % on a 2800 px canvas taking 87 ms a tick
against 24 ms with a bigger budget: the open photo's caches were over the budget
(a 5 MP window alone is 230 MB) and a "hard" budget cleared them every frame, so
every tick rebuilt them. The whole-frame, zoom window and before caches are what a
drag reuses; only idle caches (hover, compare tiles, second window) are trimmed
now, the least useful first, and the open photo's are counted and reported. The
drag test no longer waits on a tick count.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keeping a 288 MB upload with each view's stages made them thrash against the stage
budget; not keeping it made every new window (a pan crossing a snap, the first
window of the Before side, any control that invalidates the sampled pixels) upload
and convert the source again, about 64 ms against 6-12 ms. A source too big for a
view's stages is now kept once for all views of the photo (shared_source), outside
the views' stages, and given back when the app idles (trim_pool(0)).
source_uploads and shared_source_bytes expose it for tests and diagnostics.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review findings on region::plan:
- a drag at fit on a 4K or 5K display switched the window off (the whole-frame
draft shrinks, the preview-source cap does not): the window decision now
compares against the undrafted whole-frame render;
- overlays, soft proofing and windows refused for reading too far (a spot or Auto
Mask stroke) left the 1:1 view a canvas-sized upscale: those modes render the
whole frame at the size it is drawn, as before windows (a refusal is remembered
per photo, look and frame, and retried when any changes);
- the 3840 and 5120 preview sizes had become no-ops: a limit above the preview
source level is honoured;
- windows were limited to 8192 px frames: they now reach the photo's own pixels
(65536 px at most), so a medium-format photo is sharp at 1:1;
- a pinch out from a deep zoom asked for an ever wider window every frame: windows
already there are kept, magnified, until the gesture has been quiet.
The unused region::needed is gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found that (1) in a wipe the Before window was drawn
before, so under, the low-resolution Before stand-in; (2) the windows of side by
side and stacked views were clipped to the canvas, so their margins spilled into
the neighbouring pane; (3) with identical Before and After looks (a fresh,
unedited photo) both windows had the same job key and the After tile replaced the
Before one in the tile table. Windows are now drawn over the stand-ins and under
the wipe line, clipped to their pane or their side of the line (region::window_clip,
pure and tested), and tiles are keyed by side and job key.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
profile_slider_drag prints the milliseconds from a slider tick to the loupe
showing it at fit, 100 % and 400 % of the demo library's 24 MP photos.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A drag supersedes every request before it finishes, and the source a superseded
draft had decoded was thrown away with it, so a window cut from a full-size
original decoded it again for every tick. A view slot's superseded draft that is
newer than what the view shows now also hands its source to the session (late
results of other slots stay ignored, as before). Render results become textures
with a plain copy for the opaque case instead of egui's per-pixel premultiplying
conversion, on the UI thread.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Profiling a slider drag at 100 % and 400 % found each tick of the window render
taking about 60 ms: the 24 MP source (288 MB) uploaded to the device was kept with
the view's stages, counted against the stage budget, and the stages cleared every
frame, so every tick uploaded and resampled again. A source over 96 MB is now
uploaded for the render only; the stages keep the window's own sampled pixels.
A tick costs 7-12 ms instead. equivalence.rs covers it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A continuous trackpad pinch changes the zoom every frame, and with it the sizes
the loupe plans, so it would have been a stream of re-renders of every size in
between. SizeHold (pure, tested) keeps the plan from before the gesture while it
runs and for a quarter of a second after; the view is drawn from the renders it
has, magnified by the GPU, and planned afresh when the gesture is quiet (a repaint
is asked for at that moment). Another photo resets the hold.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Before image was a stand-in capped at 2560 px beside an After at full
resolution, so a 1:1 comparison misled. Both sides now get the same treatment:
the same whole-frame size and, once zoomed, a window each (Slot::RegionBefore
beside Slot::Region) cut from the same frame, in side by side, top/bottom, the
\ original view and both wipes (the Before window is clipped to its side of the
line). Each side's windows and tiles are tracked apart, a side is freed when it
is no longer shown, and ui.inspect reports regionBefore.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The loupe's, the zoom window's and the before / hover renders' stage caches hold
decoded-image sized buffers that the engine's budget did not count. A quarter of
the memory budget is now theirs: each frame the renderer clears what exceeds it,
the least reusable first (hover, before, compare, second window, the zoom window)
and the open photo's own view last (stage_trim_order, pure and tested). The
inspector's memory report shows the budget and how often caches were trimmed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
At 8:1 on a 24 MP photo every drag frame rendered the whole 24 MP frame, and a
Retina fit view (about 2800 px) decoded the original of every photo stepped to.
A new render plan (region::plan, pure and tested) makes the cost follow the
canvas, not the zoom:
- the whole-frame render is canvas-sized and never above the preview source
level, the preview size limit or the texture side (a drag drafts that);
- once the view is zoomed past it (by more than 25 %) the visible window is
rendered, cut from a frame that is at most the photo's own pixels, so beyond
100 % the GPU magnifies it instead of a bigger render being made; this also
ends the duplicated second render above 100 %;
- a window is drafted during a drag and a draft of an older value still draws;
- a user who capped the preview size keeps the whole-frame render at fit;
- no window where the picture on screen isn't the frame's own aspect (an
unsupported raw's embedded JPEG cropped differently, #280).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Automatic preview size let the loupe texture reach 8192 px on every
platform. The browser build draws with egui_glow, which panics on a texture
larger than the GPU takes, and many WebGL devices allow only 2048 or 4096.
Every render size the loupe asks for (loupe, neighbour prefetch, hover and
Before stand-ins, compare tiles, the second window) and every zoom window is now
clamped to the context's max_texture_side (2048 when the host reports none,
512 at the least). Headless drivers can pretend a small GPU for tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review findings: window growth for spots was capped silently and then rendered
wrongly; Auto Mask growth took the whole stroke's bounding box; hidden masks grew
windows; the wasm window memory cap was not enforced. window_for_reads_checked
returns None when what a window reads would need more than 8192 px (4096 on
wasm) along an axis, Session::region_job returns None then (the loupe keeps the
whole-frame render), Auto Mask growth is per dab near the request (once, not
chained along the stroke) and ignores hidden masks. Docs say what the limits are.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found that sizing the loupe render from the cropped frame
(previous commit) left the source level chosen from the output edge alone: a
crop whose own long edge is under 2560 px was drawn from the 2560 px preview of
the whole photo, soft though sized right. The level now follows the pixels the
output needs along the source's long edge (output edge / crop fraction); grid
thumbnails keep their cheap level.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The GPU renderer renders windows (no more CPU fallback): the vignette gets the
window's offset and the whole output's size (VIG_VIEW), the airlight and noise
reduction scale come from the whole frame, and a window grown for spots or
strokes is cut back after the readback. equivalence.rs renders every settings
case as a window plus a corner vignette and a spot reading from outside, within
the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read
the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its
position at f32 precision, like the GPU kernel: an f64 rounding error that
depended on the window's start could put a boundary dab on either pixel.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
WireJob carries the request's window (an old job without one has none), and the
browser build asks for window renders like the desktop; windows there are capped
at 3072 px a side to respect wasm memory.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spot removal reads outside the spot and its automatic source depends on the
pixels at hand. A windowed render now fixes automatic sources from a fixed-size
render of the whole frame and grows the window it works on to hold every spot
that touches the requested one and the patch it reads (bounded at 8192 px),
cutting the result back to the request, so photos with spots get sharp windows
too. The wide stages (dehaze, clarity, highlights/shadows) are checked at depth
on hard-edged blocks: the cut kernels cost at most a few levels, so they are not
computed from a whole-frame proxy.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fit_rect measured Percent zoom against the uncropped, unrotated photo, so a
cropped photo at 1:1 showed stretched and a rotated one used the wrong axis. The
loupe, its neighbour prefetch and the compare view now use the frame's
cropped-and-rotated size, which also stops a small crop being rendered above its
own pixels.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found that a slider drag at any zoom started a window
render (the 0.6-scale draft looked stretched), that a tile made for an earlier
look kept being drawn and that the tile table was cleared mid-drag, that the
fixed 256 px margin was far below the wide stages' kernels at depth, that spot
removal reads outside the window, and that the window texture stayed resident
after zooming out. Window renders now wait for the drag to end, a tile is drawn
only while photo, zoom and look match, the margin grows with the zoom (768 px
cap) and the window is capped at 6144 px, photos with spots keep the whole-frame
render, the texture and stages are freed when unneeded, and wasm never asks.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found local::denoise and Refine Edges scaled with the
window's own size, so their strength changed with pan and zoom. Frame::output_long
gives the whole output's long edge to both; tests on a noisy source cover them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
When the whole-frame render is smaller than the zoomed view (a 1:1 or 8:1 view
of a large photo, or a capped Preview size) the loupe also requests a window
render (Slot::Region) of the pixels on screen plus a margin, and draws it over
the whole-frame render at its place of the frame. Stateless, grid-snapped
windows mean small pans reuse the render. Not used in the browser build, while
comparing, hovering a look, soft-proofing or showing an overlay. ui.inspect's
loupe reports the window.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Session::region_job renders one PixelWindow of the loupe's frame from the source
level that frame size needs, with a key of its own and no cache writes.
ui-egui::region decides when a window is needed (the whole-frame render is
stretched) and which one: the visible pixels plus a margin, snapped to a 256 px
grid so small pans reuse the render, bounded in size, clamped into the frame.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame
texture stretched over them. RenderRequest gains an optional PixelWindow of the
virtual full output that max_w x max_h describes; only the window is
materialized. It is Frame:🪟 the crop rectangle narrows to the window
(flips as drawn), so sampling, masks, spots, red eye and the lens warp need no
change. The post-crop vignette keeps the whole frame's geometry (Frame::view)
and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a
window matches the same pixels of the full render. The GPU renderer declines
windowed requests (CPU for now).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found that with Automatic the neighbour prefetch asked for
the full source level, whose single cache slot then evicted the open photo's
source, and that hover / Before renders and every window-resize pixel produced
full-size renders. Prefetch and stand-ins are now capped at the preview source
level and the loupe size is rounded up to a 64 px step.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The loupe capped every render at the "Preview size" setting (default 2560 px),
then stretched the texture over the on-screen rect, so 1:1 and Retina fit views
of larger photos looked soft or pixelated.
The setting is now a limit: Automatic (default) renders at the drawn size, up to
the photo's own pixels and an 8192 px ceiling; 1600–5120 px still cap it. The
domain rule is AppSettings::loupe_edge (NaN/zero/huge inputs are clamped). The
saved key changes from previewEdge to previewLimit so existing installs, whose
2560 was the old default, move to Automatic. Build Standard-Sized Previews keeps
a numeric edge via standard_preview_edge().
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The fixture wrote each row as one literal run of w bytes, which PackBits
can't express past 128 (a 160-pixel row's header byte read as a repeat
run). The old decoder clipped such rows silently; with the PSD fix in
this batch an overrun is an error, as it should be, and the fixture now
splits rows into valid runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
A tiny hostile PSD could abort the process on out-of-memory in
`psd::decode` (allocation failure doesn't unwind, so catch_unwind
can't save it):
1. The RLE row byte-count table (channels x rows entries, from the
header) was reserved with Vec::with_capacity before checking that
the file holds it: 56 channels x 2^30 rows asked for ~480 GB from a
~40-byte file. The table is now taken as a slice of the input with
checked size math and read lazily, so nothing is allocated from it.
2. RLE rows were zero-padded / run-expanded to the declared width with
no tie to the input size, so all-zero row counts on a 1 Gpx (the
DecodeOptions::max_pixels limit, already enforced by check_size)
32-bit image expanded to 4 GiB per channel. A PackBits row expands
at most 64x (2-byte repeat run -> 128 bytes), so each decoded row
must now declare at least ceil(row_bytes / 128) * 2 encoded bytes,
and the decoded channels' data must be present in the file, before
any plane is allocated. Decoded planes are thus bounded by 64x the
file size as well as by max_pixels. PackBits now decodes into a
pre-sized, zeroed row and a run that would write past the declared
width is an error instead of being silently clipped.
Also: size math (width x height x bpc, samples) is checked; image
buffers are allocated with try_reserve_exact (TooLarge on failure);
only the colour (+ alpha) planes are decoded, extra channels are
skipped; the PSB layer-section length no longer truncates on 32-bit
targets; interleaving uses iterators instead of indexing.
Valid PSDs (raw and RLE, 8/16/32-bit) decode identically; regression
tests cover a huge RLE header with a truncated table (aborted with a
481 GB allocation before), all-empty RLE rows, counts larger than the
file, a repeat run past the row width (was Ok before) and a 16-bit
gray RLE file with an extra channel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.
lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).
Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.
Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).
Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
C-RAW is quantised wavelet coding: next to clipped highlights its
reconstruction overshoots the sensor range by a few quantisation steps.
On 17 CC0 CR3 files (SX70 HS, G5 X Mark II, M6 Mark II, EOS R, RP, 90D,
M200, R5 Mark II, R6 Mark II, R6 Mark III, R8) samples reached 16384 to
16419 at 14 bits, never below 0, and decoding failed with "decoded
sample outside its bit depth". Lossless files of the same bodies clip at
exactly 16383. Wavelet samples are now clamped to the CMP1 bit depth;
lossless samples outside it are still corrupt data.
The other 93 CR3 files have no sample outside the range and render
pixel-identically. Render cache version 13 -> 14.
scan_for_jpeg walks every JPEG-like start in a container it can't parse;
each walk runs through the marker segments that follow (up to 4096) and
their entropy data. A crafted CRW/MRW/X3F (or unsupported TIFF raw) of
fake starts one after another makes every walk cover most of the file:
quadratic, about a minute for 40 MB, on the import's probe thread. The
walks now report how far they read and share a budget of eight times the
file's size (at least 1 MiB); real files' candidates fail within a few
bytes or are the preview itself. Test: 200,000 fake starts, 0.15 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Root cause (in 0.2.1 and still on main): files and folders named on the
command line were imported from the eframe app-creator closure with
`app.run("library.import", …)`, which runs the engine command synchronously:
`import::expand` walked the folder recursively (one `is_dir()` open per entry,
no depth or count bound, following links) and probed every file before the
first frame could be drawn. An argument that resolves to a drive root (`.` or
`..` with a launcher's working folder at `C:\`, `/` or `\` on Windows) or to
the home folder therefore walked the whole drive with the window stuck as a
black "not responding" box. The library-problem banner's pending import had
the same synchronous call. Every other recursive walk (Local's Include
Subfolders, Find Missing Photos, preset and LUT folders, MCP) was also
unbounded, which is the "Local folders with subfolders: not responding"
pattern of filmcraft#23, and a link loop recursed until the stack overflowed.
- engine `walk::files_in`: one bounded, iterative walker (32 folder levels,
1,000,000 entries; `Walked::truncated` says it stopped), folder or file
taken from the directory listing (no open per entry), links and junctions
to a folder followed once. `import::expand` (+ `expand_within`), preset and
LUT folder expansion and MCP `expand_paths` use it.
- engine `import::launch_path`: an empty launch argument is ignored; a
relative one is resolved against the working folder; a drive or file-system
root becomes its memory card's DCIM folder or is refused, and so is the home
folder (a notice says why and to use File > Import Photos).
- desktop: launch arguments go through it and are imported with
`import::start_paths` (the import worker), like dropped files; so does the
library-problem banner's pending import.
Not verified on Windows (no machine here). The report says PhotoCraft 0.5.0
and `--memory` hang the same way with no arguments; nothing in LightCraft
walks the disk at startup without arguments (strace of a headless demo run
with HOME=/ and cwd /: no recursive listing), so that part may be outside
this code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Under load (the full workspace suite) the photo grid was captured before
its thumbnails rendered, darker than the export dialog dimmed over them,
and the dimming comparison failed; it passes alone. Each screenshot now
follows ui.settle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
With several file types among the candidates, the Import Photos review
shows a toggle per type (ARW · 120, JPG · 120): unchecking JPG leaves
the JPEGs kept beside the raws out of the import, checking a type checks
its importable files (duplicates stay unchecked). JPEG/JPG and TIFF/TIF
are one type; a file whose probe named no format goes by its extension.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The tests from @QuirkyTurtle94's #376, a duplicate fix for #372 opened
while #379 was in review: they read the painted text shapes (not the
registered rects) at 1x and 2x scale, in English, German and Brazilian
Portuguese, at panel widths 250-520, and check that tile labels fit
unelided inside their tiles, that a long mask name is elided before the
eye, and that a long Describe prompt keeps Select inside the panel.
They pass unchanged against main's implementation.
Co-Authored-By: Elliot Graham <elliotgraham94@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Import -> Copy (and Move, and Copy as DNG) handled one file after another
on the import worker: copy, fsync, read back and hash, optionally decode
and re-encode the raw as DNG. Every other CPU-heavy step of an import was
already parallel (the probes, up to 8 threads), so with the probes cached
by the review dialog's scan the transfer phase ran on one core.
ImportJob::prepare_files now plans each file in order, as before
(duplicates by path and content, sidecars, rename sequence numbers,
folders, Kept notes) and collects the copies/moves, which then run on up
to 8 threads (std::thread::scope; sequential on wasm32). Outcomes keep
their places, so results, order, names and failure messages are those of
a one-by-one import:
- a file whose content matches a transfer still pending, or whose name
could collide with one (same folder, same name family: IMG.jpg,
IMG-1.jpg, img.dng), waits until that one has finished;
- cancel stops before the next file; transfers not started leave no item;
- a panic in one transfer fails that file instead of the batch;
- Copy as DNG holds a memory-gate permit (6x the file size) while it runs.
Copy verification and crash safety are unchanged (same copy_new / place).
The desktop's import worker prepares batches of 2 x workers files (8..16,
lightcraft_engine::import::batch_size) instead of 8, so a batch's slowest
file holds back fewer threads; the browser build keeps 8.
Measured (4-core shared VM, 200 procedural 3.7 MB JPEGs = 754 MB, warm
page cache, throwaway benchmark not committed):
- probe: ~80 ms CPU per file is the JPEG decode (DCT-scaled to 64 px),
1.2 ms the content hash; the dialog's scan: 4.4 s, 3.6 cores busy.
- Copy, probes cached: copy+fsync+verify 1.59 s at 0.43 cores before,
0.94 s at 0.81 cores after. Copy, not cached: 6.45 s -> 5.51 s.
- Add, probes cached: 2 ms prepare; nothing to parallelize.
- commit of a batch on the UI thread (journal append + fsync): ~4 ms,
flat for libraries of 0, 5k, 20k and 50k photos (~0.7 s for 1300).
Copy as DNG could not be measured (no raw corpus here); it is the most
CPU-heavy per-file step and was sequential.
Test: copies_run_side_by_side_with_the_same_outcome (copies under the
test fault Slow run on more than one thread; names, order and in-batch
duplicates match a one-by-one copy; the outcome assertions also pass on
the old code, the thread assertion fails there).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The folder tree writes paths with forward slashes and its widget ids
carry them; the test put the temp folder in the id with backslashes.
Found running cargo xtask ci on Windows.
Found running cargo xtask ci with the corpus on Windows. The two changes
landed on the same day: the Fujifilm test (#282) expected the compressed
X-T20 file to keep the neutral fallback because its fit failed the gates
on all pixels (held-out RMS 0.114 against 0.10). The retry away from
edges (#263, issue #232) fits it (0.056), so the fit is accepted at both
sizes. Expect true.
follow_library_folder rebuilt the chosen subfolder from the lower-cased
folder_key on Windows and joined it with '/', so after a move the folder
shown in the sidebar came back as c:\archive\trip/day1 with its names in
lower case. Add folder_rest (compares by folder_key, returns the names as
spelled) and join them with the platform's separator. Tests compare
folders by identity where they only mean the same folder, and a new test
fails on Windows without the fix.
The progress, result and menu strings of the Lightroom catalog import in
the ja, pt-br, zh-hans, zh-hant, de and ru catalogs. The formats catalogs
held English copies of the seven new format messages; they are
translated, and German and Russian (which the PR predates) get them too,
so the formats build check passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Folder import, drag and drop and MCP folder expansion skipped these extensions, so
the preview-only fallback for these containers was only reachable by naming a file.
Adds exactly the containers that now import as preview only to import::EXTENSIONS and
the headless PHOTO_EXTENSIONS. 3FR, FFF, DCR and STI still fail and stay out.
Widens the preview-only fallback. A recognised raw whose decode fails now falls back
to its embedded JPEG for any decode error (unsupported, corrupt, limit, TIFF
structure), not only an unsupported variant, and the preview search covers more
containers:
- Canon CRW, Minolta MRW and Sigma X3F are recognised by their magic (RawFormat
Crw, Mrw, X3f) instead of failing as "RawOther files are not supported yet".
- A TIFF whose IFD0 has no ImageWidth/ImageLength but a JPEG pointer or SubIFDs
(Kodak KDC) is a raw container, not a malformed image. TIFF compression 99
(Leaf MOS) counts as a raw compression.
- When no tag points to a preview, a recognised raw we cannot decode is scanned
for the largest complete three-component DCT JPEG. A preview whose first byte
was overwritten (Minolta, Epson) is restored. A one-component JPEG is skipped
(a PowerShot CRW stores its mosaic that way).
Files with no JPEG at all still fail with "without an embedded preview".
Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs keep their raw data in
a private block. IFD0 is only a 160-640 px RGB copy and no IFD is marked as raw, so
they opened as an ordinary TIFF of that size (a 150 MP file rendered 296x220) with
no preview-only label.
probe() now recognises such a file from its structure (OtherTiff): a picture at
least 4x larger than IFD0 in both directions is declared by a SubIFDs child, or by
Exif PixelXDimension/PixelYDimension together with enough bytes outside every IFD's
strips and tiles to hold it at one bit per pixel. decode() reports the sizes as the
reason. A raw we cannot decode now falls back to its embedded JPEG, else to the TIFF
reader's view of its first image, so it imports preview-only with that reason.
RENDER_CACHE_VERSION 12: these files render differently.
- The keymap editor's strings, the fixed-key labels and the two new
command labels in the ja, pt-br, zh-hans, zh-hant, de and ru catalogs;
'Restore {}' and 'Removed from {}' go through tr_format!.
- The Presets and Soft Proofing rows say what their key does in the
Library grids (Flag as Pick and advance / expand or collapse the
stack), which main's static Shortcuts list showed as a separate label.
- The macOS menu publishes no shortcuts while a key is being recorded
(merged with main's owned_by_menu).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
A Spanish (es) catalog and formats catalog covering the same messages as
the Brazilian Portuguese, Japanese and Simplified Chinese catalogs (1595
messages, 180 formats), an Edit > Language > Español command
(app.language.spanish), es tag tests (es, es-ES, es_MX.UTF-8, es-419),
docs/localization-es.md with the terminology used, and the language
lists in docs/localization.md, parity and What's New.
Instructions use infinitives or impersonal forms (no usted). Plural
endings follow the existing convention: the English "s" passes through
where Spanish adds one, else {:.0} swallows it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
With no mirror configured the dialog was titled "Download the SAM 3
Model?", only said to put "the files" in the folder (see
docs/ai-masks.md) and offered Close. It is now titled "Install the SAM 3
Model", names the three files (model.safetensors, vocab.json,
merges.txt from facebook/sam3), and has a Show in Finder / Explorer /
File Manager button that creates and shows the model folder, plus a link
to the installation guide. Object and Describe work as soon as the files
are there.
The download location itself (a hosted mirror) is still a maintainer
decision: DEFAULT_MIRRORS stays empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The Create New Mask tiles painted their (untranslated) labels with an
unconstrained Painter::text into tiles capped at 52 pt, so Background and
Luminance ran past their tiles, and translations would more so. The grid
now measures the translated labels: four 52 pt tiles when they fit (at
10.5 pt, or 9.5 pt before dropping a column), else three or two, a tile
growing past 52 pt for a long label; only in a panel too narrow for even
that is a label cut short (full text on hover).
A mask row's name is cut short with an ellipsis before the eye (full name
on hover). The Describe field places the translated Select button first
and gives the field what is left, instead of reserving a fixed 64 pt.
Status lines wrap explicitly.
The painted label and name rects are registered (maskNewLabel:*,
maskName:*), so the sidebar test checks the text, not just the widgets,
at four panel widths with a long name and a long prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The ⌘/ dialog is now a keymap editor: click a shortcut and press the new
keys (Esc cancels), × removes it, ↺ restores the default, Reset All clears
every change. A key another command had moves to the new command, with a
toast. Changes go through the new app.setShortcut / app.resetShortcuts
commands into AppSettings.keymap (saved in ui.json) and show up in the
menus, the native macOS menu and the control channel's command list.
Modifier keys alone never record (holding ⌘ waits for the key it
modifies), ⌘C/⌘X/⌘V are recorded from the copy/cut/paste events, and
saved entries that don't parse fall back to the default shortcut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main already had Fujifilm RAF in relative WB, the file-local look, the
docs and the corpus lists; CR3 is added next to it. RENDER_CACHE_VERSION
was already 12 on main (from #255/#258/#282), so CR3 thumbnails rendered
by main from embedded previews would not be redone: 13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
When eframe could not open the window (no usable graphics device, a
broken driver), main returned the error to standard error, which a
Windows release build (no console) or an app started from Finder never
shows: the app just vanished. The error now goes to the log (the file
#286 added) and into a native message box, in the user's language, that
names the log file. The saved UI language is applied before the window
starts so the message uses it. Scripts and tests (LIGHTCRAFT_NO_PREFS)
get no dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
When catalog.lock can't be locked (a file system without locks, such as
some network shares), the library opens unprotected, as documented, but
the promised warning went only to the log. It is now the first of the
library's warnings: library.info lists it under settingsWarnings, the
app shows it once in its notices, and lightcraft-cli run / mcp print the
library's warnings on stderr (stdout stays results / protocol only). A
normally locked library shows none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Decode lossless CRX and version 0x100/0x200 C-RAW in portable Rust,
including sensor geometry, timed white balance and black/white levels.
Verify all sensor samples against six independent M50/R100/R8 references.
Any CR3 decode error falls back to the embedded JPEG, as CR3 files did
before this decoder. The full sensor buffer is allocated only after the
first plane decodes, and plane buffers grow row by row. Tile planes
decode in parallel with a windowed bit reader (24 MP: 424 -> 89 ms
lossless, 519 -> 138 ms C-RAW on an M4).
Add guarded CR3 camera-JPEG colour fitting with independent framing
validation, relative white balance and cache invalidation.
Validation: cargo xtask ci.
The pending counter was decremented when the job's guard dropped at the
end of the loop iteration, after the reply was sent, so a waiting caller
(CLI / MCP mask.add) could see the segmenter still busy right after its
answer arrived. Under a loaded test run that made
tests_segment::waiting_requests_with_a_damaged_model_are_errors fail on
assert!(!busy()). The guard is now dropped just before each reply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
After an undo or redo that changed one photo (its edit, rating, flag,
label, metadata or versions), that photo becomes the active one, as in
Lightroom Classic, so the change is on screen instead of happening to a
photo out of sight. It joins the selection if it isn't in it; steps that
touch several photos, albums or files, and photos outside the current
view, leave the selection alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#265 wrapped about 460 more UI strings (command and control labels, rule
operators, built-in preset names, status messages) in tr()/tr_format!, and
filled the 54 new formats messages of the other catalogs with English
copies. These are now translated in ja, zh-hans, zh-hant and pt-br; the
Russian formats catalog gets the 58 messages it needs to build, and German
the strings batches 1-2 added. Release-note lines stay English outside
German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The rows count every photo in the library, but choosing one filtered
whatever source was open (an album, a folder, Picks), so it often showed
nothing. Choosing a row now switches to All Photos first; clearing it
leaves the source alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The eye was drawn only while the header itself was hovered; with the
pointer on the eye, egui reports the eye (on top) as hovered and not the
header, so the eye vanished under the pointer and could not be clicked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The pipeline honoured a section's eye only where it applies Effects,
Optics, Geometry and Calibration, so turning the eye off on Light, Color
or Detail changed nothing. DevelopSettings::effective() resets the
switched-off sections to their defaults (Light with the tone curve; Color
with white balance, mixers, grading and point colour; Detail), and both the
CPU and GPU renderers render those. Borrowed, so free, when every section
is on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
- #265's German coverage test failed whenever any catalog key, command or
control label, or line of What's New lacked German, so every feature PR
would have had to ship German. It reports those gaps for every language
now (display_label_gaps_are_reported), as the project's other gap test
does; docs/localization.md says how release-note lines are matched and
that an English copy in a formats catalog marks an untranslated message.
- Russian: docs/localization-ru.md, the language lists in
docs/localization.md and docs/parity.md, ru tag tests.
- What's new: German and Russian UI. Removes a status sentence #265 put
in parity.md's Top gaps list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
rfd turns each extension into a *.ext glob that GTK and the desktop portal
match case-sensitively, so DSC_1234.NEF was hidden. One helper
(dialog_filter) gives every dialog filter the lower, UPPER and bracket
forms on Linux; the Add Photos filter uses import::EXTENSIONS.
Click the value next to a slider's name to edit it: the old value starts
selected, Return or clicking away applies the typed number on the
control's steps and range as one undo step (like a keyboard nudge), and
Esc or text that isn't a number keeps the old value. A decimal comma,
a leading + and a % or K unit are accepted. The keys typed go to the
field, not to the shortcuts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Maker note tag 0x4001 is UNDEFINED bytes (ColorData versions -3 and -4) on
PowerShot and EOS M bodies. It was read as one number per byte, so no plausible
as-shot levels were found and the white balance was 1, 1, 1 (green cast, a* about
-13 on neutrals). Byte and undefined data is now paired into 16-bit words in the
maker note's byte order before the existing probe (word 71). No per-model table.
RENDER_CACHE_VERSION 11 -> 12.
Maker note 0x0096 of the D780, D6, Z 5, Z 6, Z 7 and Z 7_2 starts 0x44 0x40
and holds 257 points, but its first interval rises by 2^bits / 1024 where the
0x44 0x20 tables rise by 2^bits / 256: the curve is the identity in the dark
part and covers 2^bits / 4 codes, not 2^bits. The decoder spaced the points
2^bits / 256 codes apart for both, so these files decoded to a fraction of
their values, far below the black level.
parse_table now takes the span from the version (0x20: all codes, 0x40: a
quarter). Other versions decode to identical samples. A known-answer test
built from hand-coded table bytes and bit strings fails without the change.
Bumps RENDER_CACHE_VERSION to 12.
#333 passed imported LUT names and folder names through tr(), so a LUT
named like a message ("Vivid") was shown translated; they are now shown
as the file names them (builtin_label / new profile_label, as for user
presets). A LUT folder named like a built-in group ("Film") shared that
group's menu widget id and the browser section's open state; both are
keyed apart now. Restores the "Amount 0 = the photo as it was" assertion
#333 replaced, and adds a What's new line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#308 painted an opaque label-coloured bar over the bottom 24 px of every
labelled photo in Photo Grid, hiding part of the photo, while its docs
said the photo pixels stay as they are. The footer is now translucent
over the photo and the docs say so. Also adds the one string pt-br lacked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Crop Apply / Lock / Locked, Set Image Zoom and Pan, the import review's
Recently Deleted choices and Empty Recently Deleted. The import's undo
labels and its "restored from Recently Deleted" summary used format!, so
they could not be translated; they go through tr_format! now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The review dialog was a fixed-size window whose thumbnail grid was capped at 330 points (about 6 x 2.5 thumbnails). It now opens at 960 x 720, can be resized by its edges and corners, and the grid takes whatever height the options and buttons leave. The dialog has a window id of its own so its size does not carry over to the other dialogs.
release.yml (five jobs) and freebsd.yml pinned craft-fonts abb83316, from
before Noto Sans CJK SC landed there, so v0.4.0 embedded only the Japanese
faces and most of the Simplified Chinese UI was tofu. The pin is now
8dcdacd5 (craft-fonts HEAD: Noto Sans CJK SC and Noto Sans Arabic), which
the Noto CJK exception merged from #203 allows. docs/releasing.md says when
to bump the pin and how to check it before a release.
flake.nix / flake.lock (community-maintained, not in CI) still pin the old
revision: the lock's narHash needs nix to recompute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#258 left the Tint row at 🟡 and docs/tint-direction.md as a review note.
The sign now matches the slider's track, the DNG specification and
Lightroom XMP, so the row is back to ✅; the doc says what happens to
saved custom tints (they shift the other way; nothing is rewritten) and
What's new tells users.
Co-Authored-By: Hanmo <letsgo@88.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
A texture's whole-pixel size is slightly off the frame's exact aspect (a
128 px thumbnail by up to ~1 %), so following it always made the loupe
rect jump a little as the thumbnail, preview and full render arrived, and
moved mask and crop overlays with it. Follow it only when it differs by
more than 2 % (an unsupported raw's embedded JPEG with another crop), and
never in the crop tool, whose overlays need the frame's own geometry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#287 made app.export refuse unknown params. Stored settings (a user export
preset, prefs.json lastExport) written by another version are not the
caller's typo, and refusing them would break that preset or Export with
Previous for good after an update: their unknown keys (also inside
watermark) are dropped before the strict check. What the caller passes is
still checked strictly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
lightcraft-cli installed no `log` backend, so every log::warn!/info! record
(the GPU-to-CPU fallback, an unknown LIGHTCRAFT_GPU_BACKEND, the adapter
chosen) was dropped and RUST_LOG, which docs/gpu-pipeline.md documents for
the fallback warning, did nothing. The desktop app had its own logger that
read only LIGHTCRAFT_LOG.
Both now install lightcraft_engine::logging: warnings and errors on stderr
by default; LIGHTCRAFT_LOG or RUST_LOG (off, error, warn, info, debug,
trace) picks another level, LIGHTCRAFT_LOG winning, and RUST_LOG's
per-target directives (`lightcraft=debug,wgpu=warn`) set LightCraft's
level. Below warnings only LightCraft's own records are shown, as the app's
logger did. The app's --help lists the variable.
Tests: level parsing and target filtering (engine logging::tests); the CLI
prints "LIGHTCRAFT_GPU_BACKEND=bogus names no known backend" with
RUST_LOG=warn and nothing with RUST_LOG=off (cli.rs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
explorer <url> opened File Explorer for links with a query or fragment, such as the OpenStreetMap link behind the Map button. Windows now opens https links with rundll32 url.dll,FileProtocolHandler, which hands the URL to the default browser without a shell.
New crate `lightcraft-heif` (a port of PhotoCraft's photocraft-heif): a thin,
panic-guarded wrapper around heic-rs (pure Rust, pinned =0.1.1) - 8/10-bit,
alpha, grid tiles, container irot/imir/clap, ICC/EXIF/XMP. `lightcraft-codecs`
decodes HEIF behind its non-default `heif` feature (HEVC patents are the
distributor's call, the same policy as PhotoCraft); the apps forward it. HEIC
files stay recognised without the feature: opening one is a clear
"isn't included in this build" error. Fuzz-found heic-rs panics arrive as
regression fixtures (inline bytes, no media committed).
Verified end-to-end on real iPhone HEICs (lightcraft-cli render, rotation and
metadata correct). Note for reviewers: measured here, the engine currently
ignores the decoded image's source-space matrix on export for ANY format (a
true Display-P3 PNG round-trips unchanged); the ICC and space are carried for
all codecs, so this inherits - and does not worsen - existing behaviour.
Co-Authored-By: opencode <noreply@opencode.ai>
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the LightCraft colour, with LightCraft.app and the Applications link side by side.
Same approach as VectorCraft's (storytold/vectorcraft#493).
- packaging/macos/dmg/: background.svg (source; text outlined, the figure an
<image> of assets/app-icon), background.tiff (1x + 2x HiDPI, Deflate, sRGB)
and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
reproducible, with nothing from the machine that ran it (no paths, users,
disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
an alias that includes the volume name. The DMG file name keeps the version.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Files whose photo is in the trash stay unchecked until Restore them or Import as
new is chosen; the toast and undo label say what was restored.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review of the onDeleted work found that purging photos one at a time built each
op list against the unchanged catalog, so photos sharing an album or a stack made
Empty Recently Deleted (and Fresh imports) fail with NoStack or leave dangling
album entries. Catalog::delete_photos_permanently_ops builds one list for all of
them; Empty, Delete Permanently and the Fresh purge use it. Photos an import
restores now take part in album/albumName/selection, and undo labels say Restore.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A file that is in Recently Deleted was always skipped as a duplicate with no way
forward (#298). The report now flags such duplicates (existingDeleted); onDeleted
restores the trashed photo with its edits or replaces it with a fresh import; and
Empty Recently Deleted removes every trashed photo in one undoable step.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After Select All in the loupe only the active filmstrip cell was highlighted,
so the selection looked like a single photo. Cells now use Selection::state_of;
selected-but-not-active cells get the selected fill and a grey outline.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The crop overlay now reports pointer positions to crop.drag instead of
computing rectangles itself. The Crop panel gets a visible Lock toggle, a
custom W × H entry, a dropdown chevron and a button label that names the
active ratio ("Original", a preset, or 1.37 : 1) instead of pixel sizes.
LR-CROP-RECT / LR-CROP-ASPECT parity rows updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
crop.drag {handle, from, to, start?} runs drag_crop through the command
layer so the UI, CLI, control channel and MCP agree; a locked crop keeps the
shape of its frame. crop.aspect now rejects non-finite, non-positive and
beyond-20:1 ratios and non-numeric arrays instead of storing a degenerate
lock that produced an empty crop.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dragging a crop handle past the image edge used to hand the raw rectangle to
CropGeometry::constrained, which slid the whole frame toward the centre and
moved the anchored corner. drag_crop owns the drag instead: the opposite
edges stay put, a locked ratio holds on all eight handles (edges grow about
their midline, sliding along a blocked side), the frame stops at the image
edge and slides along it, and it keeps a minimum size. Hostile numbers return
the start crop. Covered by Given/When/Then scenarios and property tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keep embedded and decoded preview proportions consistent in the justified
grid, photo views, alternate detail previews, and culling previews.
Co-Authored-By: GPT-6 Luna <noreply@openai.com>
* Release: FreeBSD x86_64, Flatpak x86_64/aarch64, AppImage zsync updates; README Downloads
- linux: appimagetool -u embeds gh-releases-zsync update information; the .AppImage.zsync is
published beside each AppImage (zsync installed; checked in the job).
- flatpak: new job per arch repackages the Linux tarball into a single-file .flatpak
(packaging/linux/flatpak-bundle.sh + ai.storyteller.lightcraft.bundle.yml); the Flathub
manifest gets the CLI and the same commented finish-args; packaging-lint checks they agree.
- freebsd: new job builds lightcraft-<ver>-freebsd-x86_64.tar.gz in a FreeBSD 14.3 VM with the
freebsd.yml packages (packaging/freebsd/package.sh; layout dry run in packaging-lint).
- version, linux and web no longer use the release environment (they sign nothing), so a
branch dispatch dry-runs every unsigned job; the release job still needs macOS and Windows.
- README: Downloads section listing every build; docs/releasing.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Linux packages: describe LightCraft, not PhotoCraft, in the deb/rpm description
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A name that does not fit is cut with an ellipsis before the photo count (the
full name on hover) instead of running past it; scrolling the sidebar sideways
or widening it shows more. A row asks for room for at most 140 pt of its name,
so depth, not one long name, makes the sidebar scroll. Selection and hover
bars end at the panel's edge when the content is wider.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- ui: counts stay at the visible edge when the sidebar scrolls sideways
(with the row's colour behind them where a long name runs under); the
smart-album dialog's live count includes the album's folder
- catalog: a folder path is used as given (a name with a space at its edge is
not its neighbour: Remove acted on the wrong folder); a plain POSIX path
is checked against the folder without building a key (about half the cost
on a big library, with a test that it agrees with the careful check)
- engine: editing a smart album's rules keeps the folder the editor cannot
show; a shown folder that loses its last photo gives way to All Photos;
library.state and view.json name the folder only while it is shown; a blank
folder filter is no filter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every level indents one step more than its parent however deep it goes (the
five-level indent cap is gone). The whole sidebar scrolls in both directions:
rows report how wide they need to be, the widest one of the last frame sets
the content width (at least the panel's), and a bar appears only when it
overflows. Folder names are no longer cut in the tree; Create Album stays at
the visible edge.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- engine: a smart album made from a folder view keeps the folder; only a
folder that shows exactly its own photos can be the source (not the
startup disk, not a folder above disks, not one with no photos); a
folder source and a folder filter never coexist; a folder source with no
folder shows nothing; a saved view whose folder is gone opens on All
Photos; removing a folder above a disk needs `disk: true` even with one
disk mounted; library.folders documents volume/selectable
- ui: deep chains stop indenting after five levels, the grid title gives
way to the photo count (full name on hover), disclosure triangles have
spoken names, the remove dialog says what is included and has a disk
title; test temp folders are cleaned up on failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- catalog: a row is exactly one folder (no folding of single-child levels);
the UI starts a folder that holds nothing and leads to one folder open
- engine: a library folder is a source (LibrarySource::LibraryFolder with
Session::library_folder, like Local's browse): library.source
{kind: libraryFolder, path}, kept with the library's view, shown by
library.state, followed through folder.rename / folder.move and undo,
dropped back to All Photos when its folder is removed
- ui: clicking a folder replaces whatever the grid showed (Picks, Recently
Deleted, a Local folder…) instead of narrowing it with a filter chip; the
grid is titled after the folder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Another way to explore the library next to By Date and Keywords: a tree of
the folders that hold imported photos, with counts (subfolders included),
under the disk or network share each is on. Local stays the way to browse
any folder on disk.
- catalog: Catalog::folder_tree and Filter::library_folder. Each disk is a
volume row (drive letter, Windows share, /Volumes/<share>, /media, /mnt,
gvfs, /net…), found from the stored paths alone, so an offline share costs
nothing. Levels that hold nothing and lead to one folder fold into one row;
depth is bounded. The tree and the filter read paths through one splitter
(separators, `.`, `..`, verbatim UNC prefix, repeated separators), and a
row that would cover other disks' photos is not choosable
- engine: library.folders, library.removeFolder (to Recently Deleted, one
undo step, no file touched; a whole disk or a folder spanning several
needs `disk: true`, the startup disk never), libraryFolder in
library.filter, chip; a chosen folder follows folder.rename / folder.move
and their undo
- ui: collapsible Folders section; the chosen folder's row is always opened
into view; right-click (row or triangle) Rename, Move, Show in Finder,
Remove from Library…; disk rows Remove Disk from Library…; both ask first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Bilevel scans, CCITT fax images and 2- or 4-bit gray TIFFs were rejected with
"sample buffer too short". The `tiff` crate returns such samples packed, most
significant bits first, eight, four or two to a byte with each row padded to a
whole byte; the converter expects one byte per sample. They are now spread out
one per byte and scaled to 0–255 (255, 85 and 17 times the sample: exact) before
conversion. 3-, 5-, 6- and 7-bit samples, and planar images with more than one
sample narrower than a byte, are refused with an "unsupported" error.
The new test builds 1-bit (both photometric conventions, a width that leaves row
padding, set padding bits), 2-bit and 4-bit strips in code, plus a truncated
strip that must fail cleanly; it fails without the fix with the old error. A
600×400 bilevel image, uncompressed and CCITT G4, now decodes to the same pixels
PIL reads from it.
The module documentation no longer claims palette support: the `tiff` crate
refuses RGBPalette images on every read path, so the palette branch here has
never been reached. The parity row lists palette TIFF as missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A WhiteIsZero (PhotometricInterpretation 0) gray TIFF opened as a negative at
every bit depth. The `tiff` crate (0.11) already inverts those samples as it
reads them, for every layout it accepts, and refuses the WhiteIsZero layouts it
can't invert; the decoder here then inverted them a second time. WhiteIsZero is
common in scanner and fax output, and CCITT fax images use it by definition.
The second inversion is removed. The new test builds 8- and 16-bit WhiteIsZero
and BlackIsZero strips in code and fails without the fix (the lowest sample
came out black instead of white).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep macOS plain-key ratings, labels and flags with egui, adapting the
native menu ownership correction from PR #261 (4e012a5, Gardy).
Add Shift+6-9 and contextual grid Shift+P, recognize shifted digit
punctuation, and document the verified Lightroom Classic mapping.
Cover batch changes and undo, exactly-once advance, Compare targets,
text and crop context, physical number keys and native menu ownership.
Verified native demo/control screenshots and all seven cargo xtask ci gates.
Co-authored-by: Gardy <armandpro50@gmail.com>
The filmstrip painted selection chrome only for the active photo, so after
Select All or a ⌘/Shift-click multi-selection the other selected photos
looked unselected, although the selection itself was right.
Paint it as the grid does: every selected photo gets the selected cell
fill; the active one keeps its white frame and the others get the grid's
grey one.
Test: tests_scroll::filmstrip_shows_every_selected_photo samples a cell's
background in the headless render after Select All, after selecting one
photo, and after a toggle (what a ⌘-click runs); before the fix a selected
photo that was not the active one was painted like an unselected one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The detail view's filmstrip shortened a long file name with `&name[..13]`,
a byte slice. For a name with multi-byte characters byte 13 can fall
inside a character (`202407層三限定訂閱圖(4)`: inside '限'), and the
slice panicked the UI thread as soon as that photo scrolled into the
strip, so the app quit.
`film_label` now counts characters: more than 14 are cut to 13 and an
ellipsis, as before for ASCII names.
Tests: a unit test of `film_label` (CJK, exactly 14 characters, ASCII,
empty) and a headless filmstrip test with the name from the issue; both
panicked with the reported message before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--help`, the module doc and the README promise that a `--memory` session
"writes nothing", and the desktop app broke that in two places:
- `PrefsWriter` saved `ui.json` whenever the UI state changed (issue #164),
so a throwaway session's `thumbSize`, window layout or language replaced
the user's saved settings — the README showcase script does exactly that.
- The GPU crash sentinel from #136 armed itself in every session except
`LIGHTCRAFT_NO_PREFS` (issue #169); `with_init_marker` creates the
settings folder to hold `gpu-init.marker`, removes the marker and leaves
the folder, so a `--memory` run on a fresh home left an empty
`Library/Application Support/LightCraft` behind.
Now `PrefsWriter` carries its target path (`None` for `--memory`,
`LIGHTCRAFT_NO_PREFS` or no home folder) and writes nothing without one; it
also writes nothing while the session is the one offered when the library
can't be opened (the question window and Continue Without Saving, which
`docs/control-protocol.md` already describes as writing nothing) and
resumes when `app.openLibrary` ends it. `gpu_crash_check(in_memory)` neither
arms the sentinel nor removes a marker in a `--memory` session; it still
reads one (new `read_init_marker`), so GPU rendering starts off there too
and the next ordinary launch reports and clears it. A `--memory` session
still reads `ui.json`, but a damaged one is reported and left in place
rather than set aside. Normal launches are unchanged.
Tests: `prefs_writer_writes_nothing_in_a_memory_or_temporary_session`,
`memory_session_leaves_the_gpu_marker_and_folder_alone`,
`memory_session_leaves_a_damaged_ui_json_alone` (apps/lightcraft), and
`read_init_marker` coverage in the gpu crate's marker test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ExportOptions::from_json` reads what it recognises and defaults the rest, so
`longEdgee=400` exported at 3000 px, `longEdge=banana` at full size, and a
`watermark` of `5` or `{"text":"x","bogus":1}` passed with `ok:true`, while
`size: 3` was silently clamped to 0.5 of the short edge (#181, #183).
`from_json` stays lenient for stored prefs and dialog prefill. New
`ExportOptions::validate(cmd, params)` / `from_params(params)` check every key
against the option and target lists (unknown ones name the nearest known key),
every value's kind and range, and a `watermark` object's keys, kinds and ranges
(`size` 0.005..0.5 and `inset` 0..0.4 of the short edge, `imageWidth` 0.01..1 of
the width, `opacity` 0..1, `color` [r,g,b]), returning
`invalid parameters for \`app.export\`: …` like the other commands. The three
places that run an export use it — the desktop `app.export`, the headless
backend behind `lightcraft-cli run` and the MCP `export` tool, and
`lightcraft-cli render --opt` — and `export.savePreset` validates a preset's
params when it is saved. Built-in presets, the Export dialog's `to_json`,
Export with Previous and `render --opt` all pass unchanged.
The `app.export` param string (`lightcraft-cli commands`, `engine.commands`),
the MCP `export` tool schema and docs/parity.md LR-EXP-WATERMARK now list the
watermark keys, their units and ranges.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The desktop app's logger (StderrLog in main.rs) wrote to standard error
only. A launch from a desktop menu or the Dock has no terminal, so its
warnings were lost, and the panic log in the temp folder is gone after a
reboot: there was nothing to attach to a bug report.
apps/lightcraft/src/logging.rs replaces it (no new dependency):
- standard error plus logs/lightcraft.log in the settings folder, next
to ui.json (Linux $XDG_CONFIG_HOME/lightcraft/logs, never the library);
each launch rotates the previous log to lightcraft.1.log and .2.log, so
the log of a run that crashed survives the next start; the file is
capped at 16 MiB with one note
- records logged before the settings folder is known are buffered
(bounded) and written once the file is attached
- default levels: info for lightcraft*, warn for everything else;
LIGHTCRAFT_LOG=info|debug keeps its old meaning (a test compares it
with the old logger) and wins over RUST_LOG, which otherwise replaces
the default with env_logger-style directives
- never panics: an unusable folder or a failed write leaves standard
error as the only sink; a poisoned lock is taken back
- runs with LIGHTCRAFT_NO_PREFS (tests, scripts) log to standard error
only, so they don't rotate away the user's own logs
- a panic hook chained after the engine guard's adds escaped panics to
the file (try_lock, so a panic while logging can't hang the app);
lightcraft-panics.log in the temp folder is kept
The logger is installed first in main and the file is opened after
argument parsing (--version, --help and usage errors leave no file).
The start-up and control-server messages in main.rs and
control_server.rs go through it instead of eprintln!, and a --control
or LIGHTCRAFT_CONTROL_PORT value that isn't a port is now reported.
Documented in README (Quick start, Logs), --help and docs/whats-new.md.
`tol = 0.04 + 0.16·refine` fully selected anything within 0.06 OkLab of a
sample at the default Refine 50. Through `tonemap_for_select` the report's
teal mid-grey and neutral light grey are only ~0.063 apart (L 0.60 vs 0.72,
a/b ≤ 0.02), so the second patch was 99 % selected and the mask covered the
whole photo; Refine 85 and 100 kept most of it too. Now
`0.015 + 0.06·refine` (full ≤ 0.0225 / gone ≥ 0.045 at Refine 50, gone ≥
0.066 at 85, 0.075 at 100), computed once in
`lightcraft_pipeline::masks::color_range_tolerance` and used by both the CPU
evaluator and the GPU `mask.wgsl` kind-3 parameter so the two keep agreeing.
Test: two flat patches at the report's sRGB values, sampled as
`mask.sampleColor` does; the sampled patch stays fully selected and the other
one is out at Refine 0, 50 and 85 and mostly out at 100.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`fn snapshot` wrote every reply but never looked at `ok`, so a script with a
bad `ui.set`, an unknown command or a failed `ui.screenshot` exited 0 while
the PNG showed the wrong view or was never written. The run now counts
replies with `"ok": false`, keeps going (every reply still reaches stdout and
the final `-o` screenshot is still written — continue-and-report, like
`run --keep-going`) and returns `N scripted request(s) failed` afterwards,
which `main` turns into exit 1. Usage text and docs/control-protocol.md
"Headless rendering" state the contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Split out of #240 without the compressed decoder (crates/raw/src/vendor/orfc.rs
is not part of this change; compressed ORF stays preview-only).
- The layout is read from Exif CFAPattern (0xa302, repeat counts in either
byte order) when it describes a 2x2 Bayer cell. BGGR and GBRG were not
reachable before.
- Without a usable tag the green diagonal is still found from the samples,
now from totals of 32x32 blocks instead of single-pixel differences, so
fine texture no longer outweighs a small red/blue difference. The old
version indexed the samples directly; this one reads them with get().
- probe_info no longer unpacks 12-bit packed ORFs that state their layout
(XZ-2: under 0.1 ms; it ran the same unpack as a decode before, which
takes 11 ms). 16-bit files still read their samples to find the bit depth.
Tests: stated layouts in both byte orders, unusable tags falling back to the
samples, the texture case, hostile areas for the fallback, probe_info equal
to decode; a corpus test comparing the E-1, E-400 and XZ-2 mosaics with
their embedded JPEGs under every layout.
No user-visible change yet for the bodies whose layout the old guess got
wrong (E-M1 and E-620 are BGGR): they write compressed ORF.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`select_photos {"ids": [9999]}` answered `selected: 1` and left the session
with no usable active photo; `develop.get {"id": 9999}` answered too.
`library.select` now checks every id (and `active`) against the catalog
before touching the selection and returns `no such photo N`, so the
previous selection and active photo stay in place; `photo_arg` (develop.get,
photo.inspect, photo.allMetadata, history.list) does the same for an
explicit `id`. Engine and MCP tests cover every select mode.
Fixes#182
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`moxcms` stamps the ICC header's creation date/time with the wall clock on
every `encode()` (the writer uses `ColorDateTime::now()` and ignores the
profile's `creation_date_time` field), so two exports of one photo with
the same settings differed in the embedded profile and nowhere else — the
pixels matched, the hashes never did. `write_matrix_trc`, which every
embedded profile goes through, now zeros the header's date bytes (24..36)
after encoding, as other deterministic profile writers do. A test asserts
two writes of every named space are identical, the date bytes are zero and
the profile ID is empty.
Fixes#180
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The browser accepted and persisted library.preferences cacheMb, but stored thumbnails stayed over that limit: the worker index pruned with the native default (DISK_CACHE_BYTES), so the setting only reached the desktop cache. The worker group now carries the session cache_bytes(), the app refreshes it every frame, and the next stored thumbnail prunes to it.
Add German language selection and persisted locale support, complete German
label and format catalogs, and German release notes. Connect previously
untranslated UI text, preserve user names, and size translated buttons to
fit. Format cached import source facts in the active UI language.
Validate locale coverage, placeholder formats, language persistence,
German glyphs, button bounds and cached import language switching. Review
native and headless screenshots; run all seven cargo xtask ci gates.
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Sony A7R II camera JPEGs are distortion-corrected by default while the raw is not. On the public
CC0 ILCE-7RM2 samples the JPEG is up to 15 px of 1440 off, and the misaligned window frames alone
pushed the held-out RMS to 0.108-0.115 (gate 0.10): the photo opened in the grey neutral fallback,
13 L* too dark. The same shot with distortion correction off fits at 0.059.
When the fit on all pixels fails the gates, try it once more, with the same gates, on pixels whose
3x3 neighbourhood in either proxy spans a luminance ratio of at most 3. Fits that already pass are
unchanged (29 other corpus ARW/NEF/RW2 files render pixel-identical). ILCE-7RM2 ΔE76 vs the camera
JPEG (registered) 16.6-16.9 -> 3.7-3.8; corpus GX80 19.1 -> 7.9. RENDER_CACHE_VERSION 11 -> 12.
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the
camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3,
so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and
"Enable Profile Corrections" applies it like a DNG's warp (no new warp path;
Convert to DNG keeps it). Rule, from our own measurements against the
embedded camera JPEGs (clean-room; ExifTool tag names only):
corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12
(w12 = active-area half diagonal), output = corrected / (1 + w5/32768),
on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos
from 12 other bodies / 9 other lenses; corrected renders line up with the
camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the
half diagonal before). Implausible values leave the photo uncorrected.
The loader now removes lens opcodes before the RW2 camera-look fit (its
binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction()
replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12.
On macOS every shortcut that appears in the menu bar was left to the native
menu, and the egui shortcut handler skipped those keys. AppKit only offers a
key press to the menu bar when it carries ⌘ or ⌃, or is a function key; a
plain key (E, C, H, M, I, K, D, 0–9, P, U, Y…) or one with only ⇧ / ⌥ (⇧P,
⇧V, ⌥Y…) goes to the window's winit view instead. So all of those did
nothing on macOS, while keys outside the menus (Space, G, X, ⌫) and ⌘
shortcuts worked.
`publish_shortcuts` now lists only the shortcuts the menu bar really receives
(`menu_delivers`: ⌘, ⌃ or a function key) in `native_shortcuts`. The others
keep their accelerator on the menu item, so the menu still shows the key, and
egui runs them.
Checked in a release build by pressing keys (not via the control channel,
which bypasses the native menu): C opens Crop & Rotate, E Edit, ⇧P Presets,
3 / 0 set and clear the rating; ⌘⇧H toggles the histogram once per press;
typing "c" in the search field types and leaves Crop closed. The same build
without this change ignored C, ⇧P and 3. ⌃ shortcuts (merges) are unchanged
and were not tried.
Regression test: native_menu::tests::single_key_shortcuts_stay_with_egui
(fails with the previous behaviour: "E must be left to egui").
The fetch crate is about to serve downloads whose file names, sizes and addresses come from a user-editable list,
so it checks them before touching the disk: an ordinary, unique file name (no separators, drive or stream
syntax, control characters, trailing dots, Windows device names or .part suffix), a non-zero size within the
caller's bound, a well-formed SHA-256, bounded file, mirror and attempt counts, and URLs capped at 16 KiB.
FileSpec now borrows its strings (callers with static names are unchanged) so a name read from a file needs no
leaking. download_url fetches one file from an exact address, query string included, for signed links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Options::token_env (default none) replaces the hard-coded LIGHTCRAFT_SAM3_TOKEN, so a download of one model can
never send another model's token to its host. SAM 3 names its variable through lightcraft_segment::fetch::options().
The decision (non-empty, https, the host the download began at) is a small function with its own test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The decoder change alters format-8 renders while the photo's content hash
and settings stay the same, so thumbnails rendered before it (edited
photos keep theirs on disk) would survive. Bump RENDER_CACHE_VERSION.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raw format 8 codes a cell top-left, bottom-left, top-right, bottom-right.
decode_strip placed the values in row order, which put the two green
samples of every RGGB cell on each other's sites: flat areas were right,
but every edge got a one-pixel colour zipper. Measured on a G9 II and the
corpus GH6, S5 II and S9: the Gb plane sat at (+0.5, -0.5) half-res cells
from Gr instead of the Bayer (-0.5, +0.5); with this change all four match.
The test encoder used the same order, so the round trip passed either way;
a new known-answer test hand-codes one cell and checks where it lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* About: contributors compiled in from contributors.json (Contributors + Models tabs, grab bag + table, sortable, name toggle)
Adds the craftrules standard (standards/contributors.md) to LightCraft: contributors/contributors.json
is generated by craftrules/scripts/contributors.py and baked into the binary by
crates/ui-egui/build.rs (merged next to the tr_format! generation) as static tables. The About
dialog gets tabs About · Contributors · Models. Each contributor line: @username, opt-in
display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit. Sortable by
every column; alphabetical ignores case and the leading @. Tabs and the list view are
control-channel widgets (button:aboutTab-*, button:creditsTable / button:creditsGrabBag).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors docs: craftrules is two levels up (../../craftrules)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section
build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate (verified people.toml entries)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate after rebase onto main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate (verified people.toml entries)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
load_bytes_now timed the binned demosaic with std::time::Instant, which
panics on wasm32-unknown-unknown ("time not implemented on this
platform"). Opening a raw in the web build aborted the decode. Use
web_time::Instant, as library.rs already does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Installed RAM overcommits a busy machine: on a 32 GB desktop with browsers,
a VM and editors open, about 7 GB was free and the installed-RAM rule chose
16 jobs. A fresh build then pushed free RAM below 1 GB.
`cargo xtask ci` now uses one build job per 1.5 GB of RAM available when it
starts (Linux MemAvailable, Windows AvailableMBytes, macOS free + inactive +
speculative pages), capped at the CPU count. With line-table debuginfo,
4 jobs measured about 3 GB in use, so this leaves about half the available
RAM spare. If available RAM can't be read, half the installed RAM is
assumed; if neither can, 4 jobs. Test threads follow, capped at 4.
Explicit CARGO_BUILD_JOBS / RUST_TEST_THREADS still win.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#207 let up to two edge pixels differ because the GPU's f32 framing could
fall on the other side of the lens-warp boundary. 'GPU: decide warped-geometry
edges with a CPU coverage mask' (this PR) makes the GPU use the CPU's f64
decision, so every case is back within the plain MAX_LSB check (embedded
lens + perspective: max 1 LSB, was 91 before the mask).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
settle() returns after a quiet spell, which on a loaded machine can come before the first grid
thumbnails, the profile browser's variant thumbnails or the async import review (and its
thumbnails) land. demo_grid_snapshot_has_ui_pixels, profile_browser_previews_on_hover_and_applies_on_click
and import_review_dialog failed that way when two test runs shared the machine; they (and the
two other import-review tests) now step until what they assert is there. 2 concurrent runs: 138/138 each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained Headless::step_until for the same job as this branch's
test-only wait_for_widget (run frames until a condition holds). Use the one
helper and drop the duplicate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reopening a library (or opening another one, or set_disk_cache_bytes
falling back to attaching) replaced MediaCache::rendered with a new
PreviewCache, but render jobs still in flight kept the old object.
Each PreviewCache checked only its own generation, so after a clear on
the new cache the old one's generation was still current: a finishing
thumbnail job wrote its pixels through it into the directory the clear
had just emptied. The UI rejected the result, but the JPEG stayed.
attach_disk_cache now retires the cache it replaces before swapping it:
PreviewCache::retire takes the generation write lock (so it waits for a
write in progress, like clear) and marks the object retired; every
get/put/put_deferred through it is then refused and its clear() is a
no-op, as its directory may now belong to the replacement. Files on
disk are left alone, so a plain reopen still finds its thumbnails, and
resizing the budget in place keeps the same cache object as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main thread's Workers tracked no rendered-thumbnail cache until the
first render request carried one. If Clear Preview Cache ran before that
(e.g. starting on an empty filtered view), the per-frame observation in
finished() had nothing to compare with, and the first request only
recorded the already-cleared generation as its baseline. The persisted
ThumbIndex was never invalidated, so once the filter was removed the
request touched the old entry and the worker read the pre-clear JPEG
back from browser storage. Replacing the cache (another library) before
the first request had the same gap.
Workers::start now takes the session's cache and records it, with its
current generation, as the baseline the persisted index belongs to. A
normal start with no clear observes the same cache and generation and
keeps the index. The comparison moves into wire::CacheWatch so it is
tested natively (cargo test --workspace) next to ThumbIndex.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#243 dropped every result whose request id wasn't the newest for its slot. The loupe requests a new
draft every frame while a slider moves, so when a draft took longer than a frame nothing landed
until the drag paused. Main, Before and Hover now take a superseded draft that is newer than what
they show (never one from before a library or preview-cache reset, never an error or its source).
Also saturate the xtask RAM rounding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coverage mask from 94fe300 evaluated the f64 warp for every output
pixel. coverage_mask now asks Warp::block_coverage for each 32 x 16 block
(the shared warp formulas evaluated with outward-rounded intervals, no
edge margin) and evaluates only undecided blocks near the edge per pixel
with Warp::covers; the bits are unchanged
(coverage_mask_matches_per_pixel_decision, incl. partial words and
padding bits; coverage_mask_with_the_horizon_in_view, a perspective
denominator exactly 0 inside the image, so the clamp and
undecided-division paths go through block_coverage and the mask).
Measured (release, synthetic source, embedded lens warp + CA +
perspective as in embedded_lens_perspective_edges_match):
6000x4000 ~30 ms -> 2.7 ms (32 threads, 11x), ~580 ms -> 24.5 ms
(1 thread, 24x); 2048x1365 3.6 ms -> 1.0 ms (3.5x), 67.5 ms -> ~5 ms
(12-14x). Rotate270 the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Homography::apply, Affine::apply, Warp::to_corrected,
Warp::corrected_to_source and embedded_warp are now generic over
lightcraft_geom::Real (private *_real variants in pipeline, public
apply_real in geom); the f64 API is a thin wrapper, so callers are
unchanged. Real is implemented for f64 (plain IEEE operators) and for
Interval, which rounds every bound one ulp outward and turns division by
a range containing zero, the homography's 1e-300 denominator clamp and
non-finite bounds into "undecided" (NaN). Because the interval runs the
same operations as the f64 path, it bounds the rounded f64 results, not
only the exact ones.
The f64 path keeps the same operations in the same order (only commuted
operands, which IEEE + and x leave bit-identical; no fma). Proof:
warp_f64_results_are_bit_identical_to_the_pre_generic_formulas hashes the
bits of 2000 random warps x 40 points through every formula (plus the
clamp branch, including +-1e-300 exactly) and matches the digest computed
by the same test on thumb-split (419261c) before the change; reassociating
one expression, or `<=` for `<` in the clamp, changes it.
The framing chain (output px -> transformed -> lens-corrected -> green
source, then inside the edges) is written once: Warp::framing, generic.
Warp::frame (f64, returns the corrected and green source points) serves
both the CPU resample (sample_warped) and Warp::covers; block_coverage
evaluates the same chain with intervals over a whole block of output
pixels and compares the bounds against the same edges() inside uses, with
no margin: the bounds enclose every pixel's f64 result, so the decision
equals the per-pixel one; else None.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixed four jobs were safe on 8 GB but too many for 4 GB, and slower than
needed on large machines. `cargo xtask ci` now uses one build job per 2 GB of
installed RAM, capped at the CPU count, and 4 if the RAM can't be read
(/proc/meminfo, Win32_ComputerSystem, hw.memsize). With line-table debuginfo,
4 jobs measured about 3 GB of RAM in use and 2 jobs about 1.5 GB.
Test threads follow but stop at 4: more barely shortens CI, and at 16 threads
on a loaded machine the wall-clock frame-budget test
offline_originals_never_block_a_frame failed (one 303 ms frame). Explicit
CARGO_BUILD_JOBS / RUST_TEST_THREADS still win.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keyword_list_filters_renames_and_suggests failed intermittently under
full-suite load (1 photo instead of 2 after clicking travel|italy, or no
travel|italy row at all). No metadata was lost: at the failure every
photo still had its keywords, but the filter was `travel|france`.
Local's rows (Downloads, Home, ...) are added only once their is_dir
checks come back from fs_cached worker threads. Those start when the left
panel is first drawn, which in this test is right before the clicks.
Headless::busy() did not count them. Under load a row could land between
the frame that read a widget's rect and the frame that delivered the
press. The keyword rows then moved down 29 px and the click hit the row
above (travel|france), or the toggle click missed travel. Diagnostics
showed the click aimed at y=1307 and travel|italy drawn at 1365 after
the two Local rows landed. The same failure reproduces on 5cee0c0 (2 of
20 runs), so the thumbnail work did not cause it.
fs_cached now keeps a per-context count of checks in flight
(fs_cached_running), and busy() includes it, so settle() waits for them.
The keyword test settles after opening the left panel. The new test
settle_waits_for_file_system_checks holds a check open and fails without
the busy() change.
Full ui-egui lib runs with 4 test threads: 2 failures in 25 before the
change, none in the runs after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unedited raw first shows its embedded preview (ThumbQuick), then the
grid queues the real thumbnail at BACKGROUND_THUMB_PRIORITY. The grid asks
at its own priority (10 on screen), so thumb_current found no match while
the stand-in ran (nothing pending for the real slot) and while the real job
was queued (pending at 3, asked at 10): request_thumb rebuilt the job every
frame without changing anything. thumb_inputs now records whether the
request went through a stand-in; such a request is current while its
stand-in is pending or its real job is pending/rendered at the background
priority. Direct requests keep their priority check, so a higher priority
still promotes a queued job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing the disk cache size (library.preferences cacheMb) re-attached a
new PreviewCache object, and the renderer treats a new cache object as a
library reset: every texture, the loupe included, was dropped and
re-rendered. Thumbnails are keyed by content, so a new budget leaves them
valid. The budget now changes in place on the attached cache (same object,
same generation); the next write trims to it, as before. A preview clear,
a generation bump and a new library still invalidate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hiding a kept Local folder beneath Home while browsing it (Remove from
Local, local.hide) expanded Home down to that folder and pushed the
"Show hidden locations" row out of view. local_places picked the
innermost listed folder containing the browsed one as the tree to
reveal; once the kept row was hidden that became Home, so folder_tree
opened Home, AppData, Local, Temp, ... to show the hidden folder again.
A tree no longer opens on the way down through a hidden folder: the
owner is chosen only among places whose path to the browsed folder
passes no hidden one. Hiding Home itself still lets Pictures reveal a
folder browsed inside it.
local_location_can_be_hidden_and_restored again hides a browsed
temporary folder (beneath Home on Windows), kept as Browse Folder...
does, while it is still browsed, and checks the restore row is inside
the viewport before clicking it. It failed before this fix. New unit
test: hidden_folder_is_not_revealed_in_an_outer_tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thumb_request_bench measures UI-thread time and allocations for grid
thumbnail requests (with and without pending work), using the same counting
allocator as the other bench examples. REAL_PHOTOS=1 samples up to four
photos from PHOTO_ROOT, which must now be set explicitly; the original
defaulted to a local photo library path.
Split from a327b98 (T0 thumbnail work).
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skip unchanged thumbnail jobs and reuse bounded settings hashes (both maps
capped at 1024 entries). Reject stale render results and cache writes after
edits, library changes and preview clears: render requests carry a request
id and cache generation, PreviewCache writes and reads hold the generation
lock that clear() takes, and library.clearPreviews also drops cached
sources. The web worker index follows the same generation rule.
The decoder guards, Windows path fixes and test fixture changes that were
bundled in a327b98 are separate commits; the benchmark follows this one.
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each header folds its section on click, with a chevron after the title
showing the state. The choice is kept in the UI state across restarts.
The Albums "+" button keeps working inside its header.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Folder listings arrive from background threads that Headless::busy() does
not track, so a fixed number of frames was not always enough under
full-suite load. Two tests failed intermittently and passed alone:
kept_local_root_stays_while_browsing_below_and_elsewhere and
local_folder_tree_expands_and_browses. They now wait for the expected
widget (wait_for_widget, test-only) and use their temporary folders as kept
roots. The kept-root test hides Home (in the test's in-memory session) so
the tree does not depend on the user's real folders.
local_location_can_be_hidden_and_restored now hides a kept root, reached
via "All Photographs". It no longer covers hiding a browsed temporary
folder: hiding a folder beneath Home expands the whole parent tree and
pushes the restore button out of view. That UI bug is not fixed here.
Fixture changes split from a327b98 (T0 thumbnail work), plus follow-ups.
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog paths may mix `/` and `\` on Windows, so raw string comparison saw
`N:/photos/IMG.jpg` and `N:/photos\IMG.jpg` as different files:
- Rename: planning, the move list, move_file_with and file_moves now compare
as paths. A separator-only "rename" is a no-op: no file move, no catalog
change and no undo entry, alone or batched with a genuine rename.
Previously it attempted a plain rename onto the existing file, failed, and
rolled back the rest of the batch. Case-only renames still work (#95).
- Import: the already-in-library index is keyed by PathBuf.
- Tests build expected paths with Path::join, and the CLI test escapes the
device path with serde_json instead of string formatting.
Split from a327b98 (T0 thumbnail work), with the follow-up rename fix that
the committed version needed.
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Non-JPEG thumbnail fallbacks decode at source resolution before resizing, so
the output size does not bound memory. ThumbnailOptions gains max_pixels
(default 64 MP, previously about 1 GP via DecodeOptions::fit); larger sources
return Error::TooLarge. Nothing in the workspace calls decode_thumbnail yet.
WebP RIFF chunk lengths are untrusted: set_memory_limit(bytes.len()) stops a
tiny truncated file requesting gigabytes for metadata. In image-webp 0.2.4
the limit applies only to ICC, EXIF and XMP chunks. Fuzz tests cover both;
the roundtrip test only switches to struct update syntax.
Split from a327b98 (T0 thumbnail work), where it was bundled.
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lens + perspective warp classified source-edge pixels in f32 on the GPU.
A position 0.000035 px outside the edge rounded inside, so the GPU sampled
photo pixels where the CPU reference draws blank canvas (geometry_variants,
"embedded lens + perspective", max 91 LSB). The CPU now evaluates the f64
framing decision into a one-bit-per-pixel mask; sampling and colour stay on
the GPU. The embedded-lens cases move to their own test,
embedded_lens_perspective_edges_match, with the same tolerances.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full-debuginfo workspace test build with one rustc/link.exe per CPU peaked
at 14-20 GB commit on a 32-thread Windows machine before any test ran; the
test binaries themselves peak at about 3.3 GB each. `cargo xtask ci` now sets
CARGO_PROFILE_DEV_DEBUG=line-tables-only, CARGO_BUILD_JOBS=4 and
RUST_TEST_THREADS=4 unless they are already set. Ordinary cargo commands are
unchanged; GPU coverage is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a "File Path" text rule so a smart album can match on where a photo
lives, e.g. contains "/Aliah Ira Polanco-Grylls/". "contains" /
"doesn't contain" match the whole string (spaces included) rather than
word by word; the other text operators are the shared ones. `\` and `/`
both separate and matching ignores case, so catalogs moved between
platforms still match. Demo photos have no path.
The rule editor, CLI and MCP pick the field up from FIELDS. parity.md's
smart-collection rows now list it (and the stale field count is fixed).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
On Windows HOME is usually unset, so the default export folder came out
empty and photos were written by bare filename into the working directory,
failing with 'Access is denied. (os error 5)'.
- Default export folder uses %USERPROFILE%\Pictures\LightCraft Exports on
Windows (matching the default library location).
- A blank Folder in the Export dialog, or no folder resolvable at all, now
fails with 'You need to specify an export location!' before any write.
- The Export dialog stays open on an error (like Import) and shows it;
the menu and keyboard export paths show it too instead of doing nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNY4WojPYorWa6rQLxSYHa
NEF (and other vendor raws without a colour matrix) carry their as-shot
white balance as vendor multipliers, not as a DNG AsShotNeutral. The DNG
writer only copied AsShotNeutral/AsShotWhiteXY, so Convert to DNG and
Copy as DNG produced files with no white balance at all, which develop
dark and green. Write the reciprocal of the vendor multipliers as
AsShotNeutral when the source has no DNG white of its own.
Add pt-br as a fifth interface language beside English, Simplified
Chinese, Traditional Chinese (Taiwan) and Japanese.
`crates/ui-egui/locales/pt-br.json` and `pt-br-formats.json` carry the
messages and the format strings; the language table gains the entry
(`PtBr, "pt-br", "Português (Brasil)", "Latn", ...`), and Edit > Language
plus `app.language.portuguese` select it. Locale tags pt-BR, pt_BR.UTF-8
and pt all normalize to the same language.
Translation follows pt-BR UI conventions (Adobe terms: revelação,
balanço de branco, máscara, cópia virtual…); proper nouns and file
formats stay as written (LightCraft, ArtCraft, GitHub, DNG, TIFF, XMP).
Placeholders, format specs and punctuation match the English source; the
English plural suffix is consumed with `{:.0}` where Portuguese does not
need it.
Tests: every_language_has_a_catalog_covering_the_source,
catalogs_agree_on_placeholders_and_report_gaps, locale_tags_normalize
and the painted-window test now cover pt-br. Docs: localization.md, a
new localization-pt-br.md and the parity rows.
Co-Authored-By: DeepSeek V4 Flash (opencode) <noreply@opencode.ai>
A catalog loaded from disk counts revisions from 0, like a new empty one, and the sidebar
counts, keyword tree, date groups and grid list are cached by revision alone. Opening an empty
library after one that was never edited this session therefore kept the old library's numbers.
Each catalog loaded in a process now gets its own revision range.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
zh-hant carries 1246 messages; zh-hans had 1029, so 217 recently added
strings — mask component editing, point colour samples, spot removal,
colour-label sets, smart-preview status, memory budget, the CLI/MCP command
list and the eighteen stock presets — fell back to English in the
Simplified Chinese UI.
Translate them and order the file like zh-hant so the two catalogs can be
compared line by line.
A build script's cwd is its package root, so CRAFT_FONTS_DIR=../craft-fonts —
the spelling the README and craft-fonts' integration docs both use, written
relative to the workspace root — resolved to crates/craft-fonts and failed.
The failure is silent from the user's side: the app runs, the language menu
lists every language, and selecting Chinese or Japanese paints boxes. Warn
when no font ends up embedded, not only when the variable is set but wrong.
Switching to Random from another key (without an explicit seed) derives a new seed
instead of reusing the one left behind, which was the fixed seed-0 order on a new library.
Re-choosing Random or changing only the direction keeps the shuffle on screen.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- library.shuffle treats a null seed as none given
- seeds are limited to 0..2^53-1 so JSON-double clients read them back exactly
- album test now fails if manual order overrides Random; persistence test goes through
close/reopen of the library instead of a serde round trip
- README lists random among the sort options
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
library.shuffle's registry menu path made the menu builder append it a second time after
the hand-built entry. Hide it from the auto-listing; test that it appears once and is
enabled only while sorting at random.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SortKey::Random orders photos by a splitmix64 rank of (Sort.seed, id): same seed gives
the same order, and added/edited photos never reshuffle the others. library.sort takes
an optional seed; library.shuffle picks a new one (below 2^53 for JSON clients). Menus
gain Random and Reshuffle; Ascending/Descending are disabled for Random. No effect in
Recently Added (always newest import first).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
RW2 / RWL / RAW join ARW and NEF: the starting look (matrix, hue/saturation table, tone and chroma curves) is
fitted to the file's own embedded JPEG behind the same gates, with white balance relative to the as-shot look.
Panasonic previews always show the whole active area while the default crop is the in-camera aspect ratio, so when
the preview matches the active area instead of the crop, the reference is cropped to the default crop before
fitting (corpus_rw2_with_an_in_camera_crop_gets_a_camera_look).
On 174 CC0 samples with a preview 140 fits are accepted (93 of the 114 bodies with a preview), median ΔE against
the camera JPEG 5.0 with matching lightness; most of the others are compacts and kit zooms whose camera JPEG is
distortion-corrected (tag 0x0119 isn't used yet). `lightcraft-cli calibrate` keeps collecting ARW and NEF only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtQmZ6peZXGn3wiqeowV2v
The downloader in lightcraft-segment (mirrors, resume, pinned SHA-256, pure-Rust
HTTPS) was already generic apart from four SAM 3 constants. Move it to
crates/fetch (L0, native only) so other model features can use it without
depending on the SAM 3 inference code. lightcraft-segment keeps SAM3_FILES,
DEFAULT_MIRRORS, MIRRORS_ENV and wraps mirrors(); no behaviour change. The
downloader's 13 tests moved with it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Decode every raw encoding Panasonic and Leica bodies write, from the DMC-LX1 (2005) to the DC-S1RM2 (2025):
compressed formats 4 (most bodies, 2008-2022) and 6 (S1 / S1R / S1H / S5, GH5M2), the prefix-coded strips of
format 8 (GH6 and later), packed formats 2, 5 and 7, and the 16-bit words of the 2005-2007 .RAW files. Until now
only format 5 decoded; every other Panasonic file opened as preview only.
Clean-room: no raw-decoder source was read. Tag names come from tag documentation; every encoding was recovered
by black-box analysis of 178 CC0 files from raw.pixls.us (118 bodies), and the module docs note the measurement
that settled each rule. Format-8 tags of unknown meaning must carry the values every sample has, otherwise the file
is reported as unsupported rather than guessed at.
Levels and geometry: formats 4 and 6 store samples 15 above the recorded black level (files without one use 15);
white is the active area's saturation plateau (the linearity limits are wrong on extended low-ISO shots, the GF1
and the Digilux 3); the in-camera aspect ratio is the default crop; mapped-out defect pixels are filled from
same-colour neighbours; the CFA pattern applies at sample (0, 0).
.rwl and .raw files are imported (engine, MCP folder expansion, file dialog, Linux MIME types). One file per
encoding joins the never-panic proptest; 11 CC0 files join `cargo xtask corpus`, and corpus_panasonic_encodings
checks each encoding's bit depth, black level, crop and CFA layout against the camera's own JPEG.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtQmZ6peZXGn3wiqeowV2v
- paths are built by component, not with a hard-coded '/' (cli, import, rename, find-missing)
- the rename tests' model volume treats both separators as one
- the Local-folder UI tests hide Home when the temp folder lies inside it, and wait for
the worker-thread listing instead of three frames
- the GPU equivalence test tolerates up to two edge pixels where the CPU and the card
fall on different sides of the lens-warp sampling boundary
cargo xtask ci: all 7 steps pass.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
keyword_list_filters_renames_and_suggests failed on FreeBSD CI (b35487a) and
locally (2 of 13 runs under load): ui.clickWidget clicks last frame's rect,
and right after opening the panel / expanding "travel" the rows could still
move, so the click hit "sunrise" instead of "travel|italy". Settling first:
30 of 30 runs pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
local_folder_tree_expands_and_browses failed on FreeBSD CI (707138f) and under
local load: the expanded folder's listing can land after the fixed three frames.
It now steps until the row is laid out (bounded by SETTLE).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Looked at in the running app: without a configured mirror the dialog
offered Download / Try Again that could only fail; it now shows the
manual-install hint and Close (an agent confirming still gets the
reason). "A one-time download of about 3.4 GB, saved in <folder>"; a
cancelled download is reported as stopped (resumable), not failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine (crates/engine/src/segment/):
- One worker thread owns the model and the encoded photo; the session
sends jobs (with the render job for the model input) and gets results
over channels, never locking what the model holds. In the app
(Segmenter::background) clicks, descriptions, prepares and detail
passes return at once and Session::segment_poll applies results (one
undo step per settled selection, pending clicks shown on the photo);
the CLI/MCP wait for them. Newer requests for the same selection
supersede queued ones.
- Every job runs under catch_unwind: a candle panic becomes an error and
the model is reloaded next time; busy counters are reset by drop
guards; a dead worker is restarted. The model unloads after 10 min idle.
- segment.model.status / .download {acknowledged: true} / .cancel: the
download runs on its own thread; status via atomics (try_lock only).
Without the model every AI request fails at once with "The SAM 3 model
is not installed…"; renders/exports never need it; `seg` params make
AI masks from stored segmentations without it.
- Hostile documents: at most 4 detail patches per component (capped on
deserialize and in the renderer), at most 64 Object clicks.
UI: Object / Describe without the model open "Download the SAM 3
Model?" (size, folder, SAM License link; Not Now / Download, progress,
cancel, Try Again with the reason, Continue once installed); progress in
the Masking panel when the dialog is closed. Reverted the PR's unrelated
default change (mask overlay off + hover-red) to main's behaviour.
tools/install-sam3.*: --fail so error pages never land in files, 416
restarts instead of looping, the weights are verified before they are
moved into place (a bad file is deleted, never resumed from).
Docs: docs/ai-masks.md (in-app download, mirrors, SAM License summary,
no weights in the repo), NOTICE, parity LR-MASK-OBJECTS, What's New,
ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The crate ports Apache-2.0-only Hugging Face transformers code: it is
now `license = "Apache-2.0"`, and every ported file carries the
modification notice Apache-2.0 §4(b) asks for.
- Weights::open checks every tensor's byte range (and the header)
against the file's length: a truncated or damaged download is an
error at load, never a short read later; regression test with
hostile offsets.
- fetch/: the model download, only when asked for. An ordered mirror
list (env LIGHTCRAFT_SAM3_MIRRORS, a mirrors file, DEFAULT_MIRRORS —
empty until LightCraft's CDN exists, TODO for the maintainer),
fallback to the next mirror, resume via Range, redirects, connect and
stall timeouts, cancel, size caps, exact size + SHA-256 (pinned from
tools/install-sam3.sh) before an atomic rename; damaged files are
deleted, HTTP error bodies never written.
- HTTP/1.1 client over std sockets + rustls with the RustCrypto provider
(rustls-rustcrypto) and webpki-roots: no ring / aws-lc-rs, nothing
compiled from C (no new `cc` users in the app's build graph).
- Tests against a local TcpListener server (no internet): fallback,
redirects, chunked bodies, resume, 416, hash mismatch, too much data,
stalls, cancel.
- *.safetensors are gitignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maintainer decision: Noto Sans/Serif CJK is Google-branded Source Han
(Adobe co-developed); nearly every OFL Chinese face derives from it, and the
Simplified/Traditional Chinese UI (#184, #198) uses Noto Sans CJK SC from
craft-fonts. Record it as a narrow, explicit exception to the Adobe-asset rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A built-in Sony ILCE-7M4 colour profile (assets/camera-profiles/ILCE-7M4.json, compact JSON, 39 KB), fitted by lightcraft-cli calibrate to the contributor's own 597 raw photos against their in-camera JPEGs; aggregate colour statistics only, MIT OR Apache-2.0. Compiled in via camera_profiles::BUNDLED; a local profile replaces it.
Co-authored-by: Harald Hoyer <harald@hoyer.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts with issue #150 (NEF colour from the camera JPEG): the per-file
look, its hue/saturation/value table and chroma curve, and camera
profiles now apply to every `file_local_look` format (ARW, NEF, NRW);
`calibrate` also collects NEF/NRW. Render cache version 11 (main 9,
branch 10).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One photo can show too little of a colour for its own fit: a lime shirt
covering a few dozen proxy pixels next to foliage at the same hue stayed
yellow (hue 68 deg vs the JPEG's 82 deg).
- `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS...` pools the
colour pairs of many ARWs per model (each against its embedded JPEG,
192 px proxies, <= 4000 pairs per photo) and fits one matrix from
white-balanced camera RGB plus a hue/saturation/value table.
- Profiles live in $LIGHTCRAFT_CAMERA_PROFILES or
<config>/camera-profiles/<model>.json; validated on load, damaged files
ignored with a warning, part of the render cache keys.
- Photos of a profiled model take its colour and fit only their own tone
and chroma curves; the acceptance gates still apply.
- The table fit buckets samples by hue and runs nodes in parallel; the
app's config_dir moves to the engine.
Profile from 300 Feb-Aug ILCE-7M4 photos (2 min); 29 held-out September
photos: mean dE vs camera JPEG 3.54 -> 3.10, saturated 6.41 -> 5.20;
the shirt's hue 68 -> 82 deg (JPEG 81.5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port of PR #198 onto the language table:
- Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name
繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command
app.language.traditionalChinese
- display-only helpers: builtin_label (stock preset/profile names), history_label
(generated history steps, user preset names verbatim), source_label (library
headings, album names verbatim), date_group_label / display_time
- menu, dialog and panel strings (profiles, presets, curves, crop, versions,
history, grid, date sidebar, About links) go through the catalogs
- a language command and ui.set apply the language at once; a native-menu test
checks that every language switch rebuilds the menu titles
Adapted while porting (Claude):
- date headings and capture times are localised for every language from its
*-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date
patterns, weekday names and "Unknown Date", and zh-hans the new menu messages
(ja's come from the PR)
- plain catalogs may cover different messages (English fallback); the test
reports the gaps instead of failing. The test requiring every engine command
label to be translated is dropped
- preset groups stay grouped by name: a user preset in a stock-named group is
not split into a separate group (the group header is translated when it holds
stock presets); the PR's split was a side effect of labelling
- no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified
Chinese face (which carries the Traditional characters) over the Japanese ones
- zh-hant translations for the messages main added since the PR branched
Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
profile_browser_previews_on_hover_and_applies_on_click failed on FreeBSD CI
(loupe showed "render", not "hover"): settle() returns after 12 idle frames,
which can come before the hover render lands on a loaded VM. A step_until
helper waits for the condition itself; the preset hover test had the same race.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the
close-up crop include the user's orientation. The loupe overlay now maps each box into that frame
(and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a
quarter turn. Flips need nothing: they are applied after the normalized frame. Adds
Orientation::inverse and Orientation::map_norm_rect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the CJK fallback order follows the UI language's script, but the fonts were
only installed on the first frame: switching 日本語 ↔ 简体中文 at runtime kept
the previous language's Han forms. The app remembers the language its fonts
were built for and reinstalls them when it changes.
- the native menu bar rebuilds on any language change (keyed on the language
code, not on "uses CJK fonts", which is true for both CJK languages)
- restore the tests the language-table change dropped, for every language:
core catalog entries, user-named menu items stay untranslated, the window
paints each language and menu ids don't change with it, CJK glyphs in every
UI family, and the UI runs without craft-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-reading a sidecar whose region list was emptied by a region-aware app now clears the stale
regions. A sidecar without mwg-rs:Regions at all (an app that doesn't do regions) says nothing about
them and the photo's regions are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- UI_COMMANDS still listed app.language.english/japanese next to the new
LANGUAGE_COMMANDS table, so Edit > Language showed English and 日本語 twice
- LANGUAGE_COMMANDS is `pub const`, the form xtask parity scans for; a test
reads the real menus.rs and checks every language id is found once
- the Simplified Chinese command is app.language.simplifiedChinese, in line
with the other camelCase command ids (the settings code stays zh-hans)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore main's compile-checked formatting, generalised to N catalogs: build.rs
writes one tr_format! arm per English message that matches on the active
language and calls format! with that language's translation (English is the
fallback arm). The runtime substitution dropped format specs ({:.1}, {:+},
{:.0}%), e.g. the smart-preview size and the merge progress percentage.
- formats catalogs use main's convention again ({0}/{1} for reordering, {:.0}
to consume an unused plural suffix); zh-hans converted, with the size and
percentage placeholders fixed by hand
- build.rs: auto-discovers locales/*-formats.json, validates the code
(^[a-z]+(-[a-z]+)*$), fails when the catalogs' keys differ, and reruns
when the locales directory changes
- zh-hans: translate the strings main added since the PR branched
- test: precision, sign and reordered positional values survive in every
language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A region parsed from mwg-rs:Regions is now intersected with the 0..1 frame and dropped when it
misses the photo, so a hostile or stale sidecar (x=1e5, a 1e5-wide box) can't park a box far
outside the image. Lightroom's mwg-rs:Rotation of ±π (Exif orientation 3) is mapped to the upright
frame like the quarter turns; mirrored orientations can't be expressed in MWG and are taken as
written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The symlink fallback ran read_link on every library entry for each existing
target; a large library made every Overwrite export check do thousands of
syscalls. The guard now finds the links on first need and keeps them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sunny ILCE-7M4 waterfall shot rendered yellow: sunlit rock came out
cream, +4 b* in the brightest fifth of the picture. Camera JPEGs make
shadows up to 1.4x more colourful and bleach highlights to 0.1-0.3x
(a per-channel tone curve), which a luminance-only curve can't do.
- CameraTone gains an 8-node chroma-by-display-luminance curve, fitted
per file (bright pixels included) and kept only when it lowers the
held-out error; applied after the tone map on CPU and GPU, keeping
luminance. Old smart-preview headers load with an identity curve.
- The hue/saturation table gets an sRGB-encoded value axis: dark yellows
had to turn toward orange while bright yellow-greens turned toward
green. It is applied after the baseline exposure, as it is fitted.
- Matrix-only and matrix+table looks are completed (tone, chroma) and
compared in gamma-encoded values; acceptance gates stay linear.
29 local ARWs vs their camera JPEGs: mean dE 4.23 -> 3.54, every file
closer, saturated areas 7.65 -> 6.41. Render cache v10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A global 3x3 matrix can't follow the camera's hue-dependent rendering:
on an ILCE-7M4 photo of a lime shirt among grey rocks the best matrix
rendered the shirt olive (16 deg hue error), since the few saturated
pixels carry little weight and a matrix fitted to them breaks the rest.
After the matrix, fit a 72x5 hue/saturation table (DNG ProfileHueSatMap
layout, linear ProPhoto) to the residual hue shifts and saturation
ratios: kernel-weighted per node, shrunk to identity where samples are
sparse, identity at saturation 0. It preserves luminance (the tone curve
owns it) and is kept only when it lowers the held-out error.
28 local ILCE-7M4 ARWs: mean dE vs camera JPEG 4.74 -> 4.27, none worse
overall; the shirt's hue error drops to 6-8 deg. Render cache v9.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
run --connect ADDR could not reach the app and always advised lightcraft --control 7980, whatever port ADDR named, so following the hint left the command connecting elsewhere. The hint now takes the port from ADDR itself, with an end-to-end test on a refused connection.
The localization layer was hardcoded to English and Japanese: an enum with two
variants, an `is_japanese()` gate compiled into the format macro, and one
catalog pair. Adding a language meant editing the menus, the settings row, the
checkmark logic, the format macro and the font stack — each in a different place.
A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym,
ISO 15924 script and catalog. The Language menu, the settings row, the shortcut
sheet and the control channel are all built from that table, and `build.rs`
picks up `locales/<code>-formats.json` by name, so a further language is a table
entry plus two catalogs (docs/localization.md).
Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json`
(1019 entries, key-set and placeholder-identical to `ja.json`) and
`zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto
Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones
for a Chinese reader, so shared Han characters keep the language's forms
(`fonts::cjk_fallback`); the export watermark accepts every CJK face.
`tr_format!` keeps its compile-time check. It formats the English source at the
call site — a runtime template cannot be handed to `format!` — and names those
values so a translation can place them by name (`{n}`) or position (`{_1}`).
Every translation is still checked by `format!`, so a translation with an
unknown placeholder fails the build. The Japanese catalog was rewritten from the
old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged.
Also: settings persist the BCP-47 code (never the Rust variant name),
`LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`),
`xtask parity` reads the second command table, and the glyph tests cover every
catalog character plus the interface symbols (`▸`/`▾` turned out to have no
glyph in any installed font and are gone from the Chinese text).
Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still
embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past
Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
photo.reload had no menu path, so the documented way to refresh CR3s imported
before their metadata was read (Photo > Reload from Disk) did not exist in the
app. It is now in the Photo menu and the photo context menu, and besides
size/preview-only it fills camera fields the catalog lacks (camera, lens,
exposure, GPS, capture time) from a fresh probe, never overwriting a set value.
CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata
(no camera, capture time, exposure, lens or GPS): nothing read the container.
- crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid
box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header
and sample location, XMP uuid box). Every size/offset checked; depth and box
count bounded.
- Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif
block, so CR3 reads like any TIFF-based raw; XMP from its uuid box.
- Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before
PRVW/THMB; imports report the full image size.
- Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep,
JPEG track preferred over PRVW with fallback.
Raw (CRX) data is still not decoded; CR3 stays preview-only.
Photos in Recently Deleted could not be restored from the app: photo.restore and
photo.deletePermanently existed only as engine commands, and adding the same file
again just said "1 duplicate skipped" (the duplicate check counts deleted photos),
with the side panel that lists Recently Deleted collapsed by default.
- Restore and Delete Permanently in the Photo menu and the photo context menu,
in place of Delete, when the selection is in Recently Deleted.
- The filmstrip gets the photo context menu (it had none).
- An import that only finds files the library already has selects those photos:
in Recently Deleted (side panel opened, how to restore or delete permanently and
import afresh) or in All Photos. Mixed imports note how many are in Recently
Deleted. The message stays 6 s.
- Import review badges say "In Recently Deleted"; badges are translated.
- Japanese strings, parity rows, what's new; headless regression test.
LightCraft has no colour matrix for Nikon cameras. Because of this, NEF
files showed colours that were too gray and too green.
- Colour: LightCraft now calculates a colour matrix and a tone curve from
the JPEG preview in each NEF file. Sony ARW files use the same
procedure and the same quality checks. White balance is relative to
the as-shot look, as for ARW files.
- One function, camera_preview::file_local_look, selects the formats for
the fit and for the relative white balance. Photo::relative_wb in the
catalog uses the same formats.
- Black level: Nikon stores the black level (maker note 0x003d) in
14-bit units, also in 12-bit files. The decoder read it as a 12-bit
value. Because of this, 12-bit files from the D750, D780, D850, D7500
and Z 50 were too dark. Now the decoder divides the value by 4 for
12-bit files.
- The corpus gets the D7500 12-bit and 14-bit lossless samples. A test
compares their levels. The render cache version increases to 9.
Results on 13 NEF files that LightCraft can decode: LightCraft accepted
the fit for all 13 files. The mean colour difference from the camera
JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed
light improved by a small quantity only (12.7 to 12.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hovering a box shows handles at its corners and edge midpoints; dragging one
previews the new box live and commits once on release, as one undo step, through
the new `photo.setRegion {id?, index, rect}`: catalog-only like removal, never
rewriting the sidecar, with the rectangle validated (finite numbers only,
clamped to the photo, at least 0.5 % each way) and a test with a real sidecar.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A date, keyword or person left over from the last session silently hid photos
behind a small badge, so view.json no longer stores or restores the filter (the
source, sort and selection still come back). In the loupe a small pill at the
canvas' top left names the active filters (the filmstrip and Next / Previous
follow them) and clears them on a click; it sits in the canvas margin, so the
photo does not move.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
View > Face Boxes (and a toolbar button in the loupe) hides or shows the boxes.
Hovering a box shows a × in its corner; clicking it runs the new undoable
`photo.removeRegion {id?, index}`, a catalog-only edit. The XMP sidecar is
never rewritten for it, even with auto-write on (a `skip_auto_write` flag
consumed when the command ends), because the sidecar writer builds a fresh
packet that does not carry the regions: reading the metadata from the file
brings a removed region back. A test with a real sidecar proves the file stays
byte for byte, that ordinary edits still auto-write, and that undo restores it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The view listed everyone in the library, so with another filter active (a
date, a keyword…) picking a person could end in an empty grid. The list now
covers the photos matching every filter except `person` itself, counts are
within them, and the filter chips show above the cards (removable, Clear all).
`Catalog::people_in(&Filter)`; the cache is keyed on the catalog revision and
the filter.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A dedicated view mode (View > People, and a toolbar button) shows a card per
person named on faces: a close-up of their largest face, the name and how many
photos they are in. A click shows that person's photos in the grid. Only the
cards on screen render their face. The sidebar section is gone (it would get
crowded).
`Catalog::people()` now returns `Person { name, count, photo, face }`, the
photo and face being the person's largest. `Session::face_job` renders a
square close-up through the engine (the photo's own look, crop replaced, the
smallest source level that keeps it sharp, cached like variant thumbnails); it
clamps hostile rectangles, with a test for corners, oversized, degenerate and
non-finite faces.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Read-only and names-only: the left panel gets a People section built from the
named Face regions read from XMP, with photo counts (once per photo, case
insensitive, pets and unnamed faces left out). A click filters the grid via a
new `person` field on `library.filter`, also reachable as a `person:` search
token and shown as a filter chip. The list is cached on the catalog revision.
No face detection, identity or writing regions back yet. Parity rows for the
People list, filter and view are 🟡 with that scope noted.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The name sits in a dark rounded label centred above the box with a small
caret (below it when there is no room above), replacing the offset-shadow
text. The box is a 1px white outline with a 1px black keyline so it shows on
any background.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in
the sensor's own frame and records the orientation as mwg-rs:Rotation
(-pi/2 for 6, +pi/2 for 8, 0 for 1) while AppliedToDimensions names the
upright size. Ignoring Rotation put every portrait photo's face box in the
wrong place. Convert such regions into the upright frame, with a synthetic
regression test for -pi/2, +pi/2 and 0.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Threads the Vec<Region> that lightcraft-meta already reads from XMP
(MWG-RS) through to the screen, so it's no longer inert:
- catalog::Meta gains a `regions` field (catalog now depends on
lightcraft-meta, which doesn't depend back on catalog, so no cycle).
- crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar
/ merge_into) populate it for both embedded-XMP and external-sidecar
photos, mirroring how `keywords` is already handled.
- ui-egui's loupe view (panels/detail.rs) draws each region as a
rectangle over the photo via the existing CanvasMap, color-coded by
kind (Face/Pet/other) with its name label if present, right alongside
the existing info overlay. Read-only, no new interaction.
New end-to-end test (regions_from_sidecar_are_read_on_import) covers a
region written to a sidecar reaching Photo.meta.regions through real
import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Metadata::regions: Vec<Region>, populated by parse_xmp from mwg-rs:Regions
(MWG Region Guidelines v2.0) — the format Lightroom, digiKam and Picasa write
for face/pet/focus/barcode regions. Read-only: LightCraft does not write
regions yet (blocked on #92, which would otherwise clobber existing sidecars).
- Canonicalises the mwg-rs/stArea/stDim namespaces so any prefix a writer
picks resolves consistently, reusing the existing generic struct/array XMP
machinery (handles both the element and attribute encodings transparently).
- Area center-coordinates are converted to lightcraft_geom::Rect via
Rect::from_center. Normalized units (the default, and the only unit we've
seen in practice) need no AppliedToDimensions cross-reference; the rare
pixel-unit region is converted when AppliedToDimensions is present, and
dropped (not stored as a nonsense fraction) when it isn't.
- Hostile/malformed regions (missing Area, NaN/absurd/zero/negative extents)
are skipped individually rather than failing the whole list or panicking.
- regions merges like keywords in fill_missing/overlay_user_fields, since it
only ever comes from XMP today.
Not included: nothing yet reads Metadata::regions (no catalog field, no UI,
no command) — this is plumbing for a later PR, with no user-visible effect
and no docs/parity.md row to update.
cargo xtask ci passes (fmt, clippy -D warnings, test, parity, layers, assets,
wasm).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.
- Object: click to include, ⌥-click to leave a part out (SAM 3's
interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
"car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
area around a selection from a sharper source: 5-10x finer edges on
small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
app (LIGHTCRAFT_LOG=info).
candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
- Added 4 control handles on the edge of the radial gradient mask to resize rx and ry independently.
- Added a rotation handle to adjust the mask angle.
- Changed drag gesture handling to store the original mask shape to prevent drift from accumulated relative deltas.
- Holding the Option/Alt key while dragging a handle scales the mask proportionally.
- Snaps the mask to a perfect circle (1:1 aspect ratio) when the aspect ratio is within 5%.
Sony bodies from before ~2017 (RX100–RX100M5, RX10, NEX, SLT, ILCE-6000,
A7/A7II/A7RII, ...) write no plain 0x7313/0x7310/0x74c7 tags in the raw IFD,
so the decoder used unit WB multipliers (strong green cast, e.g. the CC0
DSC-RX100M3 sample), black 512 (800 on 1-inch sensors), and kept padding
columns at the right edge.
- White balance: WB_RGBLevels from the maker note's enciphered Tag2010
block (layouts per ExifTool's Tag2010a..i tables; byte substitution
p^3 mod 249 checked black-box on 15 CC0 files: SonyISO matches Exif ISO,
ILCE-7M3 levels match its plain 0x7313).
- Black level: recovered from the encrypted SR2SubIFD by known-plaintext
analysis (constant key in every file; RX100M6/ILCE-7M3 plain 0x7310 as
plaintext; 8 keystream bytes at offset 2510, plausibility-checked).
- Crop: DNG DefaultCrop or the maker note's FullImageSize.
- "12-bit uncompressed" ARWs hold 14-bit-scale data: black/white follow it.
- Render cache version bumped; corpus manifest + corpus test, unit tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
snapshot() ignored settle()'s result, so on a loaded machine (seen on the FreeBSD VM and
under local load) a timed-out settle compared half-rendered frames and failed as a
misleading pixel diff. The test now settles explicitly with a 300 s budget, asserts it
settled, then paints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Japanese fonts (BIZ UDPGothic Regular/Bold for the UI, BIZ UDMincho for text
watermarks; #110, #114) now come from storytold/craft-fonts instead of assets/fonts/.
- crates/engine/build.rs: the craft-fonts recipe. With CRAFT_FONTS_DIR set it embeds
the manifest's fonts as lightcraft_engine::CRAFT_FONTS (wasm32: BIZ UDPGothic Regular
only, for the web size budget); unset, CRAFT_FONTS is empty. Local files only, no
network; craft-fonts is not in any Cargo.toml.
- UI: theme::font_definitions adds the Japanese faces after Inter and egui's defaults
in every family (BIZ UDPGothic first, Bold first for semibold). About box lists the
embedded families.
- Watermarks: Inter, then the craft-fonts Japanese faces (Mincho first).
- Removed BIZUDPGothic-Regular/Bold.ttf, BIZUDMincho-Regular.ttf and their OFL files,
and their ATTRIBUTION rows; added an attribution row for craft-fonts as an optional
build input; NOTICE updated.
- Tests: Japanese glyph tests use CRAFT_FONTS and skip without it; new tests for
Japanese in the UI with craft-fonts and for the UI and watermarks without it.
- CI: the FreeBSD job builds/tests with craft-fonts. Releases check out craft-fonts at
a pinned commit, build with CRAFT_FONTS_DIR + CRAFT_FONTS_REQUIRED=1, and ship the
fonts' OFL licences (plus NOTICE) in every package.
- Docs: AGENTS.md, CLAUDE.md, README, docs/web.md, docs/localization-ja.md, ROADMAP,
parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom-converted DNGs carry their camera profile's ProfileHueSatMapData1/2
and ProfileLookTableData (and sometimes ProfileToneCurve). We used only the
colour matrices, so such DNGs rendered flat and muted.
- raw: new `profile` module reads the tags (DNG spec ch. 6: value-major,
hue-middle, saturation-minor tables, wrap-around hue, tri-linear lookups,
sRGB value encoding) from the raw IFD or IFD 0; malformed tags are ignored.
- engine: the raw loader applies the hue/sat map (interpolated by the same
illuminant weight as the matrices) before BaselineExposure and the look
table after it, in linear ProPhoto; a ProfileToneCurve becomes the source's
camera tone. Without one the standard base tone is kept.
- dngwrite keeps the look with the data (conversions, smart previews, merges).
- Render cache version 7.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wgpu loads the driver of every backend in an instance's set while it
enumerates adapters. The compute device (crates/gpu) hard-coded
Backends::PRIMARY (Vulkan + DX12 + Metal) and ignored WGPU_BACKEND, and
was created from LightcraftApp::new on a background thread at launch, so
a crashing Vulkan driver (Intel UHD 630, igvk64.dll) killed the process
before the window appeared; the window (egui-wgpu) also listed Vulkan.
- lightcraft_gpu::backend: DX12 only on Windows (Metal on macOS, as before
on Linux) for both the compute device and the window;
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|gl|auto|off, else WGPU_BACKEND;
backends missing from the build fall back to the default.
- The device is created only when GPU rendering is enabled, after the
first frame; the preference is applied before the window opens.
- Crash sentinel: gpu-init.marker in the settings folder around device
creation; if left behind, the next launch starts with GPU rendering off
and a notice saying how to turn it back on.
- Tests: backend parsing/selection, sentinel, window defaults, headless
snapshot with LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off.
- Docs: gpu-pipeline.md (backends, env vars, troubleshooting), whats-new,
parity LR-BEHAV-GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- safe_file: write policy documented (durable for what can't be recreated, atomic-only for
recreatable outputs); new write_atomic_nosync (temp + rename, temp removed on failure, no
file/folder sync); per-thread sync counter for tests.
- export::write_file (exports, renders, screenshots, merge previews, CLI/MCP) uses the
non-syncing writer; new write_file_durable keeps the sync for Edit a Copy (the TIFF becomes a
library photo). Catalog, sidecars, settings, DNG conversion/merges and smart previews stay
durable; the #93 original-overwrite guard is untouched.
- Import > Copy verifies the synced copy against the probe's content hash (SipHash-1-3/128 of the
whole file) instead of re-reading the source; byte compare when there is no hash. A file changed
since Review Import is now refused instead of imported with stale details. Move keeps its
byte-for-byte check before deleting sources.
- check_write_target: skip building the library-wide guard when nothing is at the target.
- write_new_unique keeps its read-back (DNGs replace raws; cheap next to encoding).
- Tests: no sync on the export path, durable writers still sync, Copy caught by the probe hash
(corrupt copy, stale hash, changed source); ignored timing test write_policy_timing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Retain active preset preview when hovering within the scroll or panel area across headers and gaps.
- Prevent premature dropping of hover preview textures during asynchronous rerendering in detail view.
- Clear cached hover state when clicking an item or when the pointer leaves the panel.
- Add headless regression tests covering preset hover persistence and transitions.
Signed-off-by: benlukka <68825026+benlukka@users.noreply.github.com>
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3.**Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that*exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4.**Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
3.**Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
wrong or incomplete, downgrade it to 🟡 with a note.
4.**Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -62,10 +68,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
@@ -108,4 +129,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3.**Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that*exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4.**Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
3.**Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
wrong or incomplete, downgrade it to 🟡 with a note.
4.**Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -55,6 +61,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Pure Rust** in the product. No C/C++ dependencies.
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
@@ -62,10 +72,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
@@ -88,6 +113,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
`crates/ui-egui/src/menus.rs`.
- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
@@ -108,4 +136,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@@ -59,6 +64,7 @@
<a href="#feature-status">Status</a> ·
<a href="#quick-start">Quick start</a> ·
<a href="ROADMAP.md">Roadmap</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
@@ -200,7 +206,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized,
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
so they stay smooth whether you have forty photos or forty thousand.
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
|---|---|
@@ -301,20 +326,21 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -323,6 +349,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
@@ -356,7 +444,8 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
work from public specs and black-box observation.
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
(one task id per commit).
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
@@ -367,6 +456,60 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
<br>
## Downloads
**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
| | Value | Kind |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
| 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 |
## Progress log
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).
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
verified; RAW decoding 76% → 77%; headline numbers unchanged.
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
samples.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
README showcase. ≈ 8 h elapsed.
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
ecosystem) complete.
## Risks that coding hours alone don't retire
## Revision history
- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
then our own calibration; long-tail camera/lens coverage grows over time.
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
turns it into measured comparisons against local-only Lightroom references.
## Raw format coverage and known gaps
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
it can't decode yet.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
## Japanese interface and text watermarks
English/Japanese interface language is persisted in UI state. Core menus have Japanese
translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled
under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
| 2026-10-02 | major | Parity estimate and effort table |
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none |
| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
@@ -20,9 +24,13 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
"description":"Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).",
"derivation":"Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.",
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
## Colour
@@ -50,6 +65,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
Loaded 100 of 620 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.