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,77 +226,121 @@ async function processImage(
await initPhoton();
const p = getPhoton();
let img;
try {
const { pixels, width, height } = await decodeImage(imageData);
img = new p.PhotonImage(pixels, width, height);
} catch (e) {
throw new Error(`Failed to load image: ${e}`);
}
// Check geometry cache first
const cacheKey = buildGeometryCacheKey(imageData, device, config);
const cached = geometryCache.get(cacheKey);
try {
// Crop
if (config.crop.enabled) {
const width = img.get_width();
const height = img.get_height();
const targetAspect = device.width / device.height;
let geometryPixels: Uint8ClampedArray;
let geometryWidth: number;
let geometryHeight: number;
let cropX1: number, cropY1: number, cropX2: number, cropY2: 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);
img = new p.PhotonImage(pixels, width, height);
} catch (e) {
throw new Error(`Failed to load image: ${e}`);
}
if (config.crop.mode === 'manual' && config.crop.region) {
// Manual crop: use the provided region
const region = config.crop.region;
cropX1 = Math.max(0, Math.round(region.x));
cropY1 = Math.max(0, Math.round(region.y));
cropX2 = Math.min(width, Math.round(region.x + region.width));
cropY2 = Math.min(height, Math.round(region.y + region.height));
} else {
// Auto crop modes: center, top, bottom
const currentAspect = width / height;
try {
// Crop
if (config.crop.enabled) {
const width = img.get_width();
const height = img.get_height();
const targetAspect = device.width / device.height;
if (currentAspect > targetAspect) {
const newWidth = Math.max(1, Math.floor(height * targetAspect));
let xOffset: number;
if (config.crop.mode === 'top' || config.crop.mode === 'bottom') {
xOffset = Math.floor((width - newWidth) / 2);
} else {
xOffset = Math.floor((width - newWidth) / 2);
}
cropX1 = Math.max(0, xOffset);
cropY1 = 0;
cropX2 = Math.min(width, cropX1 + newWidth);
cropY2 = height;
let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
if (config.crop.mode === 'manual' && config.crop.region) {
// Manual crop: use the provided region
const region = config.crop.region;
cropX1 = Math.max(0, Math.round(region.x));
cropY1 = Math.max(0, Math.round(region.y));
cropX2 = Math.min(width, Math.round(region.x + region.width));
cropY2 = Math.min(height, Math.round(region.y + region.height));
} else {
const newHeight = Math.max(1, Math.floor(width / targetAspect));
let yOffset: number;
if (config.crop.mode === 'top') {
yOffset = 0;
} else if (config.crop.mode === 'bottom') {
yOffset = height - newHeight;
// Auto crop modes: center, top, bottom
const currentAspect = width / height;
if (currentAspect > targetAspect) {
const newWidth = Math.max(1, Math.floor(height * targetAspect));
let xOffset: number;
if (config.crop.mode === 'top' || config.crop.mode === 'bottom') {
xOffset = Math.floor((width - newWidth) / 2);
} else {
xOffset = Math.floor((width - newWidth) / 2);
}
cropX1 = Math.max(0, xOffset);
cropY1 = 0;
cropX2 = Math.min(width, cropX1 + newWidth);
cropY2 = height;
} else {
yOffset = Math.floor((height - newHeight) / 2);
const newHeight = Math.max(1, Math.floor(width / targetAspect));
let yOffset: number;
if (config.crop.mode === 'top') {
yOffset = 0;
} else if (config.crop.mode === 'bottom') {
yOffset = height - newHeight;
} else {
yOffset = Math.floor((height - newHeight) / 2);
}
cropX1 = 0;
cropY1 = Math.max(0, yOffset);
cropX2 = width;
cropY2 = Math.min(height, cropY1 + newHeight);
}
cropX1 = 0;
cropY1 = Math.max(0, yOffset);
cropX2 = width;
cropY2 = Math.min(height, cropY1 + newHeight);
}
if (cropX2 > cropX1 && cropY2 > cropY1) {
const cropped = p.crop(img, cropX1, cropY1, cropX2, cropY2);
img.free();
img = cropped;
}
}
if (cropX2 > cropX1 && cropY2 > cropY1) {
const cropped = p.crop(img, cropX1, cropY1, cropX2, cropY2);
// Resize
if (config.resize.enabled) {
const resized = p.resize(img, device.width, device.height, p.SamplingFilter.Lanczos3);
img.free();
img = cropped;
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 */
}
}
}
// Resize
if (config.resize.enabled) {
const resized = p.resize(img, device.width, device.height, p.SamplingFilter.Lanczos3);
img.free();
img = resized;
}
// 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) {