- engine: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama` (bracket size from
EXIF); MergeJob (Send) with progress/cancel, preview renders through the develop
pipeline (deghost overlay, auto-crop frame); finish writes <name>-HDR/-Pano.dng
next to the first source, imports, selects, applies Auto Settings / auto crop
- ui: Photo Merge dialog (HDR / Panorama / HDR Panorama) with live preview on a
worker thread, background final merge with progress toast; context menu and
Ctrl+H / Ctrl+M (Ctrl now means the physical Control key on macOS)
- cli: `merge hdr|panorama|hdr-panorama`, `synth-merge` for synthetic inputs
- merge::synth: procedural brackets (DNG) and panorama views; docs/merge.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renders run in dedicated module Web Workers, each a second instance of the app's wasm module
(index.html compiles it once and posts the WebAssembly.Module to worker.js). A job crosses as
JSON (WireJob: source, settings, request); the worker reads originals and cached thumbnails from
browser storage itself, keeps decoded sources and per-view stage caches, and transfers RGBA back.
Jobs follow the worker that decoded the photo; the loupe waits for it. No wasm threads or
SharedArrayBuffer needed; falls back to inline rendering if no worker starts (?workers=0).
Thumbnail disk cache on the web: thumbs/<key>.jpg in OPFS/IndexedDB with an index on the main
thread, pruned to the desktop budget (2 GB → 80 %, LRU).
ui-egui: RenderOffload hook in the Renderer (queue/priorities/dedup stay in the UI).
engine: RenderJob.origin + MediaCache::origin_ref; preview: encode_jpeg/decode_jpeg public.
xtask web: copies worker.js; --serve sends COOP/COEP/CORP (documented in docs/web.md).
Headless Chrome, cold load + grid scroll, 24 demo photos: main-thread busy 12.4 s → 0.7–1.1 s,
long tasks 23 (max 1.1 s) → 0; grid filled in 6.3–8.4 s vs 13–16 s; slider job 4 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`media::develop` renders on the GPU when available (views, render_now, exports;
thumbnails stay on the CPU) and falls back to the CPU otherwise. `app.gpu
{enabled?}` toggles it at runtime, `LIGHTCRAFT_GPU=0` disables it for the process,
`ui.inspect` reports the adapter under perf.gpu.
Performance: dropped device buffers are recycled once their thread has submitted
(fresh 100-300 MB allocations per pass cost as much as the passes), Gaussian
passes ping-pong between two buffers and read all channels of a pixel at once,
and long box radii slide over longer chunks. LIGHTCRAFT_PROFILE prints real GPU
stage timings. render_bench prints CPU and GPU columns and the CPU/GPU difference.
24 MP ARW, loaded machine, min of 7: cold loupe 415 -> 32 ms, exposure drag
22-42 -> 3.9 ms, NR drag (1920) 301+ -> 18 ms, export render 1037+ -> 291 ms;
max 1 LSB difference. docs/gpu-pipeline.md describes the implementation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog Store on the web is an in-memory mirror (apps/lightcraft-web/src/files.rs) loaded
from browser storage at start-up and flushed in the background in modification order (so a
snapshot always lands before the log reset that follows it). Storage is OPFS when writable from
the main thread (createWritable), else IndexedDB (?store=idb forces it); ?reset clears it.
Imported originals are stored as originals/<content hash> and referenced as
web/<hash>/<name>; the main thread keeps a bounded memory cache and loads misses (and the
active photo) asynchronously. view.json and ui.json are saved every second when changed.
Engine: Session::open_library_in(LibraryStores) opens a library from any Store (presets, view
and prefs go through it too); Session::save_view; Copy imports into a non-disk library add.
JS automation API: lightcraft.command(id, paramsJson) (+ web.stats).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integrated with M4 optics/Upright: render_impl resolves Upright and uses frame_for (lens + auto CA)
on the cached source; defringe runs in the cached linear stage and optics settings are part of its key.
Stage-cache equivalence test extended with optics/geometry steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pipeline::upright: own gradient-based line-segment detector (level-line region growing seeded from the
strongest gradients, weighted principal-axis fit, elongation test); vanishing points by RANSAC over
segment pairs + least-squares refinement (smallest eigenvector of Σ w·l·lᵀ)
- corrections as a virtual camera rotation K·R·K⁻¹ (vertical VP → y axis; Full also horizontal VP → x axis
with focal length from VP orthogonality when plausible); Level = rotation; Auto = Full/Vertical when
moderate, else Level; all re-centred
- Guided: guides stored in normalized lens-corrected coordinates; 2+ vertical/horizontal guides define the
vanishing points, a single guide levels
- the analysis runs on the lens-corrected image at 1024 px and the result is stored in
geometry.upright_transform (identical preview/export); settings naming a mode without a transform
(XMP, CLI) are resolved at render time
- commands geometry.upright {mode} (Update = run again) and geometry.guides {guides, add}
- UI: Upright mode buttons, Update, Draw/Clear Guides in the crop panel; on-canvas guide drawing that
follows the warp
- tests: segment detector on known lines; Vertical/Auto/Full fix converging verticals; Level fixes a tilted
horizon; Full fixes both families; guided from two guides; analysis resolution independence; lazy vs
stored transform; engine commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exports above 2560 px were rendered from the 2560 px preview proxy and upsampled. SourceLevel::Full
(native size; procedural demo scenes at their nominal size) is now selected for outputs larger than
a preview; the media cache keeps only the last full-size original. render_bench gains a batch-export
scenario (decode + render + JPEG per file).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pipeline::optics::Warp: one inverse map (lens-corrected → source) inside the single geometry resample:
manual radial distortion, embedded DNG WarpRectilinear (per plane), per-channel lateral CA scale,
radial vignetting gain (manual + embedded FixVignetteRadial); all in normalized radii
- automatic lateral CA estimate: radial edge profiles matched sub-pixel per plane, robust LSQ fit of
displacement = α·r (cached per source)
- defringe: purple/green hue ranges (OkLab hue) desaturated next to high-contrast edges, width relative
to the image long edge
- "Enable Lens Corrections" applies only lens data embedded in DNGs (opcodes are no longer baked at decode;
stored on the photo record, on by default at import when present). No LCP data anywhere.
- new controls: optics.caRed/caBlue, defringe hue ranges; Optics panel: Manual sub-section, Defringe flyout
- tests: barrel grid straightened, CA removal (auto + manual), embedded corrections toggle/scale, preview vs
export resolution independence, CA estimator accuracy, orientation of embedded lens data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- finish: sRGB encode through an interpolated 4096-entry table (< 2e-5 from the exact curve,
tested), exp2 instead of powf(2, x).
- guided / guided_fast: coefficient and output passes run row-parallel (were serial loops);
Image::zip_map.
- blur_bench reports process CPU time next to wall-clock (meaningful on a loaded machine).
- RENDER_CACHE_VERSION 3 (exposure-after-filters and the table change pixels by <= 2 LSB).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pipeline: StageCache + render_cached: resampled source, white-balanced/retouched/denoised image
and each spatial plane (log L, guided base, clarity, texture, dark channel + airlight) are keyed
by exactly their inputs; keeps a draft-size and a full-size entry warm.
- Exposure moved after the spatial filters (applied per pixel; planes shifted by EV): identical up
to rounding (test: <= 2 LSB vs. exposing the source), so exposure drags hit the cache too.
- engine: RenderJob::draft / with_stages; ui: loupe and before views own a StageCache, loupe
renders at Draft quality while interacting.
- engine example render_bench (min-of-N wall + process CPU time).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- embedded_preview: CR3 PRVW/THMB boxes (plus RAF, RW2, ORF, PEF from the
format commits); stops at the first valid box of each kind
- engine: raw variants that report Unsupported are probed and shown from
their embedded JPEG (orientation from the JPEG or the raw), not an error
- crates/raw/tests/corpus.rs: every corpus/raw file is recognised, has a
JPEG preview (DNG optional) and decodes or is a known-unsupported
variant; prints decode timings
- xtask corpus --download: 37 CC0 raw.pixls.us samples (matched by SHA-256)
- ROADMAP/crate docs: format coverage, NEF Huffman / RW2 format 4 /
compressed ORF+RAF gaps, neutral colour-matrix fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- engine::import: expand files/folders recursively (sorted; hidden entries
and the library folder skipped), skip known paths, probe + hash in
parallel (std::thread::scope), skip duplicates by content hash (against
the library and within the batch), add in place or copy into
<library>/Originals/YYYY/YYYY-MM-DD/ with unique names; one undoable op.
- library.import {paths, mode: add|copy, album?} returns
{imported, duplicates[{path, existing, reason}], failed, scanned}.
- probe_bytes records the content hash (SipHash-1-3 128) on every photo.
- system clock for import/edit times on native (Session::with_system_clock).
- app: command-line folders are expanded by the engine (re-importing the
same folder is a no-op); example gen_photos writes procedural test JPEGs
(never committed).
- Measured (200 procedural 2000 px JPEGs, M-series): import 2.25 s; reopen:
library load 1.7 ms, control ready 0.12 s, first thumbnail 0.17 s,
visible grid drawn 0.48 s (thumbnails from the disk cache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- new crate lightcraft-preview (L3): 128-bit content hashing (SipHash-1-3),
cost-bounded Lru, DiskCache (JPEG q90 files keyed by content hash +
settings hash + size + RENDER_CACHE_VERSION; temp+rename writes; LRU prune
by mtime to 80% of budget; corrupt files dropped), PreviewCache (memory
LRU over disk), JobPool (per-slot dedupe, priorities, reprioritize/drop,
inline mode for wasm).
- engine: Session::thumb_job buckets thumbnails to 128/256/384/512 and serves
them from the cache without decoding the source; decoded thumbnail
sources now in a 384 MB LRU (was unbounded); library attaches
<library>/thumbs; library.info cache stats; library.clearPreviews.
- ui-egui: Renderer runs on JobPool; thumbnails scrolled out of the
prefetch area are dropped from the queue; on-screen thumbnails get
re-prioritized; ui.inspect reports in-flight renders + thumb textures.
- Demo library (24 photos): grid fully drawn 4.4 s cold -> 0.21 s warm
(24/24 disk hits, no source decoded).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- catalog: Store trait (FsStore: temp+fsync+rename+dir fsync; MemStore),
Journal: CRC-checked JSON-lines op log with sequence numbers + atomic
snapshots; torn final record dropped and truncated, stale records after a
snapshot skipped, mid-log damage preserved as catalog.log.damaged-N.
- PushHistory op so each edit logs one history step (not the full history).
- engine: Session::open_library/persist/close_library; every top-level
command is fsynced before returning; presets.json + view.json;
library.info / library.compact commands.
- app: default library ~/Pictures/LightCraft Library (LIGHTCRAFT_LIBRARY or
--library DIR override), --memory for throwaway sessions, snapshot on quit.
- tests: proptest replay(log)==state with random snapshots/reopens, every
truncation of the last record, stale log, mid-log damage, FsStore, engine
restart without close.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export encoding lives in lightcraft-engine::export (pure bytes, shared by app/CLI/MCP/web).
Embeds our own sRGB ICC; writer creates the output folder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>