perf: cache post-geometry pixels for faster adjustments

Split processing into geometry phase (crop/resize) and adjustment phase.
Cache the post-geometry pixels keyed by image fingerprint + crop/resize config.
When only adjustments change, skip expensive decode/crop/resize operations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 07:09:51 -04:00
parent 1518f34070
commit a1b653efb7

View File

@@ -4,6 +4,15 @@ import type { Device, PipelineConfig } from '../types';
let photon: typeof import('@silvia-odwyer/photon') | null = null;
// Cache for post-crop/resize data to speed up adjustment-only changes
type CachedGeometry = {
pixels: Uint8ClampedArray;
width: number;
height: number;
};
const geometryCache: Map<string, CachedGeometry> = new Map();
const MAX_CACHE_SIZE = 10; // Keep last 10 unique geometry states
async function initPhoton() {
if (photon) return photon;
const module = await import('@silvia-odwyer/photon');
@@ -12,6 +21,38 @@ async function initPhoton() {
return photon;
}
function buildGeometryCacheKey(
imageData: ArrayBuffer,
device: Device,
config: PipelineConfig
): string {
// Use image size + first 1000 bytes as fingerprint
const view = new Uint8Array(imageData);
const fingerprint = Array.from(view.slice(0, 1000)).join(',');
const imageKey = `${imageData.byteLength}:${fingerprint}`;
// Include geometry-affecting config
const cropKey = config.crop.enabled
? `crop:${config.crop.mode}:${JSON.stringify(config.crop.region ?? null)}`
: 'crop:off';
const resizeKey = config.resize.enabled ? `resize:${device.width}x${device.height}` : 'resize:off';
return `${imageKey}|${cropKey}|${resizeKey}`;
}
function pruneCache(): void {
if (geometryCache.size > MAX_CACHE_SIZE) {
// Remove oldest entries (first in map)
const keysToDelete = Array.from(geometryCache.keys()).slice(
0,
geometryCache.size - MAX_CACHE_SIZE
);
for (const key of keysToDelete) {
geometryCache.delete(key);
}
}
}
function getPhoton() {
if (!photon) throw new Error('Photon not initialized');
return photon;
@@ -185,6 +226,21 @@ async function processImage(
await initPhoton();
const p = getPhoton();
// Check geometry cache first
const cacheKey = buildGeometryCacheKey(imageData, device, config);
const cached = geometryCache.get(cacheKey);
let geometryPixels: Uint8ClampedArray;
let geometryWidth: number;
let geometryHeight: number;
if (cached) {
// Use cached post-geometry data - skip expensive crop/resize
geometryPixels = new Uint8ClampedArray(cached.pixels);
geometryWidth = cached.width;
geometryHeight = cached.height;
} else {
// Run geometry phase: decode → crop → resize
let img;
try {
const { pixels, width, height } = await decodeImage(imageData);
@@ -256,6 +312,35 @@ async function processImage(
img = resized;
}
// Extract pixel data after geometry phase
const imgData = getImageData(img);
geometryPixels = imgData.data;
geometryWidth = imgData.width;
geometryHeight = imgData.height;
// Cache this geometry result
geometryCache.set(cacheKey, {
pixels: new Uint8ClampedArray(geometryPixels),
width: geometryWidth,
height: geometryHeight
});
pruneCache();
} finally {
try {
img.free();
} catch {
/* ignore */
}
}
}
// Adjustment phase: work on a copy of geometry pixels
let data = geometryPixels;
const width = geometryWidth;
const height = geometryHeight;
let img = fromImageData(data, width, height);
try {
// Adjustments
if (config.brightness.enabled && config.brightness.value !== 0) {
p.adjust_brightness(img, Math.round(config.brightness.value * 0.5));
@@ -275,8 +360,7 @@ async function processImage(
// Get pixel data for manual operations
const imgData = getImageData(img);
let { data } = imgData;
const { width, height } = imgData;
data = imgData.data;
// Auto-levels
if (config.autoLevels.enabled) {