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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user