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; 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() { async function initPhoton() {
if (photon) return photon; if (photon) return photon;
const module = await import('@silvia-odwyer/photon'); const module = await import('@silvia-odwyer/photon');
@@ -12,6 +21,38 @@ async function initPhoton() {
return photon; 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() { function getPhoton() {
if (!photon) throw new Error('Photon not initialized'); if (!photon) throw new Error('Photon not initialized');
return photon; return photon;
@@ -185,77 +226,121 @@ async function processImage(
await initPhoton(); await initPhoton();
const p = getPhoton(); const p = getPhoton();
let img; // Check geometry cache first
try { const cacheKey = buildGeometryCacheKey(imageData, device, config);
const { pixels, width, height } = await decodeImage(imageData); const cached = geometryCache.get(cacheKey);
img = new p.PhotonImage(pixels, width, height);
} catch (e) {
throw new Error(`Failed to load image: ${e}`);
}
try { let geometryPixels: Uint8ClampedArray;
// Crop let geometryWidth: number;
if (config.crop.enabled) { let geometryHeight: number;
const width = img.get_width();
const height = img.get_height();
const targetAspect = device.width / device.height;
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) { try {
// Manual crop: use the provided region // Crop
const region = config.crop.region; if (config.crop.enabled) {
cropX1 = Math.max(0, Math.round(region.x)); const width = img.get_width();
cropY1 = Math.max(0, Math.round(region.y)); const height = img.get_height();
cropX2 = Math.min(width, Math.round(region.x + region.width)); const targetAspect = device.width / device.height;
cropY2 = Math.min(height, Math.round(region.y + region.height));
} else {
// Auto crop modes: center, top, bottom
const currentAspect = width / height;
if (currentAspect > targetAspect) { let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
const newWidth = Math.max(1, Math.floor(height * targetAspect));
let xOffset: number; if (config.crop.mode === 'manual' && config.crop.region) {
if (config.crop.mode === 'top' || config.crop.mode === 'bottom') { // Manual crop: use the provided region
xOffset = Math.floor((width - newWidth) / 2); const region = config.crop.region;
} else { cropX1 = Math.max(0, Math.round(region.x));
xOffset = Math.floor((width - newWidth) / 2); cropY1 = Math.max(0, Math.round(region.y));
} cropX2 = Math.min(width, Math.round(region.x + region.width));
cropX1 = Math.max(0, xOffset); cropY2 = Math.min(height, Math.round(region.y + region.height));
cropY1 = 0;
cropX2 = Math.min(width, cropX1 + newWidth);
cropY2 = height;
} else { } else {
const newHeight = Math.max(1, Math.floor(width / targetAspect)); // Auto crop modes: center, top, bottom
let yOffset: number; const currentAspect = width / height;
if (config.crop.mode === 'top') {
yOffset = 0; if (currentAspect > targetAspect) {
} else if (config.crop.mode === 'bottom') { const newWidth = Math.max(1, Math.floor(height * targetAspect));
yOffset = height - newHeight; 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 { } 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; if (cropX2 > cropX1 && cropY2 > cropY1) {
cropY2 = Math.min(height, cropY1 + newHeight); const cropped = p.crop(img, cropX1, cropY1, cropX2, cropY2);
img.free();
img = cropped;
} }
} }
if (cropX2 > cropX1 && cropY2 > cropY1) { // Resize
const cropped = p.crop(img, cropX1, cropY1, cropX2, cropY2); if (config.resize.enabled) {
const resized = p.resize(img, device.width, device.height, p.SamplingFilter.Lanczos3);
img.free(); 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 // Adjustment phase: work on a copy of geometry pixels
if (config.resize.enabled) { let data = geometryPixels;
const resized = p.resize(img, device.width, device.height, p.SamplingFilter.Lanczos3); const width = geometryWidth;
img.free(); const height = geometryHeight;
img = resized; let img = fromImageData(data, width, height);
}
try {
// Adjustments // Adjustments
if (config.brightness.enabled && config.brightness.value !== 0) { if (config.brightness.enabled && config.brightness.value !== 0) {
p.adjust_brightness(img, Math.round(config.brightness.value * 0.5)); p.adjust_brightness(img, Math.round(config.brightness.value * 0.5));
@@ -275,8 +360,7 @@ async function processImage(
// Get pixel data for manual operations // Get pixel data for manual operations
const imgData = getImageData(img); const imgData = getImageData(img);
let { data } = imgData; data = imgData.data;
const { width, height } = imgData;
// Auto-levels // Auto-levels
if (config.autoLevels.enabled) { if (config.autoLevels.enabled) {