-
-
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(