Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Changed
- **New interface: Safelight.** Why: the floating "glass" workspace read as a web page — blurred cards over the photo, a blue accent competing with the image, web fonts and pointer cursors. Vitrine now has a docked, native-feeling layout: a Windows-style title bar with a Gallery | Develop switch, a command bar (filename and position on the left, actions on the right, folding into a `⋯` menu before the filename truncates on narrow windows), a 340 px inspector with the histogram on top, a tool strip with a sliding indicator, a docked filmstrip and a status bar. Surfaces are neutral graphite separated by hairlines so the photo is the only colour on screen; one safelight amber marks what you changed (an edited dot on every slider), the primary action, ratings and keyboard focus. System font, arrow cursor on controls, no text selection on chrome, menus that switch on hover and close on Escape. Motion is a spring for things that travel (segmented indicators, the tool bar, the filmstrip ring), short fades elsewhere, and all of it turns off under *Reduce motion*. Every slider gained label scrubbing (Shift for fine), click-to-type values and a default tick. Affects: `src/index.css`, `src/components/Layout/*`, `src/components/Controls/*`, `src/components/Panels/*`, `src/components/Gallery/*`, `src/components/Dialogs/*`, `src/layout/photoRegion.ts`.
- **Settings only shows settings that work.** The old panel had always-on read-only checkboxes, selects wired to nothing and a Clear Cache button that did nothing. It now holds RAW defaults for new photos (demosaic, highlights, camera match — the same store the RAW Decode panel remembers), the welcome-screen toggle, a working memory-cache Clear, the rendering path and the version. Affects: `src/components/Panels/SettingsPanel.tsx`.
- **Settings only shows settings that work.** The old panel had always-on read-only checkboxes, selects wired to nothing and a Clear Cache button that did nothing. It now holds the welcome-screen toggle, a working memory-cache Clear, the rendering path and the version. Affects: `src/components/Panels/SettingsPanel.tsx`.
- **Photos that can't be previewed get a neutral tile** (format plus "No preview") instead of a red "Error" block, in the filmstrip and the gallery. Affects: `src/components/Panels/ThumbnailPanel.tsx`, `src/components/Gallery/GalleryView.tsx`.
- Open dialogs list every supported RAW extension (CR3, RAF, NRW, SRW, …), not just seven. Affects: `src/App.tsx`, `src/services/ElectronService.ts`.

### Removed
- **The RAW Decode panel and RAW decode settings.** Why: a PEN-F ORF opened dark and grey, and per-photo decode settings made that state easy to get stuck in and hard to understand. Every RAW now opens one fixed way — full resolution, 16-bit, DCB demosaic, blended highlights, matched to the camera's own look — with nothing to configure. Removed: the RAW Decode panel (demosaic, highlights, camera match), the remembered decode defaults and the RAW section of Settings; decode options an older build saved per photo are ignored, so exports always match the editor. The post-decode **Highlight recovery** slider moved under Basic Adjustments (RAW photos only). Affects: `src/components/Panels/RawDecodePanel.tsx` (deleted), `src/components/Panels/HighlightRecoveryControl.tsx` (new), `src/utils/rawDecodeDefaultsStorage.ts` (deleted), `src/components/Layout/Canvas.tsx`, `src/services/ImageService.ts`, `src/components/Panels/SettingsPanel.tsx`.

### Fixed
- **A failed camera match showed (and cached) a dark, flat image.** Cause: when matching, the native decoder turns auto-brighten off (`-W`) because the match sets the final tone; if the match then failed (the likely cause in packaged builds: its worker script was packed inside `app.asar`, which worker threads may not load from) the un-brightened base was shown as is and written to the disk cache, so every later open reused it. Now the worker is unpacked and loaded from `app.asar.unpacked`, a failed match re-decodes with auto-brighten, a failed match is never cached, and the cache key was bumped so bases written by earlier builds miss once. Affects: `electron/rawDecoder.cjs`, `electron/cameraMatch.cjs`, `electron/baseCache.cjs`, `src/services/RawImageService.ts`, `package.json`.
- **ORF (and every RAW) open stalled for a minute in builds without the native decoder.** Cause: `libraw.js`/`libraw.wasm` are git-ignored, so a checkout had only `public/libraw/worker.js`; the libraw-wasm rung aborted inside its worker without answering, and every open waited out the 60 s watchdog ("Developing full quality…") before falling back to the embedded JPEG. `scripts/sync-libraw-wasm.cjs` now copies the runtime in on install and before every build, the resolver requires the complete runtime (missing → skipped in under a second instead of 62 s), and a file LibRaw can't read fails at once with LibRaw's reason. Affects: `scripts/sync-libraw-wasm.cjs` (new), `electron/librawWasmNode.cjs`, `package.json`.
- **Portrait ORFs turned sideways when full quality arrived.** Cause: the embedded-JPEG fallback ignored the ORF container's orientation (Olympus previews carry none of their own), so the progressive preview opened upright and the "full" decode swapped in rotated. It now orients exactly like the preview, and upscales with the preview's aspect ratio instead of stretching it to the sensor box (5200×3904 includes margins). Verified on the PEN-F fixture at orientations 1, 3, 6 and 8: preview and fallback agree pixel for pixel. Affects: `electron/rawDecoder.cjs`.
- A zoomed-out photo sat in a darker rectangle on the CPU render path (canvas cleared to the old surround colour). Affects: `src/components/Layout/Canvas.tsx`, `src/App.tsx`.
Expand Down
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ click away.

### Why Vitrine

- 📷 **Real RAW** — native LibRaw decoding for 15+ camera formats (CR2/CR3, NEF, ARW, ORF, DNG, RW2, PEF…), with per-photo demosaic and highlight-recovery control.
- 📷 **Real RAW** — native LibRaw decoding for 15+ camera formats (CR2/CR3, NEF, ARW, ORF, DNG, RW2, PEF…), matched to your camera's own look — no decode settings to get wrong.
- ⚡ **Fast** — a GPU pipeline develops your edits in real time; RAW files open in ~0.5 s (embedded preview) while the full 16-bit decode swaps in behind the scenes.
- ✨ **AI where it counts** — Real-ESRGAN ×2/×4 super-resolution and NAFNet motion-deblur run on your GPU, alongside classic denoise, sharpen, and dehaze.
- 🎨 **Truly non-destructive** — adjustments are stored per image and replayed on reopen; the source file is never modified.
Expand Down Expand Up @@ -101,15 +101,13 @@ the versioned `Setup …​.exe` and `… portable.exe`, their `SHA256SUMS.txt`,

![Before and after — RAW to developed](docs/shots/before-after.png)

**Colors that match your camera.** By default, Vitrine fits each RAW's develop to the camera's own embedded render at decode time — so what you see on open is what the camera meant, not a generic bright decode. Measured against OM Workspace (which uses the in-camera engine) on real ORFs: ΔE76 of 1.2–4.7, where Workspace's own difference to the camera JPEG is ~0.8–1.1. Methodology: default settings on both sides, sRGB exports, untouched files; the only known drift is deep-shadow foliage in backlit scenes (the camera's local tone mapping is position-dependent; a color transform is not). Toggle per photo in the RAW Decode panel — off gives Vitrine's neutral bright starting point.
**Colors that match your camera.** By default, Vitrine fits each RAW's develop to the camera's own embedded render at decode time — so what you see on open is what the camera meant, not a generic bright decode. Measured against OM Workspace (which uses the in-camera engine) on real ORFs: ΔE76 of 1.2–4.7, where Workspace's own difference to the camera JPEG is ~0.8–1.1. Methodology: default settings on both sides, sRGB exports, untouched files; the only known drift is deep-shadow foliage in backlit scenes (the camera's local tone mapping is position-dependent; a color transform is not). There is nothing to configure: every RAW opens this way, and if a match can't be made the photo opens with a normal auto-exposed decode instead.

![Camera JPEG vs OM Workspace vs Vitrine — color fidelity comparison](docs/shots/color-fidelity.jpg)

**RAW, handled properly.** Choose the demosaic algorithm and highlight-recovery mode per photo, and read camera/lens details straight from the RAW container.
**RAW, handled properly.** Full-resolution 16-bit decodes, a Highlight recovery slider for blown skies, and camera/lens details read straight from the RAW container.

| RAW Decode (per-photo) | Camera & lens, from the RAW |
|:--:|:--:|
| ![RAW Decode panel](docs/shots/raw-decode.png) | ![Camera and lens EXIF](docs/shots/exif.png) |
![Camera and lens EXIF](docs/shots/exif.png)

**Every tool reads the same** — one docked inspector, the same sliders everywhere, and an amber dot on exactly what you changed.

Expand All @@ -136,16 +134,15 @@ the versioned `Setup …​.exe` and `… portable.exe`, their `SHA256SUMS.txt`,
- **History** — per-image checkpoint timeline; click any checkpoint to restore that state; persists across sessions separately from Ctrl+Z undo.
- **Histogram** — live RGB and luminosity tone-distribution display at the top of the inspector.
- **Gallery** — a library grid view of the open folder with selection, per-tile ratings, rating filter, double-click-to-edit, Del-to-remove (drop from the session or move to the Recycle Bin, always confirmed), and a right-click context menu (Open, Remove…, Show in Explorer); switch to it from the title bar (Gallery | Develop) or the filmstrip.
- **Settings** — RAW defaults for new photos (demosaic, highlights, camera match), the startup welcome screen, and the memory cache.
- **Settings** — the startup welcome screen and the memory cache.

## Features

- **RAW processing** — native LibRaw (`dcraw_emu`) Bayer demosaic in the Electron main process for 15+ formats (CR2/CR3, NEF, ARW, ORF, DNG, RW2, PEF, …), decoding with DCB demosaic + blended highlight reconstruction by default; `libraw-wasm` and embedded-JPEG fallbacks ensure every RAW opens.
- **Camera match (default)** — each RAW is fitted at decode time to its own embedded camera JPEG, so the opening render matches the out-of-camera look (picture mode and gradation included) instead of a generic decode; per-photo toggle in the RAW Decode panel, works with any brand that embeds a preview.
- **RAW decode defaults are remembered** — the demosaic/highlight/Camera-match options you last chose become the default for RAW files you haven't opened before, across sessions; already-opened photos keep their own saved options.
- **Per-image RAW decode control** — a "RAW Decode" panel (shown only for RAW files) lets you choose the demosaic algorithm (AHD/DCB) and highlight-recovery mode (Off/Blend/Reconstruct) per photo (changing either re-decodes from disk), plus a post-decode "Highlight recovery" slider that reconstructs single-channel-clipped highlights; all saved per image and applied on export.
- **Camera match (default)** — each RAW is fitted at decode time to its own embedded camera JPEG, so the opening render matches the out-of-camera look (picture mode and gradation included) instead of a generic decode; works with any brand that embeds a preview, and falls back to a normal auto-exposed decode when a match isn't possible.
- **Highlight recovery** — a post-decode slider under Basic Adjustments (RAW photos only) that reconstructs single-channel-clipped highlights from the surviving channels; saved per image and applied on export.
- **Instant RAW preview (progressive open)** — opening a RAW paints the camera's embedded preview in ~0.5 s with your saved edits applied, while the full 16-bit decode develops in the background and swaps in seamlessly ("Developing full quality…" shows in the footer); pixel-precise actions (Auto adjustments, transforms, upscale, print, copy style) wait for full quality automatically.
- **Persistent RAW decode cache** — decoded full-quality bases are kept on disk (up to 2 GB, LRU) keyed by file, decode options, and file modification time, so reopening a RAW in a later session loads full quality in about a second instead of re-decoding; entries invalidate automatically when the source file or decode options change.
- **Persistent RAW decode cache** — decoded full-quality bases are kept on disk (up to 2 GB, LRU) keyed by file and its modification time, so reopening a RAW in a later session loads full quality in about a second instead of re-decoding; entries invalidate automatically when the source file changes.
- **GPU-accelerated preview** — resident-texture WebGL2 pipeline: image uploaded to the GPU once, all modules run as fragment-shader passes with zero GPU→CPU readback; CPU/Web-Worker fallback runs off the main thread when WebGL2 is unavailable.
- **Preview quality ratchet** — zooming past an image's previous farthest zoom (or applying a crop) automatically re-renders the preview from a higher-resolution source, up to native size, so zoomed and cropped views stay sharp.
- **AI super-resolution upscale** — Real-ESRGAN x4plus (onnxruntime-node + DirectML) runs in the main process for sharper ×2/×4 enlargements when a GPU is available, with tiled bounded-memory inference, live progress, an AI/Standard badge, and automatic fallback to the deterministic Lanczos path.
Expand Down
49 changes: 28 additions & 21 deletions docs/LIBRAW_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,37 +69,43 @@ chain lacked), and it could not even read the file without `window.electronAPI`.
In a non-Electron/browser context `decodeRawFile` therefore surfaces an error
rather than returning fabricated pixels.

## Per-image decode options
## Decode options (fixed)

`RawDecodeOptions` (`src/types/electron.ts`):
Every RAW decodes one way — there is no per-photo or per-user setting. The RAW
Decode panel (demosaic / highlight mode / camera match per photo) and the
remembered decode defaults were removed: they let a photo get stuck in a bad
state (a dark, flat render was reported on a PEN-F ORF) and they answered a
question most photographers never want to ask. Options an older build
persisted per photo are ignored.

`DEFAULT_RAW_DECODE_OPTIONS` (`src/types/electron.ts`, mirrored in
`electron/rawDecoder.cjs`):

```typescript
type DemosaicAlgo = 'ahd' | 'dcb';
type HighlightMode = 'off' | 'blend' | 'reconstruct';
interface RawDecodeOptions { demosaic: DemosaicAlgo; highlightMode: HighlightMode; }
// Default: { demosaic: 'dcb', highlightMode: 'blend' }
{ demosaic: 'dcb', highlightMode: 'blend', cameraMatch: true }
```

These are set per image from the RAW Decode panel and threaded end-to-end:
`RawImageService.reDecode(options)` → `loadRawImage(path, options)` → the
`decode-raw-file` IPC call → `decodeRawFile(filePath, log, options)`, which maps
them onto whichever engine actually runs:
Canvas seeds the store with it on every open, and `ImageService.decodeForExport`
(single export and batch) uses it directly, so an export always matches the
editor. How it maps onto the engines:

| option | native `dcraw_emu` flag | libraw-wasm field |
|---|---|---|
| `demosaic: 'ahd'` / `'dcb'` | `-q 3` / `-q 4` | `userQual: 3` / `4` |
| `highlightMode: 'off'` | (omitted — LibRaw default = clip) | (omitted) |
| `demosaic: 'dcb'` | `-q 4` | `userQual: 4` |
| `highlightMode: 'blend'` | `-H 2` | `highlight: 2` |
| `highlightMode: 'reconstruct'` | `-H 5` | `highlight: 5` |
| `cameraMatch: true` | `-W` (auto-brighten off; the match sets the tone) | — |

The embedded-JPEG fallback ignores both options (there is no demosaic to steer).
**Camera match** fits the decode to the photo's own embedded camera JPEG
(`electron/cameraMatch.cjs`; the full-resolution apply runs in
`cameraMatchWorker.cjs`, which is `asarUnpack`ed and loaded from
`app.asar.unpacked` in packaged builds). If the match can't be made, the native
rung re-decodes **without** `-W` — the un-brightened base is never shown — and
the result carries `cameraMatched: false`, which keeps it out of the disk base
cache so the next open retries the match. The embedded-JPEG fallback ignores all
options (it already is the camera's render).

`reDecode()` re-runs the decode with the new options, re-checks the user hasn't
switched images while the async decode was in flight, then replaces the cached
base + working image and clears the processing pipeline's cache so existing
module edits re-apply on top of the fresh base. It does **not** push a History
checkpoint — decode options are a property of the base image, orthogonal to the
module-edit timeline (see the doc comment on `reDecode` for the full reasoning).
`RawImageService.reDecode(options)` still exists for internal use but has no UI
caller.

## Base-image cache

Expand All @@ -126,4 +132,5 @@ serve pixels from stale decode options.
| Renderer entry point | `src/services/RawImageService.ts` |
| Decode options type + defaults | `src/types/electron.ts` |
| Base-image cache | `src/services/ImageCacheService.ts` |
| RAW Decode panel (UI) | `src/components/Panels/RawDecodePanel.tsx` |
| Camera match (fit + worker) | `electron/cameraMatch.cjs`, `electron/cameraMatchWorker.cjs` |
| Highlight recovery (UI) | `src/components/Panels/HighlightRecoveryControl.tsx` |
Binary file removed docs/shots/raw-decode.png
Binary file not shown.
4 changes: 3 additions & 1 deletion electron/baseCache.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ function optionsHash(options) {
// cameraMatch.cjs) are pixel provenance too, but are not part of this key. If
// they ever change in a release, bump the `cm:` tag (e.g. `cm2:`) so stale
// cached bases fitted with the old parameters miss instead of being served.
return crypto.createHash('sha1').update(`${demosaic}|${highlightMode}|cm:${cameraMatch ? 1 : 0}`).digest('hex').slice(0, 8);
// cm2: entries written before the failed-match fix could hold a dark, never-brightened base
// under the matched key — the bump makes them miss so the RAW decodes fresh.
return crypto.createHash('sha1').update(`${demosaic}|${highlightMode}|cm2:${cameraMatch ? 1 : 0}`).digest('hex').slice(0, 8);
}

/**
Expand Down
14 changes: 13 additions & 1 deletion electron/cameraMatch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,21 @@ async function applyCameraMatch(decoded, orfBuf, log = console) {
}
}

/**
* The worker script on the real filesystem. In a packaged build electron/ lives inside
* app.asar, which worker_threads cannot load from; electron-builder unpacks this file
* (package.json asarUnpack) to app.asar.unpacked, so point there. Unchanged in dev.
*/
function workerScriptPath(dir = __dirname) {
const p = path.join(dir, 'cameraMatchWorker.cjs');
const asar = `${path.sep}app.asar${path.sep}`;
return p.includes(asar) ? p.replace(asar, `${path.sep}app.asar.unpacked${path.sep}`) : p;
}

/** Run the full-res apply loop in a worker thread; transfers the buffer both ways. */
function runApplyWorker(dataArrayBuffer, model) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, 'cameraMatchWorker.cjs'), {
const worker = new Worker(workerScriptPath(), {
workerData: { model },
});
worker.once('message', (msg) => {
Expand All @@ -379,6 +390,7 @@ function runApplyWorker(dataArrayBuffer, model) {

module.exports = {
applyCameraMatch,
workerScriptPath,
// pure internals exported for unit tests
fitTransform,
fitMatrix,
Expand Down
Loading