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
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>
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.
`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.
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>
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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 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.
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>
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>
Smart previews store the decoded source with the fitted colour matrix
baked in, but the file-local tone curve lives in the decoder's
SourceInfo, which a proxy did not keep: an offline ARW rendered and
exported with the generic filmic curve instead (darker, a different
look). The proxy header now carries the curve (validated on load,
ignored when invalid), DecodedSource::info_or merges it into the header
facts, and is_valid reads the longer header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve SourceRef::load_source against #104's offline fallback: a File
source keeps the decoder's SourceInfo; a smart-preview fallback has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When open_library failed, the desktop app (and the web build) quietly ran
an in-memory session seeded with the demo photos: it looked like a reset
library and everything done in it was lost at quit.
- The session now starts empty and in memory (no demo photos) and
LightcraftApp::library_problem shows a blocking window with the path and
the error: Try Again, Choose Another Library… (app.openLibrary), Continue
Without Saving, Quit. Command-line files are imported once the user has
chosen.
- A temporary session shows a banner under the top bar (with Open
Library…) for as long as it lasts; it has no library, so nothing is
written anywhere. Opening a library ends it.
- ui.inspect → libraryProblem; widget ids for automation; documented in
control-protocol.md.
- Web build: same window (Try Again disabled: reloading the page retries).
- Headless UI test: locked library → window → Continue Without Saving →
banner, nothing written to the library → lock released → Try Again opens
it. Checked in the real app over the control channel too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>