From a1b653efb75acb7a69fa935ad97d278baf9217c9 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 14 May 2026 07:09:51 -0400 Subject: [PATCH] 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 --- src/lib/processing/photonWorker.ts | 200 ++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 58 deletions(-) diff --git a/src/lib/processing/photonWorker.ts b/src/lib/processing/photonWorker.ts index 6f1470d..a548a38 100644 --- a/src/lib/processing/photonWorker.ts +++ b/src/lib/processing/photonWorker.ts @@ -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 = 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) {