diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ca536..5c0575d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 16cfedd..fcd08ce 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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. diff --git a/docs/LIBRAW_INTEGRATION.md b/docs/LIBRAW_INTEGRATION.md index 9964cce..28b5705 100644 --- a/docs/LIBRAW_INTEGRATION.md +++ b/docs/LIBRAW_INTEGRATION.md @@ -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 @@ -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` | diff --git a/docs/shots/raw-decode.png b/docs/shots/raw-decode.png deleted file mode 100644 index 3d00d84..0000000 Binary files a/docs/shots/raw-decode.png and /dev/null differ diff --git a/electron/baseCache.cjs b/electron/baseCache.cjs index e3cc68f..c78a5fa 100644 --- a/electron/baseCache.cjs +++ b/electron/baseCache.cjs @@ -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); } /** diff --git a/electron/cameraMatch.cjs b/electron/cameraMatch.cjs index 5b40c65..90892d2 100644 --- a/electron/cameraMatch.cjs +++ b/electron/cameraMatch.cjs @@ -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) => { @@ -379,6 +390,7 @@ function runApplyWorker(dataArrayBuffer, model) { module.exports = { applyCameraMatch, + workerScriptPath, // pure internals exported for unit tests fitTransform, fitMatrix, diff --git a/electron/rawDecoder.cjs b/electron/rawDecoder.cjs index 732354a..d40c832 100644 --- a/electron/rawDecoder.cjs +++ b/electron/rawDecoder.cjs @@ -390,37 +390,55 @@ function sweepStaleRawTmpDirs({ baseDir = os.tmpdir(), maxAgeMs = 24 * 60 * 60 * * @param {object} [log] logger (defaults to console) * @param {object} [options] { demosaic, highlightMode } — defaults to DEFAULT_RAW_DECODE_OPTIONS */ -async function decodeRawFile(filePath, log = console, options) { +async function decodeRawFile(filePath, log = console, options, rungs = {}) { const resolvedOpts = options || DEFAULT_RAW_DECODE_OPTIONS; + // `rungs` lets tests stand in for the engines; production always uses the real ones. + const native = rungs.decodeNative || decodeNative; + const wasm = rungs.decodeWasm || decodeWasm; + const embedded = rungs.decodeEmbeddedJpeg || decodeEmbeddedJpeg; // Camera match post-pass: applied to the true-demosaic rungs only. The // embedded-JPEG fallback rung already IS the camera render — matching it to // itself would be a no-op at best. Fail-open: any camera-match failure - // returns the unmatched decode (see cameraMatch.cjs). + // returns the unmatched decode (see cameraMatch.cjs), and the result says so + // via `cameraMatched` so the renderer never caches a failed match as a match. const maybeMatch = async (decoded) => { if (!resolvedOpts.cameraMatch) return decoded; try { const { applyCameraMatch } = require('./cameraMatch.cjs'); const orfBuf = await fs.promises.readFile(filePath); - return await applyCameraMatch(decoded, orfBuf, log); + const matched = await applyCameraMatch(decoded, orfBuf, log); + // applyCameraMatch hands back the SAME object when it skipped or failed. + return matched === decoded ? { ...decoded, cameraMatched: false } : { ...matched, cameraMatched: true }; } catch (err) { // Fail-open at this level too: a camera-match hiccup (e.g. the source // file vanished between decode and re-read) must not discard a decode // that already succeeded, and must not cascade to the next rung. log.warn(`camera-match post-pass failed (${err.message}) — using unmatched decode`); - return decoded; + return { ...decoded, cameraMatched: false }; } }; try { - return await maybeMatch(await decodeNative(filePath, log, resolvedOpts)); + const matched = await maybeMatch(await native(filePath, log, resolvedOpts)); + if (resolvedOpts.cameraMatch && !matched.cameraMatched) { + // The native rung decodes WITHOUT auto-brighten (-W) when matching, because the + // match is what sets the final tone. Unmatched, that base is far too dark (a PEN-F + // ORF averaged 0.32 vs 0.40 brightened, with crushed midtones) — so a failed match + // re-decodes the normal, auto-brightened way instead of showing the dark base. + log.warn('camera match unavailable — re-decoding with auto-brighten'); + return { ...(await native(filePath, log, { ...resolvedOpts, cameraMatch: false })), cameraMatched: false }; + } + return matched; } catch (nativeError) { log.warn(`Native dcraw_emu decode failed (${nativeError.message}); trying libraw-wasm/Node`); try { - return await maybeMatch(await decodeWasm(filePath, log, resolvedOpts)); + // libraw-wasm never disables auto-brighten, so an unmatched wasm base is already + // correctly exposed — no re-decode needed when the match fails. + return await maybeMatch(await wasm(filePath, log, resolvedOpts)); } catch (wasmError) { log.warn(`libraw-wasm/Node decode failed (${wasmError.message}); falling back to embedded JPEG`); - return await decodeEmbeddedJpeg(filePath, log); + return await embedded(filePath, log); } } } diff --git a/package.json b/package.json index 120bfdb..135d6f9 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "asarUnpack": [ "electron/librawWasmWorker.cjs", "electron/librawWasmNode.cjs", + "electron/cameraMatchWorker.cjs", "**/node_modules/sharp/**", "**/node_modules/@img/**", "**/node_modules/onnxruntime-node/**" diff --git a/src/App.tsx b/src/App.tsx index c26da6a..a8c37f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -111,7 +111,7 @@ export function OriginalPane() { // Build the offscreen canvas when this component mounts AND whenever the base image is // swapped in place (baseImageVersion bumps on every ImageService.updateCurrentImageData call — - // a progressive RAW open's background full-decode swap, or a RAW Decode re-decode). The parent + // a progressive RAW open's background full-decode swap, or a RAW re-decode). The parent // re-keys us (key={currentImage?.id ?? 'none'}) on image SWITCH, so mount alone would cover a // fresh open, but not a swap on the image that's already showing: without baseImageVersion in // the deps, Before kept showing the graded embedded PREVIEW forever if the split stayed open diff --git a/src/components/Dialogs/glassFormStyles.ts b/src/components/Dialogs/glassFormStyles.ts index c091713..b0e8c14 100644 --- a/src/components/Dialogs/glassFormStyles.ts +++ b/src/components/Dialogs/glassFormStyles.ts @@ -7,7 +7,7 @@ import type { CSSProperties } from 'react'; * here so there is one definition to update. */ -/** Native //s — see the file-level -// note below on why Demosaic/Highlights aren't Segmented here). -const selectStyle: CSSProperties = { - width: '100%', - fontSize: 12, - padding: '6px 8px', - borderRadius: 8, - border: '1px solid rgba(255,255,255,.1)', - background: 'rgba(255,255,255,.04)', - color: 'var(--glass-text-label)', -}; - -interface RawDecodePanelProps { - /** - * The currently open image (App's live selection, threaded down through - * AdjustmentPanel). Passed as a prop rather than read from the Zustand store - * because this app keeps `currentImage` in App-local state — the store's - * `currentImage` is never populated, so gating on it left this panel - * permanently invisible. - */ - currentImage?: ImageFileInfo | null; -} - -/** - * Collapsible "RAW Decode" section — demosaic algorithm + highlight recovery mode for the - * currently open RAW file. Hidden entirely for non-RAW images (own gate via the file-extension - * helper rawImageService.isRawFile, since ImageFileInfo carries no reliable isRaw flag). - * - * Both selects are bound to appStore.rawDecodeOptions (kept in lock-step with the actually - * decoded base) and, on change, call rawImageService.reDecode with the full merged options — - * the only path by which decode options take effect (see RawImageService.reDecode). While a - * re-decode is in flight (store.reDecoding) both controls are disabled and a progress note - * is shown, mirroring the guard reDecode itself applies. A rejected re-decode surfaces a - * notification instead of an unhandled promise rejection. - * - * Demosaic/Highlights stay real - * restyled with the same glass tokens reads identically to the rest of the card system - * without trading away that test contract (see the Glass UI design spec — "keep hidden - * selects" is the sanctioned fallback when a control swap would weaken value-binding). - */ -export function RawDecodePanel({ currentImage }: RawDecodePanelProps) { - const rawDecodeOptions = useAppStore((s) => s.rawDecodeOptions); - const reDecoding = useAppStore((s) => s.reDecoding); - const externalParamsVersion = useAppStore((s) => s.externalParamsVersion); - const [open, setOpen] = useState(false); - // Highlight-recovery strength lives on the pipeline module (persisted per-image by - // EditPersistenceService like every other module param — NOT a decode option, so no - // re-decode on change). Mirror it into local state for the slider, re-syncing when the - // open image changes AND on externalParamsVersion: the per-image restore lands AFTER - // the image-change effect fires (async decode), and Canvas bumps the signal once - // restoreState completes — without it a reopened image renders with its saved strength - // while the slider displays 0 (v1.20.0 smoke H2 caught this live). - const [hrStrength, setHrStrength] = useState(readHrStrength); - - // Camera match is a CONTROLLED checkbox bound to store options that only - // update AFTER the multi-second re-decode resolves — without an optimistic - // pending state the box appears frozen for the whole decode (verified live: - // users click it repeatedly believing it's dead). Pending shows the target - // state immediately; cleared once the re-decode lands (or fails, reverting - // the visual to the store truth). - const [pendingCameraMatch, setPendingCameraMatch] = useState(null); - - useEffect(() => { - if (!reDecoding) setPendingCameraMatch(null); - }, [reDecoding, rawDecodeOptions.cameraMatch]); - - useEffect(() => { - setHrStrength(readHrStrength()); - }, [currentImage?.id, externalParamsVersion]); - - const applyHrStrength = (strength: number) => { - setHrStrength(strength); - const m = imageProcessingPipeline.getModule(HR_MODULE_ID) as - | { setParams?: (p: { strength: number }) => void } - | undefined; - m?.setParams?.({ strength }); - imageProcessingPipeline.invalidateModuleCache(HR_MODULE_ID); - useAppStore.getState().triggerReprocessing(); // no re-decode — just re-runs the pipeline - }; - - const isRaw = currentImage ? rawImageService.isRawFile(currentImage.path) : false; - if (!isRaw) return null; - - const runReDecode = (options: RawDecodeOptions) => { - rawImageService.reDecode(options, currentImage?.id).catch((err) => { - notificationService.error( - 'RAW re-decode failed', - err instanceof Error ? err.message : String(err), - ); - }); - }; - - const handleDemosaicChange = (demosaic: DemosaicAlgo) => { - runReDecode({ ...rawDecodeOptions, demosaic }); - }; - - const handleHighlightChange = (highlightMode: HighlightMode) => { - runReDecode({ ...rawDecodeOptions, highlightMode }); - }; - - const handleCameraMatchChange = (cameraMatch: boolean) => { - setPendingCameraMatch(cameraMatch); // optimistic — the box flips NOW, not after the decode - runReDecode({ ...rawDecodeOptions, cameraMatch }); - }; - - return ( -
-
setOpen((o) => !o)} - > - {open ? ( - - ) : ( - - )} - - RAW Decode - {!open && ( - - {rawDecodeOptions.demosaic.toUpperCase()} · {rawDecodeOptions.highlightMode} - {rawDecodeOptions.cameraMatch ? ' · cam' : ''} - - )} - {reDecoding && Decoding…} -
- {open && ( -
-
- - handleCameraMatchChange(e.target.checked)} - /> -
-
- - -
-
- - -
-
-
- - - {hrStrength.toFixed(0)} - -
- applyHrStrength(parseFloat(e.target.value))} - onDoubleClick={() => applyHrStrength(0)} - className="slider" - style={{ width: '100%' }} - /> -
- {HR_TOOLTIP} -
-
- {reDecoding && ( -
- Re-decoding RAW file… -
- )} -
- {RE_DECODE_TOOLTIP} -
-
- )} -
- ); -} - -export default RawDecodePanel; diff --git a/src/components/Panels/SettingsPanel.tsx b/src/components/Panels/SettingsPanel.tsx index a5f3a8f..1769190 100644 --- a/src/components/Panels/SettingsPanel.tsx +++ b/src/components/Panels/SettingsPanel.tsx @@ -1,27 +1,13 @@ import { useEffect, useState, type ReactNode } from 'react'; import { useAppStore } from '../../stores/appStore'; -import { Segmented, type SegmentedOption } from '../Controls/Segmented'; import { SectionLabel } from '../Controls/SectionLabel'; import { ChipButton } from '../Controls/ChipButton'; import { imageCacheService } from '../../services/ImageCacheService'; import { notificationService } from '../../services/NotificationService'; -import { loadRawDecodeDefaults, saveRawDecodeDefaults } from '../../utils/rawDecodeDefaultsStorage'; -import { DEFAULT_RAW_DECODE_OPTIONS, type DemosaicAlgo, type HighlightMode, type RawDecodeOptions } from '../../types/electron'; // Same key the Welcome screen's "Don't show again" writes and App reads at startup. const WELCOME_DISMISSED_KEY = 'photo-editor-welcome-dismissed'; -const DEMOSAIC_OPTIONS: SegmentedOption[] = [ - { value: 'ahd', label: 'AHD' }, - { value: 'dcb', label: 'DCB' }, -]; - -const HIGHLIGHT_OPTIONS: SegmentedOption[] = [ - { value: 'off', label: 'Off' }, - { value: 'blend', label: 'Blend' }, - { value: 'reconstruct', label: 'Reconstruct' }, -]; - function readWelcomeEnabled(): boolean { try { return localStorage.getItem(WELCOME_DISMISSED_KEY) !== 'true'; } catch { return true; } } @@ -31,29 +17,22 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; } -/** Label on the left, control on the right — or, `stacked`, the control full-width underneath. */ -function Row({ label, hint, stacked, children }: { label: string; hint?: string; stacked?: boolean; children: ReactNode }) { - const text = ( -
-
{label}
- {hint &&
{hint}
} -
- ); - if (stacked) { - return
{text}{children}
; - } +/** Label on the left, control on the right — one line per setting. */ +function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return (
- {text} +
+
{label}
+ {hint &&
{hint}
} +
{children}
); } /** - * Settings: only things that are real and take effect. RAW defaults apply to photos that have no - * saved decode options of their own (a photo you already opened keeps its own, see - * rawDecodeDefaultsStorage); the rest is startup behaviour, memory and version info. + * Settings: only things that are real and take effect — startup behaviour, memory and version + * info. (RAW decoding has no settings: every RAW decodes one fixed way, see rawDecoder.cjs.) */ export function SettingsPanel() { const renderMode = useAppStore((s) => s.renderMode); @@ -61,7 +40,6 @@ export function SettingsPanel() { // Real app version via the same IPC source the splash and the MenuBar About // dialog use (main.cjs 'get-app-version' reads package.json). const [appVersion, setAppVersion] = useState(null); - const [rawDefaults, setRawDefaults] = useState(DEFAULT_RAW_DECODE_OPTIONS); const [welcomeEnabled, setWelcomeEnabled] = useState(readWelcomeEnabled); const [cacheSize, setCacheSize] = useState(() => imageCacheService.getStats().totalSize); @@ -70,18 +48,9 @@ export function SettingsPanel() { window.electronAPI?.getAppVersion?.() .then((version) => { if (!cancelled) setAppVersion(version); }) .catch(() => { /* leave null — the line shows a placeholder */ }); - loadRawDecodeDefaults() - .then((opts) => { if (!cancelled) setRawDefaults(opts); }) - .catch(() => { /* factory defaults stay */ }); return () => { cancelled = true; }; }, []); - const updateRawDefaults = (patch: Partial) => { - const next = { ...rawDefaults, ...patch }; - setRawDefaults(next); - saveRawDecodeDefaults(next); - }; - const toggleWelcome = (enabled: boolean) => { setWelcomeEnabled(enabled); try { @@ -105,22 +74,6 @@ export function SettingsPanel() {
-
- RAW defaults for new photos - - updateRawDefaults({ demosaic })} /> - - - updateRawDefaults({ highlightMode })} /> - - - updateRawDefaults({ cameraMatch: e.target.checked })} /> - -
-
Startup diff --git a/src/services/BatchProcessingService.ts b/src/services/BatchProcessingService.ts index 220eb8e..f5af428 100644 --- a/src/services/BatchProcessingService.ts +++ b/src/services/BatchProcessingService.ts @@ -328,10 +328,8 @@ export class BatchProcessingService { // Decode the image via the side-effect-free export decode (NOT loadImage). This gives the // batch three things loadImage did NOT: - // 1. PER-IMAGE decode options — decodeForExport resolves each file's own persisted RAW - // options (EditPersistenceService.getSavedRawDecodeOptions, shape-validated) instead of - // the STORE's current options (which belong to whatever image the user has open). A - // batch of RAWs with different demosaic/highlight settings now each decode with THEIR own. + // 1. The same fixed RAW decode the editor uses — decodeForExport always decodes with + // DEFAULT_RAW_DECODE_OPTIONS, so a batch can never render a RAW differently from the editor. // 2. No editor side effects — it never sets currentImage / fires notifyImageLoaded, so a // batch run no longer swaps the user's open image out from under them per file. // 3. interactive=false disk behaviour — decodeForExport decodes with interactive=false, so a diff --git a/src/services/ImageService.ts b/src/services/ImageService.ts index 24599d6..080a039 100644 --- a/src/services/ImageService.ts +++ b/src/services/ImageService.ts @@ -7,9 +7,7 @@ import { canvasPoolService } from './CanvasPoolService'; import { autoRawAdjustmentService, RAWDetectionResult } from './AutoRawAdjustmentService'; import { ImageProcessingPipeline } from './ImageProcessingPipeline'; import { useAppStore } from '../stores/appStore'; -import { editPersistenceService } from './EditPersistenceService'; -import { RawDecodeOptions } from '../types/electron'; -import { loadRawDecodeDefaults } from '../utils/rawDecodeDefaultsStorage'; +import { DEFAULT_RAW_DECODE_OPTIONS, RawDecodeOptions } from '../types/electron'; export interface ImageData { width: number; @@ -406,7 +404,7 @@ export class ImageService { storeOpts.highlightMode !== decodeOptions.highlightMode || !storeOpts.cameraMatch !== !decodeOptions.cameraMatch; - // A re-decode (RawDecodePanel → RawImageService.reDecode) for THIS path may be in flight. + // A re-decode (RawImageService.reDecode) for THIS path may be in flight. // reDecode updates the store's rawDecodeOptions only AFTER its own decode resolves, so when // the ORIGINAL background decode lands FIRST (the common order — it started earlier), // `optionsChanged` still reads false here even though a fresher re-decode is about to own @@ -849,17 +847,9 @@ export class ImageService { logger.info(`Decoding image for export (no editor side effects): ${filePath}`); if (rawImageService.isRawFile(filePath)) { - // Honor the per-image decode options so the export matches what the user sees in the - // preview: the CURRENTLY open image's options live in the store (source of truth while - // it's open); any OTHER file's options were persisted by EditPersistenceService the last - // time it was open. Neither present -> the user's saved decode defaults. - const isCurrentImage = this.getCurrentImage()?.filePath === filePath; - const decodeOptions = isCurrentImage - ? useAppStore.getState().rawDecodeOptions - // No saved per-image options → the user's last-chosen defaults (same - // fallback the interactive open uses), so a batch export renders a - // never-opened RAW the way opening it would. - : (await editPersistenceService.getSavedRawDecodeOptions(filePath)) ?? (await loadRawDecodeDefaults()); + // Every RAW decodes one fixed way — the same options the interactive open uses — so an + // export always matches what the editor shows. + const decodeOptions = DEFAULT_RAW_DECODE_OPTIONS; // interactive=false: an export decode is a one-shot the user never reopens interactively — // it must not write-through to (and churn) the disk base-cache LRU. Disk READS still apply. diff --git a/src/services/RawImageService.ts b/src/services/RawImageService.ts index 1c91394..bd66d37 100644 --- a/src/services/RawImageService.ts +++ b/src/services/RawImageService.ts @@ -16,7 +16,6 @@ import { imageCacheService } from './ImageCacheService'; import { editPersistenceService } from './EditPersistenceService'; import { useAppStore } from '../stores/appStore'; import { type RawDecodeOptions } from '../types/electron'; -import { saveRawDecodeDefaults } from '../utils/rawDecodeDefaultsStorage'; import { RAW_EXTENSIONS_DOTTED } from '../utils/rawExtensions'; export interface RawImageData { @@ -224,11 +223,6 @@ export class RawImageService { // (scheduleSave writes serialize(), which embeds rawDecodeOptions into the edit state). store.setRawDecodeOptions(options); editPersistenceService.scheduleSave(); - // A successful user-initiated re-decode is the commit point for the - // SESSION-CROSSING default too: RAW files with no saved per-image options - // will now open with these settings (utils/rawDecodeDefaultsStorage.ts). - saveRawDecodeDefaults(options); - // Clear cached module results (base changed) and reprocess so the existing edits re-apply. imageProcessingPipeline.clearCache(); store.triggerReprocessing(); @@ -260,7 +254,7 @@ export class RawImageService { // This is the L2 tier behind the in-memory L1 base cache (ImageCacheService), which // ImageService.loadImage already checked first — read order is L1 → L2 → LibRaw. The // progressive PREVIEW never routes through here, so it is never disk-cached. - let result: { data: ArrayBuffer; width: number; height: number; channels?: number; bitDepth?: number } | null = null; + let result: { data: ArrayBuffer; width: number; height: number; channels?: number; bitDepth?: number; cameraMatched?: boolean } | null = null; let fromDiskCache = false; if (window.electronAPI.baseCacheRead) { try { @@ -299,7 +293,10 @@ export class RawImageService { // INTERACTIVE only: a batch export of 50 RAWs (or an export decode) must not churn the // ~2GB disk LRU with one-shot decodes it will never reopen. Disk READS stay enabled for // everyone (above) — a coherent, free win — but only the Canvas open path WRITES through. - if (interactive && !fromDiskCache && result.bitDepth === 16 && window.electronAPI.baseCacheWrite) { + // A requested-but-failed camera match is a transient degradation too: persisting it under + // the matched key would serve the unmatched look on every later open instead of retrying. + const matchFailed = !!decodeOptions?.cameraMatch && result.cameraMatched === false; + if (interactive && !fromDiskCache && !matchFailed && result.bitDepth === 16 && window.electronAPI.baseCacheWrite) { try { void window.electronAPI.baseCacheWrite(filePath, decodeOptions, { data: result.data, diff --git a/src/test/batchPerImageDecodeOptions.test.ts b/src/test/batchPerImageDecodeOptions.test.ts index 5588203..7dcb959 100644 --- a/src/test/batchPerImageDecodeOptions.test.ts +++ b/src/test/batchPerImageDecodeOptions.test.ts @@ -1,16 +1,16 @@ /** - * Q3 item 1: a batch run must decode each RAW with ITS OWN persisted decode options, not the - * STORE's current options (which belong to whatever image the user has open). BatchProcessingService - * now decodes via ImageService.decodeForExport (side-effect-free, per-image option resolution) rather - * than loadImage (store options). This test drives a real two-image batch job through the public API - * and spies the decode IPC to prove each path decoded with its own options. + * A batch run decodes every RAW the one fixed way the editor does (the RAW Decode panel and + * per-photo decode options were removed). Options an older build persisted per photo must be + * ignored, so a batch export can never disagree with what the editor shows. BatchProcessingService + * decodes via ImageService.decodeForExport (side-effect-free). This test drives a real two-image + * batch job through the public API and spies the decode IPC. */ import { batchProcessingService, BatchProcessingSettings } from '../services/BatchProcessingService'; import { exportService } from '../services/ExportService'; import { editPersistenceService } from '../services/EditPersistenceService'; import { imageService } from '../services/ImageService'; import { ImageFileInfo } from '../services/FileSystemService'; -import { RawDecodeOptions } from '../types/electron'; +import { DEFAULT_RAW_DECODE_OPTIONS, RawDecodeOptions } from '../types/electron'; const OPTS_A: RawDecodeOptions = { demosaic: 'ahd', highlightMode: 'off' }; const OPTS_B: RawDecodeOptions = { demosaic: 'dcb', highlightMode: 'reconstruct' }; @@ -50,7 +50,7 @@ beforeEach(() => { storeGet: jest.fn(), storeSet: jest.fn(), }; - // No image open → decodeForExport resolves BOTH files via their persisted per-image options. + // No image open, and each file carries options an older build persisted — to be ignored. jest.spyOn(imageService, 'getCurrentImage').mockReturnValue(null); jest.spyOn(editPersistenceService, 'getSavedRawDecodeOptions').mockImplementation( async (path: string) => (path === '/a.orf' ? OPTS_A : OPTS_B), @@ -66,8 +66,8 @@ afterEach(() => { jest.restoreAllMocks(); }); -describe('BatchProcessingService — per-image RAW decode options', () => { - it('decodes each batch image with its OWN persisted options (not the store options)', async () => { +describe('BatchProcessingService — fixed RAW decode', () => { + it('decodes every batch image with the default options, ignoring persisted per-photo options', async () => { const jobId = batchProcessingService.createBatchJob( 'per-image-opts', [rawImage('/a.orf', 'a.orf'), rawImage('/b.orf', 'b.orf')], @@ -77,10 +77,8 @@ describe('BatchProcessingService — per-image RAW decode options', () => { await batchProcessingService.startBatchJob(jobId); - // Each path hit the decode IPC with ITS OWN options — proof the batch no longer smears one - // image's options across the whole run. - expect(decodeApi()).toHaveBeenCalledWith('/a.orf', OPTS_A); - expect(decodeApi()).toHaveBeenCalledWith('/b.orf', OPTS_B); + expect(decodeApi()).toHaveBeenCalledWith('/a.orf', DEFAULT_RAW_DECODE_OPTIONS); + expect(decodeApi()).toHaveBeenCalledWith('/b.orf', DEFAULT_RAW_DECODE_OPTIONS); // And the job completed cleanly (both images exported). const job = batchProcessingService.getJobs().find((j) => j.id === jobId); diff --git a/src/test/cameraMatchFallback.test.ts b/src/test/cameraMatchFallback.test.ts new file mode 100644 index 0000000..1037954 --- /dev/null +++ b/src/test/cameraMatchFallback.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ +/** + * A failed camera match must never leave the dark base on screen. The native rung decodes with + * auto-brighten OFF (-W) when matching, because the match sets the final tone; if the match then + * fails (e.g. its worker can't load in a packaged build), that base used to be shown — and cached + * — as is: a dark, flat image (reported on a PEN-F ORF). Now the native rung re-decodes the normal + * auto-brightened way and the result is flagged `cameraMatched: false` so it is never cached. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const applyCameraMatch = jest.fn(); +jest.mock('../../electron/cameraMatch.cjs', () => ({ applyCameraMatch: (...a: unknown[]) => applyCameraMatch(...a) })); + +type Decoded = { data: ArrayBuffer; width: number; height: number; channels: number; bitDepth: number; cameraMatched?: boolean; tag?: string }; +type Opts = { demosaic: string; highlightMode: string; cameraMatch?: boolean }; +const { decodeRawFile } = require('../../electron/rawDecoder.cjs') as { + decodeRawFile: (p: string, log: unknown, o: Opts, rungs: Record) => Promise; +}; + +const quiet = { log: () => {}, warn: () => {} }; +const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cm-fallback-')), 'x.ORF'); +fs.writeFileSync(file, Buffer.alloc(16)); +const base = (tag: string): Decoded => ({ data: new ArrayBuffer(6), width: 1, height: 1, channels: 3, bitDepth: 16, tag }); +const MATCH = { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: true }; + +describe('camera match fallback', () => { + beforeEach(() => applyCameraMatch.mockReset()); + + it('re-decodes the native rung with auto-brighten when the match fails', async () => { + applyCameraMatch.mockImplementation(async (d: Decoded) => d); // same object back = skipped/failed + const native = jest.fn(async (_p: string, _l: unknown, o: Opts) => base(o.cameraMatch ? 'dark -W' : 'brightened')); + const r = await decodeRawFile(file, quiet, MATCH, { decodeNative: native }); + expect(native).toHaveBeenCalledTimes(2); + expect(native.mock.calls[1][2].cameraMatch).toBe(false); + expect(r.tag).toBe('brightened'); + expect(r.cameraMatched).toBe(false); + }); + + it('keeps a successful match and flags it', async () => { + applyCameraMatch.mockImplementation(async (d: Decoded) => ({ ...d, tag: 'matched' })); + const native = jest.fn(async () => base('dark -W')); + const r = await decodeRawFile(file, quiet, MATCH, { decodeNative: native }); + expect(native).toHaveBeenCalledTimes(1); + expect(r.tag).toBe('matched'); + expect(r.cameraMatched).toBe(true); + }); + + it('flags a failed match on the wasm rung without re-decoding (wasm never disables auto-brighten)', async () => { + applyCameraMatch.mockImplementation(async (d: Decoded) => d); + const native = jest.fn(async () => { throw new Error('no dcraw_emu'); }); + const wasm = jest.fn(async () => base('wasm')); + const r = await decodeRawFile(file, quiet, MATCH, { decodeNative: native, decodeWasm: wasm }); + expect(wasm).toHaveBeenCalledTimes(1); + expect(r.tag).toBe('wasm'); + expect(r.cameraMatched).toBe(false); + }); + + it('points the worker at app.asar.unpacked in a packaged build', () => { + jest.isolateModules(() => { + const { workerScriptPath } = jest.requireActual('../../electron/cameraMatch.cjs') as { workerScriptPath: (dir?: string) => string }; + const packaged = path.join(path.sep, 'Vitrine', 'resources', 'app.asar', 'electron'); + expect(workerScriptPath(packaged)).toBe(path.join(path.sep, 'Vitrine', 'resources', 'app.asar.unpacked', 'electron', 'cameraMatchWorker.cjs')); + const dev = path.join(path.sep, 'src', 'vitrine', 'electron'); + expect(workerScriptPath(dev)).toBe(path.join(dev, 'cameraMatchWorker.cjs')); + }); + }); +}); diff --git a/src/test/canvasEditRestoreBeforeFirstPass.test.tsx b/src/test/canvasEditRestoreBeforeFirstPass.test.tsx index 69f550b..f9052fb 100644 --- a/src/test/canvasEditRestoreBeforeFirstPass.test.tsx +++ b/src/test/canvasEditRestoreBeforeFirstPass.test.tsx @@ -115,8 +115,9 @@ describe('Canvas.loadImage — persisted edits apply before the first pass', () // Restore is seeded with the fetched state at the decoded dimensions. expect(editPersistenceService.restoreState).toHaveBeenCalledWith(EDIT_STATE, 4000, 3000, IMG_A.path); - // Decode options came from the same single read. - expect(useAppStore.getState().rawDecodeOptions).toEqual(AHD_RECON); + // Persisted per-photo decode options (from an older build) are ignored: every RAW decodes + // one fixed way. + expect(useAppStore.getState().rawDecodeOptions).toEqual(DEFAULT_RAW_DECODE_OPTIONS); // No redundant second pass: the post-load triggerReprocessing is gone. expect(useAppStore.getState().processingVersion).toBe(0); diff --git a/src/test/canvasLoadImageRace.test.tsx b/src/test/canvasLoadImageRace.test.tsx index 5821faa..8884b7f 100644 --- a/src/test/canvasLoadImageRace.test.tsx +++ b/src/test/canvasLoadImageRace.test.tsx @@ -181,7 +181,7 @@ describe('Canvas.loadImage — mid-flight setRawDecodeOptions race (pre-decode)' (checkpointService.getCheckpoints as jest.Mock).mockReturnValue([{ id: 1 }]); }); - it("does not let a superseded image's saved decode options land after a newer image already set its own", async () => { + it("bails a superseded load before it decodes, and persisted per-photo decode options never reach the store", async () => { // Decode options now arrive as part of the single full-edit-state read (getSavedEditState). const stateFor = (opts: RawDecodeOptions) => ({ version: 1, modules: {}, rawDecodeOptions: opts }); let resolveA: (state: unknown) => void = () => {}; @@ -208,18 +208,19 @@ describe('Canvas.loadImage — mid-flight setRawDecodeOptions race (pre-decode)' await Promise.resolve(); rerender( {}} onActualSize={() => {}} onZoomIn={() => {}} onZoomOut={() => {}} zoom={1} currentImage={IMG_B} />); - // Flush B's (fast) load through to completion — B's saved options land in the store. + // Flush B's (fast) load through to completion. Every RAW decodes one fixed way now, so B's + // persisted options (from an older build) are ignored — the store holds the defaults. await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); - expect(useAppStore.getState().rawDecodeOptions).toEqual(B_OPTIONS); + expect(useAppStore.getState().rawDecodeOptions).toEqual(DEFAULT_RAW_DECODE_OPTIONS); // Now resolve A's stale getSavedEditState call. resolveA(stateFor(A_OPTIONS)); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); - // A's (stale) options must NOT have overwritten B's — the guard bails before the write. - expect(useAppStore.getState().rawDecodeOptions).toEqual(B_OPTIONS); + // A's stale resolution changes nothing — the guard bails before any write. + expect(useAppStore.getState().rawDecodeOptions).toEqual(DEFAULT_RAW_DECODE_OPTIONS); // And A's decode must never even have been requested — the guard returns before // reaching imageService.loadImage at all. expect((imageService.loadImage as jest.Mock).mock.calls.map((c) => c[0])).not.toContain(IMG_A.path); diff --git a/src/test/glassModulePortsB.test.tsx b/src/test/glassModulePortsB.test.tsx index 20e35af..fe25a7a 100644 --- a/src/test/glassModulePortsB.test.tsx +++ b/src/test/glassModulePortsB.test.tsx @@ -154,29 +154,50 @@ describe('HistoryPanel — glass port', () => { }); }); -// ── RAW Decode ─────────────────────────────────────────────────────────────── +// ── Highlight recovery (RAW) ───────────────────────────────────────────────── +// The RAW Decode panel was removed (every RAW decodes one fixed way); its post-decode +// Highlight recovery slider moved under Basic Adjustments for RAW photos. jest.mock('../services/RawImageService', () => ({ - rawImageService: { isRawFile: jest.fn(() => true), reDecode: jest.fn(async () => {}) }, + rawImageService: { + isRawFile: jest.fn((p: string) => /\.(orf|cr2|cr3|nef|arw|dng|raf|rw2)$/i.test(p)), + reDecode: jest.fn(async () => {}), + }, })); jest.mock('../services/NotificationService', () => ({ notificationService: { error: jest.fn() } })); -import { RawDecodePanel } from '../components/Panels/RawDecodePanel'; -import { useAppStore } from '../stores/appStore'; -import { DEFAULT_RAW_DECODE_OPTIONS } from '../types/electron'; +import { HighlightRecoveryControl } from '../components/Panels/HighlightRecoveryControl'; +import { rawImageService } from '../services/RawImageService'; +import { imageProcessingPipeline } from '../services/ImageProcessingPipeline'; import type { ImageFileInfo } from '../services/FileSystemService'; const RAW_IMAGE: ImageFileInfo = { id: '1', name: 'photo.orf', path: '/photo.orf', size: 100, format: 'orf', type: 'image', lastModified: 0, dateModified: new Date(), }; +const JPEG_IMAGE: ImageFileInfo = { ...RAW_IMAGE, id: '2', name: 'photo.jpg', path: '/photo.jpg', format: 'jpg' }; +const hrModule = () => + imageProcessingPipeline.getModule('highlightrecovery') as unknown as { + getParams: () => { strength: number }; setParams: (p: { strength: number }) => void; + }; + +describe('HighlightRecoveryControl', () => { + beforeEach(() => hrModule().setParams({ strength: 0 })); + + it('renders nothing for a non-RAW photo or with no photo open', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + rerender(); + expect(container).toBeEmptyDOMElement(); + }); -describe('RawDecodePanel — glass port', () => { - it('renders as a docked inspector section and keeps the Demosaic/Highlights selects labeled', () => { - useAppStore.setState({ rawDecodeOptions: DEFAULT_RAW_DECODE_OPTIONS, reDecoding: false }); - render(); + it('is a shared SliderRow (default 0) for a RAW photo', () => { + render(); + expect(screen.getByRole('slider', { name: 'Highlight recovery' })).toHaveValue('0'); + }); - expect(screen.getByRole('region', { name: 'RAW Decode' })).toBeInTheDocument(); - fireEvent.click(screen.getByText('RAW Decode')); - expect(screen.getByLabelText('Demosaic')).toBeInTheDocument(); - expect(screen.getByLabelText('Highlights')).toBeInTheDocument(); + it('sets the module strength without re-decoding the file', () => { + render(); + fireEvent.change(screen.getByRole('slider', { name: 'Highlight recovery' }), { target: { value: '65' } }); + expect(hrModule().getParams().strength).toBe(65); + expect(rawImageService.reDecode).not.toHaveBeenCalled(); }); }); diff --git a/src/test/rawDecodeDefaultsStorage.test.ts b/src/test/rawDecodeDefaultsStorage.test.ts deleted file mode 100644 index 7b48d91..0000000 --- a/src/test/rawDecodeDefaultsStorage.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * RAW Decode defaults memory (v1.31.0) — the last user-chosen decode options - * become the default for never-opened RAW files, across pictures and sessions. - * Persisted via the MAIN-PROCESS durable store (storeGet/storeSet), NOT - * localStorage — a renderer localStorage write was live-proven to vanish when - * the app quit shortly after. Contract: round-trip; cameraMatch stored as an - * explicit boolean; corrupt payloads degrade to factory defaults; no-Electron - * environments degrade to factory defaults. - */ -import { - saveRawDecodeDefaults, - loadRawDecodeDefaults, - RAW_DECODE_DEFAULTS_KEY, -} from '../utils/rawDecodeDefaultsStorage'; -import { DEFAULT_RAW_DECODE_OPTIONS } from '../types/electron'; - -describe('rawDecodeDefaultsStorage', () => { - let mem: Record; - - beforeEach(() => { - mem = {}; - (window as unknown as { electronAPI: unknown }).electronAPI = { - storeGet: jest.fn(async (k: string) => (k in mem ? mem[k] : null)), - storeSet: jest.fn(async (k: string, v: unknown) => { mem[k] = v; return true; }), - }; - }); - - afterEach(() => { - delete (window as unknown as { electronAPI?: unknown }).electronAPI; - }); - - test('round-trips the chosen options through the durable store', async () => { - saveRawDecodeDefaults({ demosaic: 'ahd', highlightMode: 'reconstruct', cameraMatch: false }); - await Promise.resolve(); // let the fire-and-forget storeSet land - expect(await loadRawDecodeDefaults()).toEqual({ - demosaic: 'ahd', - highlightMode: 'reconstruct', - cameraMatch: false, - }); - }); - - test('absent cameraMatch is stored as an explicit false', async () => { - saveRawDecodeDefaults({ demosaic: 'dcb', highlightMode: 'off' }); - await Promise.resolve(); - expect((mem[RAW_DECODE_DEFAULTS_KEY] as { cameraMatch: boolean }).cameraMatch).toBe(false); - }); - - test('unset store yields factory defaults', async () => { - expect(await loadRawDecodeDefaults()).toEqual(DEFAULT_RAW_DECODE_OPTIONS); - }); - - test('out-of-enum values degrade to factory defaults', async () => { - mem[RAW_DECODE_DEFAULTS_KEY] = { demosaic: 'xtrans', highlightMode: 'blend' }; - expect(await loadRawDecodeDefaults()).toEqual(DEFAULT_RAW_DECODE_OPTIONS); - }); - - test('a rejecting store IPC degrades to factory defaults', async () => { - (window as unknown as { electronAPI: { storeGet: jest.Mock } }).electronAPI.storeGet = - jest.fn(async () => { throw new Error('ipc dead'); }); - expect(await loadRawDecodeDefaults()).toEqual(DEFAULT_RAW_DECODE_OPTIONS); - }); - - test('no electronAPI degrades to factory defaults (and save is a no-op)', async () => { - delete (window as unknown as { electronAPI?: unknown }).electronAPI; - expect(() => saveRawDecodeDefaults({ demosaic: 'ahd', highlightMode: 'off' })).not.toThrow(); - expect(await loadRawDecodeDefaults()).toEqual(DEFAULT_RAW_DECODE_OPTIONS); - }); - - test('load returns a fresh object each time (no shared mutable state)', async () => { - saveRawDecodeDefaults({ demosaic: 'ahd', highlightMode: 'blend', cameraMatch: true }); - await Promise.resolve(); - const a = await loadRawDecodeDefaults(); - a.demosaic = 'dcb'; - expect((await loadRawDecodeDefaults()).demosaic).toBe('ahd'); - }); -}); diff --git a/src/test/rawDecodeOptions.test.ts b/src/test/rawDecodeOptions.test.ts index 8cfdec2..e3485bd 100644 --- a/src/test/rawDecodeOptions.test.ts +++ b/src/test/rawDecodeOptions.test.ts @@ -6,7 +6,6 @@ import { checkpointService } from '../services/CheckpointService'; import { imageProcessingPipeline } from '../services/ImageProcessingPipeline'; import { imageCacheService } from '../services/ImageCacheService'; import { DEFAULT_RAW_DECODE_OPTIONS, RawDecodeOptions } from '../types/electron'; -import { saveRawDecodeDefaults } from '../utils/rawDecodeDefaultsStorage'; import type { ImageData as ServiceImageData } from '../services/ImageService'; const AHD_RECON: RawDecodeOptions = { demosaic: 'ahd', highlightMode: 'reconstruct' }; @@ -213,7 +212,7 @@ describe('RawImageService.reDecode', () => { }); }); -describe('ImageService.decodeForExport — per-file RAW decode options', () => { +describe('ImageService.decodeForExport — one fixed RAW decode', () => { const px = new Uint16Array(4 * 2 * 3).fill(32768); // 3ch 16-bit native decode payload beforeEach(() => { @@ -234,52 +233,27 @@ describe('ImageService.decodeForExport — per-file RAW decode options', () => { const api = () => (window as unknown as { electronAPI: { decodeRawFile: jest.Mock } }).electronAPI; - it('uses the store options when exporting the CURRENT image', async () => { + // The RAW Decode panel and per-photo decode options were removed: every RAW decodes one fixed + // way, so an export can never disagree with what the editor shows. + it('decodes the CURRENT image with the default options even if the store holds others', async () => { jest.spyOn(imageService, 'getCurrentImage').mockReturnValue({ width: 4, height: 2, data: new Float32Array(4 * 2 * 4), fileName: 'photo.orf', filePath: '/photo.orf', isRaw: true, }); useAppStore.getState().setRawDecodeOptions(AHD_RECON); - const getSaved = jest.spyOn(editPersistenceService, 'getSavedRawDecodeOptions'); await imageService.decodeForExport('/photo.orf'); - expect(api().decodeRawFile).toHaveBeenCalledWith('/photo.orf', AHD_RECON); - expect(getSaved).not.toHaveBeenCalled(); + expect(api().decodeRawFile).toHaveBeenCalledWith('/photo.orf', DEFAULT_RAW_DECODE_OPTIONS); }); - it('uses the persisted per-image options when exporting a NON-current file', async () => { - jest.spyOn(imageService, 'getCurrentImage').mockReturnValue({ - width: 4, height: 2, data: new Float32Array(4 * 2 * 4), - fileName: 'photo.orf', filePath: '/photo.orf', isRaw: true, - }); - jest.spyOn(editPersistenceService, 'getSavedRawDecodeOptions').mockResolvedValue(AHD_RECON); - - await imageService.decodeForExport('/other.orf'); - - expect(api().decodeRawFile).toHaveBeenCalledWith('/other.orf', AHD_RECON); - }); - - it('falls back to the USER decode defaults for a non-current file with nothing persisted', async () => { - // v1.31.0: the fallback is the user's saved decode defaults (main-process - // durable store), not the factory constants. Wire the storeGet/storeSet - // mocks to a real in-memory map so a save round-trips. - const mem: Record = {}; - const eapi = (window as unknown as { electronAPI: Record }).electronAPI; - eapi.storeGet = jest.fn(async (k: string) => (k in mem ? mem[k] : null)); - eapi.storeSet = jest.fn(async (k: string, v: unknown) => { mem[k] = v; return true; }); + it('ignores options an older build persisted for a NON-current file', async () => { jest.spyOn(imageService, 'getCurrentImage').mockReturnValue(null); - jest.spyOn(editPersistenceService, 'getSavedRawDecodeOptions').mockResolvedValue(null); + const getSaved = jest.spyOn(editPersistenceService, 'getSavedRawDecodeOptions').mockResolvedValue(AHD_RECON); await imageService.decodeForExport('/other.orf'); - expect(api().decodeRawFile).toHaveBeenCalledWith('/other.orf', DEFAULT_RAW_DECODE_OPTIONS); - saveRawDecodeDefaults(AHD_RECON); - await Promise.resolve(); // fire-and-forget storeSet lands - await imageService.decodeForExport('/other.orf'); - expect(api().decodeRawFile).toHaveBeenLastCalledWith( - '/other.orf', - { ...AHD_RECON, cameraMatch: !!AHD_RECON.cameraMatch }, - ); + expect(api().decodeRawFile).toHaveBeenCalledWith('/other.orf', DEFAULT_RAW_DECODE_OPTIONS); + expect(getSaved).not.toHaveBeenCalled(); }); }); diff --git a/src/test/rawDecodePanel.test.tsx b/src/test/rawDecodePanel.test.tsx deleted file mode 100644 index e6932f7..0000000 --- a/src/test/rawDecodePanel.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -// src/test/rawDecodePanel.test.tsx -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { RawDecodePanel } from '../components/Panels/RawDecodePanel'; -import { useAppStore } from '../stores/appStore'; -import { rawImageService } from '../services/RawImageService'; -import { notificationService } from '../services/NotificationService'; -import { DEFAULT_RAW_DECODE_OPTIONS } from '../types/electron'; -import { imageProcessingPipeline } from '../services/ImageProcessingPipeline'; -import type { ImageFileInfo } from '../services/FileSystemService'; - -const hrModule = () => - imageProcessingPipeline.getModule( - 'highlightrecovery', - ) as unknown as { getParams: () => { strength: number }; setParams: (p: { strength: number }) => void }; - -jest.mock('../services/RawImageService', () => ({ - rawImageService: { - isRawFile: jest.fn((path: string) => /\.(orf|cr2|cr3|nef|arw|dng|raf|rw2)$/i.test(path)), - reDecode: jest.fn(async () => {}), - }, -})); - -jest.mock('../services/NotificationService', () => ({ - notificationService: { error: jest.fn() }, -})); - -// The current image is passed as a PROP (App's live selection). The Zustand -// store's `currentImage` is never populated in this app, so gating on it made -// the panel permanently invisible (v1.13.0 bug) — these tests drive the prop. -const RAW_IMAGE: ImageFileInfo = { - id: '1', name: 'photo.orf', path: '/photo.orf', size: 100, - format: 'orf', type: 'image', lastModified: 0, dateModified: new Date(), -}; - -const JPEG_IMAGE: ImageFileInfo = { - id: '2', name: 'photo.jpg', path: '/photo.jpg', size: 100, - format: 'jpg', type: 'image', lastModified: 0, dateModified: new Date(), -}; - -describe('RawDecodePanel', () => { - beforeEach(() => { - useAppStore.setState({ rawDecodeOptions: DEFAULT_RAW_DECODE_OPTIONS, reDecoding: false }); - (rawImageService.reDecode as jest.Mock).mockClear(); - (rawImageService.reDecode as jest.Mock).mockResolvedValue(undefined); - (notificationService.error as jest.Mock).mockClear(); - hrModule().setParams({ strength: 0 }); // reset the shared pipeline module between tests - }); - - it('renders nothing when there is no current image', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing for a non-RAW image', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('is collapsed by default for a RAW image (selects not yet in the DOM)', () => { - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend' } }); - render(); - expect(screen.getByText('RAW Decode')).toBeInTheDocument(); - expect(screen.queryByLabelText('Demosaic')).toBeNull(); - }); - - it('renders the two selects with current values for a RAW image once expanded', () => { - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend' } }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - expect(screen.getByLabelText('Demosaic')).toHaveValue('dcb'); - expect(screen.getByLabelText('Highlights')).toHaveValue('blend'); - }); - - it('selecting a demosaic option invokes reDecode with the expected RawDecodeOptions', () => { - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend' } }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - fireEvent.change(screen.getByLabelText('Demosaic'), { target: { value: 'ahd' } }); - expect(rawImageService.reDecode).toHaveBeenCalledWith({ demosaic: 'ahd', highlightMode: 'blend' }, RAW_IMAGE.id); - }); - - it('selecting a highlight option invokes reDecode with the expected RawDecodeOptions', () => { - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend' } }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - fireEvent.change(screen.getByLabelText('Highlights'), { target: { value: 'reconstruct' } }); - expect(rawImageService.reDecode).toHaveBeenCalledWith({ demosaic: 'dcb', highlightMode: 'reconstruct' }, RAW_IMAGE.id); - }); - - it('surfaces a notification when reDecode rejects (no unhandled rejection)', async () => { - (rawImageService.reDecode as jest.Mock).mockRejectedValueOnce(new Error('decode boom')); - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend' } }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - fireEvent.change(screen.getByLabelText('Highlights'), { target: { value: 'reconstruct' } }); - await waitFor(() => - expect(notificationService.error).toHaveBeenCalledWith( - 'RAW re-decode failed', - expect.stringMatching(/decode boom/), - ), - ); - }); - - it('disables both controls and shows progress while reDecoding', () => { - useAppStore.setState({ rawDecodeOptions: DEFAULT_RAW_DECODE_OPTIONS, reDecoding: true }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - expect(screen.getByLabelText('Demosaic')).toBeDisabled(); - expect(screen.getByLabelText('Highlights')).toBeDisabled(); - expect(screen.getByText(/re-decoding/i)).toBeInTheDocument(); - }); - - it('shows the highlight recovery slider (default 0) once expanded', () => { - render(); - fireEvent.click(screen.getByText('RAW Decode')); - const slider = screen.getByLabelText('Highlight recovery') as HTMLInputElement; - expect(slider).toHaveValue('0'); - }); - - it('moving the highlight recovery slider updates the module strength WITHOUT a re-decode', () => { - render(); - fireEvent.click(screen.getByText('RAW Decode')); - fireEvent.change(screen.getByLabelText('Highlight recovery'), { target: { value: '65' } }); - expect(hrModule().getParams().strength).toBe(65); - // It is a POST-decode module param, so it must NOT trigger a RAW re-decode. - expect(rawImageService.reDecode).not.toHaveBeenCalled(); - }); - - it('the highlight recovery control makes clear it does NOT re-decode', () => { - render(); - fireEvent.click(screen.getByText('RAW Decode')); - expect(screen.getByLabelText('Highlight recovery')).toHaveAttribute( - 'title', - expect.stringMatching(/does NOT re-decode/i), - ); - }); - - it('exposes a tooltip noting that changing options re-decodes the file', () => { - useAppStore.setState({ rawDecodeOptions: DEFAULT_RAW_DECODE_OPTIONS }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - expect(screen.getByLabelText('Demosaic')).toHaveAttribute('title', expect.stringMatching(/re-decode/i)); - expect(screen.getByLabelText('Highlights')).toHaveAttribute('title', expect.stringMatching(/re-decode/i)); - }); - - describe('Camera match checkbox', () => { - it('reflects the store option and triggers reDecode with the toggled value', () => { - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: true } }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - const cb = screen.getByLabelText('Camera match') as HTMLInputElement; - expect(cb.checked).toBe(true); - fireEvent.click(cb); - expect(rawImageService.reDecode).toHaveBeenCalledWith( - { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: false }, - RAW_IMAGE.id, - ); - }); - - it('flips OPTIMISTICALLY on click — before the store catches up', () => { - // The store option only updates after the multi-second re-decode resolves; - // the box must show the target state immediately or it reads as dead. - useAppStore.setState({ rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: true } }); - let resolveDecode: () => void = () => {}; - (rawImageService.reDecode as jest.Mock).mockImplementation( - () => new Promise((r) => { resolveDecode = r; }), - ); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - const cb = screen.getByLabelText('Camera match') as HTMLInputElement; - fireEvent.click(cb); - // Store still says cameraMatch: true, but the box already shows false. - expect(useAppStore.getState().rawDecodeOptions.cameraMatch).toBe(true); - expect(cb.checked).toBe(false); - resolveDecode(); - }); - - it('settles on the store truth once the re-decode lands', async () => { - useAppStore.setState({ - rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: true }, - reDecoding: false, - }); - render(); - fireEvent.click(screen.getByText('RAW Decode')); - const cb = screen.getByLabelText('Camera match') as HTMLInputElement; - fireEvent.click(cb); - // Simulate the re-decode completing: store options + reDecoding settle. - useAppStore.setState({ - rawDecodeOptions: { demosaic: 'dcb', highlightMode: 'blend', cameraMatch: false }, - reDecoding: false, - }); - await waitFor(() => expect(cb.checked).toBe(false)); - }); - }); -}); diff --git a/src/types/electron.ts b/src/types/electron.ts index 71230ff..2dce115 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -125,7 +125,9 @@ export interface ElectronAPI { // File system readFile: (filePath: string) => Promise; - decodeRawFile: (filePath: string, options?: RawDecodeOptions) => Promise<{ data: ArrayBuffer; width: number; height: number; channels: number; bitDepth?: number }>; + /** `cameraMatched` is false when camera match was requested but could not be applied (the + * base is then the plain auto-brightened decode — never cached under the matched key). */ + decodeRawFile: (filePath: string, options?: RawDecodeOptions) => Promise<{ data: ArrayBuffer; width: number; height: number; channels: number; bitDepth?: number; cameraMatched?: boolean }>; /** Fast progressive-open preview: the embedded JPEG, oriented + downscaled to fit maxDim * (8-bit RGB). Rejects when no embedded preview exists. See ImageService progressive open. */ decodeRawPreview: (filePath: string, maxDim?: number) => Promise<{ data: ArrayBuffer; width: number; height: number; channels: number; bitDepth?: number }>; diff --git a/src/utils/rawDecodeDefaultsStorage.ts b/src/utils/rawDecodeDefaultsStorage.ts deleted file mode 100644 index df5765e..0000000 --- a/src/utils/rawDecodeDefaultsStorage.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * RAW Decode defaults memory (v1.31.0, user request): the last decode options - * the user actively chose (committed by a successful re-decode in the RAW - * Decode panel) become the DEFAULT for RAW files that have no per-image saved - * options — across pictures AND across sessions. Per-image persistence is - * untouched: a photo you already opened keeps ITS OWN saved options; this only - * replaces the factory fallback (DCB / blend / camera match) for never-opened - * files. - * - * Persisted through the MAIN-PROCESS durable store (storeGet/storeSet — the - * same %APPDATA% JSON store the per-image edits live in), NOT localStorage: - * live-diagnosed that a renderer localStorage write can be LOST when the app - * quits shortly after (S1 wrote the key, S2 launch read null). The main-process - * write survives any renderer teardown. - */ -import { DEFAULT_RAW_DECODE_OPTIONS, type RawDecodeOptions } from '../types/electron'; -import { isValidRawDecodeOptions } from '../services/EditPersistenceService'; -import { logger } from './Logger'; - -export const RAW_DECODE_DEFAULTS_KEY = 'rawDecodeDefaults'; - -export function saveRawDecodeDefaults(options: RawDecodeOptions): void { - try { - if (typeof window === 'undefined' || !window.electronAPI?.storeSet) return; - const payload: RawDecodeOptions = { - demosaic: options.demosaic, - highlightMode: options.highlightMode, - // Persist an EXPLICIT boolean: absent means "pre-feature look" in the - // per-image store, but as a user default the choice is always concrete. - cameraMatch: !!options.cameraMatch, - }; - void window.electronAPI.storeSet(RAW_DECODE_DEFAULTS_KEY, payload) - .catch((e) => logger.warn('Failed to save RAW decode defaults:', e)); - } catch (e) { - logger.warn('Failed to save RAW decode defaults:', e); - } -} - -/** The user's saved defaults, or the factory defaults when unset/corrupt. */ -export async function loadRawDecodeDefaults(): Promise { - try { - if (typeof window === 'undefined' || !window.electronAPI?.storeGet) { - return { ...DEFAULT_RAW_DECODE_OPTIONS }; - } - const saved = await window.electronAPI.storeGet(RAW_DECODE_DEFAULTS_KEY); - // Same validator the per-image persistence uses — one source of truth for - // what a legal decode-options shape is. - if (isValidRawDecodeOptions(saved)) return { ...saved }; - } catch (e) { - logger.warn('Failed to load RAW decode defaults:', e); - } - return { ...DEFAULT_RAW_DECODE_OPTIONS }; -} diff --git a/src/utils/rawExtensions.ts b/src/utils/rawExtensions.ts index 110a8c9..6713f92 100644 --- a/src/utils/rawExtensions.ts +++ b/src/utils/rawExtensions.ts @@ -2,7 +2,7 @@ * Canonical RAW file-extension lists — single source of truth for both RAW * *detection* (does this file look like a RAW photo, for UI purposes: the * gallery/filmstrip RAW badge, the RAW count in the gallery footer, the - * RawDecodePanel visibility gate) and RAW *decode routing* (should this file + * Highlight recovery control gate) and RAW *decode routing* (should this file * be sent through `RawImageService`'s LibRaw decode pipeline in * `ImageService.loadImage`, vs. treated as a regular sharp/browser-decodable * image).