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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Set up pnpm
uses: pnpm/action-setup@v5
with:
version: 11.2.2
version: 12.10.1

- name: Set up Node
uses: actions/setup-node@v5
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to **Vitrine** (formerly Photo Editor Pro) are documented in
this file. The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [1.39.0] - 2026-10-13

### Changed
- **A RAW keeps its shape when full quality replaces the preview.** Why: the camera corrects lens distortion in its embedded JPEG and the RAW decode did not, so on a lens with visible barrel distortion the picture widened and bowed about 10 seconds after opening. Camera match already measured that distortion (1.38.1) to pair its samples; it now applies the same radial model to the pixels, so the full-quality image has the camera's framing as well as its colour. The resample is Catmull-Rom on the 16-bit data, split across worker threads over shared memory (about 1 s for 20 MP). Photos whose preview already lines up are untouched. Note: on affected photos the frame is now cropped the way the camera crops it, so a crop or mask saved before this release sits a few percent off towards the edges. The cache key was bumped so every RAW decodes once more. Affects: `electron/cameraMatchGeometry.cjs` (new), `electron/cameraMatch.cjs`, `electron/baseCache.cjs`, `package.json` (`asarUnpack`).
- **Camera match holds contrast better in fine detail.** Why: the colour fit ran on a 200x150 grid of cell averages, and a tone curve fitted on averages reads too little contrast wherever the detail is finer than a cell. On a portrait of foliage against a bright sky the highlights came out about 20 8-bit levels darker than the camera JPEG; on a 400x300 grid that is about 12, with the four other test photos unchanged or slightly better, for roughly 0.3 s more per decode. It is still not an exact match on that kind of photo. Affects: `electron/cameraMatch.cjs`.
- Updated `exifreader` to 4.50.0 and `sharp` to 0.35.5. Why: `pnpm audit --prod` reported high-severity advisories in both (and in `@xmldom/xmldom` through exifreader); they parse the photos you open. The production audit went from 26 advisories to 8, all in `adm-zip`, which only runs in the install script of `onnxruntime-node`.
- CI installs with pnpm 12.10.1, the version the releases are built with.

### Fixed
- **A camera-match worker that died silently left the photo stuck on "Developing full quality".** Cause: only the worker's `message` and `error` events were handled, so an exit without either never settled the promise. Fix: an `exit` handler rejects it and the open falls back to the unmatched decode. Affects: `electron/cameraMatch.cjs`.
- **A malformed log entry from the window could drop a whole batch of log lines.** Cause: the log writer called `level.toUpperCase()` on whatever it was sent, inside the batch flush. Fix: entries are validated, truncated and stripped of newlines before they are queued. Affects: `electron/main.cjs`.
- Writing metadata into a file now requires an image extension, like the other write paths. Affects: `electron/main.cjs`.

## [1.38.1] - 2026-10-12

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion electron/baseCache.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ function optionsHash(options) {
// under the matched key — the bump makes them miss so the RAW decodes fresh.
// cm3: the fit now registers the preview to the decode (lens-distortion-corrected JPEGs);
// bases fitted without it have flattened highlights.
return crypto.createHash('sha1').update(`${demosaic}|${highlightMode}|cm3:${cameraMatch ? 1 : 0}`).digest('hex').slice(0, 8);
// cm5: matched bases are also resampled to the camera JPEG's geometry (cameraMatchGeometry.cjs)
// and fitted on the 400x300 grid (cm4 was a pre-release key for the geometry change alone).
return crypto.createHash('sha1').update(`${demosaic}|${highlightMode}|cm5:${cameraMatch ? 1 : 0}`).digest('hex').slice(0, 8);
}

/**
Expand Down
37 changes: 29 additions & 8 deletions electron/cameraMatch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@
const { Worker } = require('worker_threads');
const path = require('path');
const { findEmbeddedJpegs, readOrientation } = require('./embeddedPreview.cjs');

// Analysis grid. 200x150 ≈ 30k cells → ~20-28k usable fit samples on a typical
// photo; each cell averages ~500 source pixels, so per-pixel detail differences
// (sharpening halos, noise reduction) wash out of the fit target.
const GW = 200;
const GH = 150;
const { undistortParallel } = require('./cameraMatchGeometry.cjs');

// Analysis grid. 400x300 = 120k cells; each cell averages ~170 source pixels of a
// 20MP decode, so per-pixel detail differences (sharpening halos, noise
// reduction) still wash out of the fit target. It was 200x150 until the preview
// was registered to the decode: a tone curve fitted on coarser averages reads
// too little contrast wherever the detail is finer than a cell (foliage against
// sky measured 20 8-bit levels dark in the highlights at 200x150, 12 at 400x300),
// and finer cells only pair correctly once the two images line up.
const GW = 400;
const GH = 300;

// Fit hyper-parameters (validated on a 56-image corpus + the 4-photo comparison
// set; N13/λ0.5 with perceptual weighting matched or improved every case over
Expand Down Expand Up @@ -460,8 +465,22 @@ async function applyCameraMatch(decoded, orfBuf, log = console) {
return decoded;
}
const t0 = Date.now();
const data = await runApplyWorker(decoded.data, model);
log.log(`camera-match: fitted and applied in ${Date.now() - t0}ms (${decoded.width}x${decoded.height}, preview distortion k=${k.toFixed(4)})`);
let data = await runApplyWorker(decoded.data, model);
const tColour = Date.now() - t0;
// Give the pixels the camera's geometry too, so the picture does not change
// shape when full quality replaces the preview. Only k > 0 (barrel corrected
// by the camera): the corrected frame is then a crop of the decode. On
// failure keep the colour-matched, uncorrected decode.
let geometry = 'unchanged';
if (k > 0) {
try {
data = await undistortParallel(data, decoded.width, decoded.height, k);
geometry = `corrected in ${Date.now() - t0 - tColour}ms`;
} catch (err) {
log.warn(`camera-match: geometry correction failed (${err.message}) — keeping the decode's geometry`);
}
}
log.log(`camera-match: fitted and applied in ${tColour}ms (${decoded.width}x${decoded.height}, preview distortion k=${k.toFixed(4)}, geometry ${geometry})`);
return { ...decoded, data };
} catch (err) {
log.warn(`camera-match failed (${err.message}) — using unmatched decode`);
Expand Down Expand Up @@ -495,6 +514,8 @@ function runApplyWorker(dataArrayBuffer, model) {
worker.terminate();
reject(err);
});
// A worker that dies without posting or erroring would otherwise leave the open hanging.
worker.once('exit', (code) => reject(new Error(`camera-match worker exited with code ${code}`)));
worker.postMessage({ data: dataArrayBuffer }, [dataArrayBuffer]);
});
}
Expand Down
148 changes: 148 additions & 0 deletions electron/cameraMatchGeometry.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Geometry half of camera match: resample the decode into the camera JPEG's frame.
//
// cameraMatch.cjs measures the radial coefficient k that lines the embedded
// preview up with the decode (the camera bakes lens-distortion correction into
// its JPEG; the decode is the uncorrected sensor image). The colour fit only
// needs that to pair cells correctly, but leaving the decode uncorrected means
// the picture visibly changes shape when full quality replaces the preview. This
// applies the same model to the pixels, so the full-quality image has the
// camera's geometry as well as its colour.
//
// The ~20MP Catmull-Rom resample takes about 4 s on one thread, so it is split
// into row strips across worker threads over shared memory. This file is its own
// worker script, and runs from app.asar.unpacked in a packaged build (see
// package.json asarUnpack) because worker_threads cannot load from the asar.

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const os = require('os');
const path = require('path');

const LUT_SIZE = 2048;
const MAX_THREADS = 8;

/**
* cameraMatch.warpPoint maps a decode point p to the preview point q = p·(1 + k·ρp²),
* with ρ the radius normalised so the frame corner is 1. Resampling needs the
* inverse: for an output (preview-frame) radius ρq, the decode radius ρp. Returns
* ρp/ρq tabulated over ρq² in [0,1] (LUT_SIZE + 2 entries, so i+1 is always valid).
*/
function buildInverseRadialLut(k) {
const lut = new Float64Array(LUT_SIZE + 2);
for (let i = 0; i < lut.length; i++) {
const t = Math.sqrt(Math.min(1, i / LUT_SIZE));
if (t === 0) { lut[i] = 1; continue; }
let r = t;
for (let it = 0; it < 12; it++) r -= (r + k * r * r * r - t) / (1 + 3 * k * r * r);
lut[i] = r / t;
}
return lut;
}

/**
* Resample packed 16-bit RGB through the inverse of distortion k (Catmull-Rom).
* Only k > 0 is supported — the corrected frame is then a crop of the decode, so
* every output pixel has a source. Fills rows [yStart, yEnd) of dst (default: a
* new buffer, all rows) and returns it.
*/
function undistortRGB16(src, width, height, k, dst = new Uint16Array(src.length), yStart = 0, yEnd = height) {
const lut = buildInverseRadialLut(k);
const aspect = width / height;
const norm = (aspect * aspect + 1) / 4;
const maxX = width - 1, maxY = height - 1;
const stride = width * 3;
for (let y = yStart; y < yEnd; y++) {
const qy = (y + 0.5) / height - 0.5;
const qy2 = qy * qy;
let o = y * stride;
for (let x = 0; x < width; x++, o += 3) {
const qx = (x + 0.5) / width - 0.5;
const xa = qx * aspect;
const lt = ((xa * xa + qy2) / norm) * LUT_SIZE;
const li = lt | 0;
const s = lut[li] + (lut[li + 1] - lut[li]) * (lt - li);
const sx = (qx * s + 0.5) * width - 0.5;
const sy = (qy * s + 0.5) * height - 0.5;
const ix = Math.floor(sx), iy = Math.floor(sy);
const tx = sx - ix, ty = sy - iy;

const wx0 = ((-tx + 2) * tx - 1) * tx * 0.5;
const wx1 = ((3 * tx - 5) * tx * tx + 2) * 0.5;
const wx2 = ((-3 * tx + 4) * tx + 1) * tx * 0.5;
const wx3 = (tx - 1) * tx * tx * 0.5;
const wy0 = ((-ty + 2) * ty - 1) * ty * 0.5;
const wy1 = ((3 * ty - 5) * ty * ty + 2) * 0.5;
const wy2 = ((-3 * ty + 4) * ty + 1) * ty * 0.5;
const wy3 = (ty - 1) * ty * ty * 0.5;

const x0 = (ix - 1 < 0 ? 0 : ix - 1 > maxX ? maxX : ix - 1) * 3;
const x1 = (ix < 0 ? 0 : ix > maxX ? maxX : ix) * 3;
const x2 = (ix + 1 < 0 ? 0 : ix + 1 > maxX ? maxX : ix + 1) * 3;
const x3 = (ix + 2 < 0 ? 0 : ix + 2 > maxX ? maxX : ix + 2) * 3;
const r0 = (iy - 1 < 0 ? 0 : iy - 1 > maxY ? maxY : iy - 1) * stride;
const r1 = (iy < 0 ? 0 : iy > maxY ? maxY : iy) * stride;
const r2 = (iy + 1 < 0 ? 0 : iy + 1 > maxY ? maxY : iy + 1) * stride;
const r3 = (iy + 2 < 0 ? 0 : iy + 2 > maxY ? maxY : iy + 2) * stride;

for (let c = 0; c < 3; c++) {
const v =
wy0 * (wx0 * src[r0 + x0 + c] + wx1 * src[r0 + x1 + c] + wx2 * src[r0 + x2 + c] + wx3 * src[r0 + x3 + c]) +
wy1 * (wx0 * src[r1 + x0 + c] + wx1 * src[r1 + x1 + c] + wx2 * src[r1 + x2 + c] + wx3 * src[r1 + x3 + c]) +
wy2 * (wx0 * src[r2 + x0 + c] + wx1 * src[r2 + x1 + c] + wx2 * src[r2 + x2 + c] + wx3 * src[r2 + x3 + c]) +
wy3 * (wx0 * src[r3 + x0 + c] + wx1 * src[r3 + x1 + c] + wx2 * src[r3 + x2 + c] + wx3 * src[r3 + x3 + c]);
dst[o + c] = v < 0 ? 0 : v > 65535 ? 65535 : v + 0.5 | 0;
}
}
}
return dst;
}

/** This script on the real filesystem (same asar rule as cameraMatch.workerScriptPath). */
function scriptPath() {
const asar = `${path.sep}app.asar${path.sep}`;
return __filename.includes(asar) ? __filename.replace(asar, `${path.sep}app.asar.unpacked${path.sep}`) : __filename;
}

/**
* undistortRGB16 across worker threads. Takes the decode's ArrayBuffer (packed
* 16-bit RGB) and resolves with a new ArrayBuffer of the same size.
*/
function undistortParallel(dataArrayBuffer, width, height, k) {
const bytes = dataArrayBuffer.byteLength;
const srcSab = new SharedArrayBuffer(bytes);
const dstSab = new SharedArrayBuffer(bytes);
new Uint16Array(srcSab).set(new Uint16Array(dataArrayBuffer));
const threads = Math.max(1, Math.min(MAX_THREADS, os.availableParallelism() - 1));
const rows = Math.ceil(height / threads);
const workers = [];
const strips = [];
for (let y = 0; y < height; y += rows) {
strips.push(new Promise((resolve, reject) => {
const worker = new Worker(scriptPath(), {
workerData: { undistort: { srcSab, dstSab, width, height, k, yStart: y, yEnd: Math.min(height, y + rows) } },
});
workers.push(worker);
worker.once('message', (msg) => (msg && msg.ok ? resolve() : reject(new Error((msg && msg.error) || 'undistort worker failed'))));
worker.once('error', reject);
worker.once('exit', (code) => { if (code !== 0) reject(new Error(`undistort worker exited with code ${code}`)); });
}));
}
return Promise.all(strips)
.then(() => {
const out = new Uint16Array(bytes / 2);
out.set(new Uint16Array(dstSab));
return out.buffer;
})
.finally(() => { for (const w of workers) w.terminate(); });
}

if (!isMainThread && workerData && workerData.undistort) {
try {
const { srcSab, dstSab, width, height, k, yStart, yEnd } = workerData.undistort;
undistortRGB16(new Uint16Array(srcSab), width, height, k, new Uint16Array(dstSab), yStart, yEnd);
parentPort.postMessage({ ok: true });
} catch (err) {
parentPort.postMessage({ ok: false, error: err.message });
}
}

module.exports = { buildInverseRadialLut, undistortRGB16, undistortParallel, LUT_SIZE };
11 changes: 9 additions & 2 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ ipcMain.handle('read-raw-metadata', async (event, filePath) => {
// failure so the renderer promise rejects (no silent success).
ipcMain.handle('write-image-metadata', async (event, filePath, metadata) => {
try {
const safeFilePath = validateWritePath(filePath);
const safeFilePath = validateWritePath(filePath, { requireAllowedExtension: true });
markSelfWrite(safeFilePath); // in-place metadata write must not retrigger the watcher
return await writeImageMetadata(safeFilePath, metadata);
} catch (error) {
Expand Down Expand Up @@ -1141,7 +1141,14 @@ const scheduleFlush = () => {

ipcMain.handle('write-log', async (event, logEntry) => {
try {
logQueue.push(logEntry);
// The entry comes from the renderer: a missing level would throw inside the batch
// flush and drop the whole batch, and a newline in the message would forge a log line.
if (!logEntry || typeof logEntry.level !== 'string' || typeof logEntry.message !== 'string') return false;
logQueue.push({
timestamp: String(logEntry.timestamp),
level: logEntry.level.slice(0, 16),
message: logEntry.message.slice(0, 8192).replace(/[\r\n]+/g, ' | '),
});
scheduleFlush();

// For critical errors, flush immediately
Expand Down
4 changes: 4 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ module.exports = {
}]
},
moduleNameMapper: {
// exifreader is only loaded by the main process (Node), which resolves its CommonJS
// dist build. Under jsdom Jest picks the package's "browser" export (ESM source)
// and cannot parse it, so pin the build the app really runs.
'^exifreader$': '<rootDir>/node_modules/exifreader/dist/exif-reader.js',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/src/__mocks__/fileMock.js',
// The worker factory uses a Vite `?worker&url` import, which ts-jest cannot
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "photo_app",
"version": "1.38.1",
"version": "1.39.0",
"description": "Vitrine — a free, non-destructive RAW photo editor for Windows",
"main": "electron/main.cjs",
"scripts": {
Expand Down Expand Up @@ -41,14 +41,14 @@
},
"type": "module",
"dependencies": {
"@img/sharp-win32-x64": "0.34.5",
"exifreader": "^4.23.5",
"@img/sharp-win32-x64": "0.35.5",
"exifreader": "^4.50.0",
"libraw-wasm": "^1.1.2",
"lucide-react": "^0.562.0",
"onnxruntime-node": "1.27.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sharp": "0.34.5",
"sharp": "0.35.5",
"zustand": "^5.0.8"
},
"devDependencies": {
Expand Down Expand Up @@ -165,6 +165,7 @@
"electron/librawWasmWorker.cjs",
"electron/librawWasmNode.cjs",
"electron/cameraMatchWorker.cjs",
"electron/cameraMatchGeometry.cjs",
"**/node_modules/sharp/**",
"**/node_modules/@img/**",
"**/node_modules/onnxruntime-node/**"
Expand Down
Loading