feat: add photon WASM utilities

- Image loading and canvas conversion
- Crop to aspect ratio
- Resize with Lanczos3 sampling
- Greyscale conversion
- Brightness, contrast, gamma adjustments
- Dithering algorithms (Floyd-Steinberg, ordered, Atkinson)
- Quantization and auto-levels

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:09:20 -04:00
parent d5ba4a4f3e
commit a4c3185a21

View File

@@ -0,0 +1,337 @@
// Photon WASM wrapper for image processing (main thread fallback)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PhotonModule = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PhotonImage = any;
let photon: PhotonModule | null = null;
export async function initPhoton(): Promise<PhotonModule> {
if (photon) return photon;
const module = await import('@silvia-odwyer/photon');
await module.default();
photon = module;
return photon;
}
function getPhoton(): PhotonModule {
if (!photon) throw new Error('Photon not initialized. Call initPhoton() first.');
return photon;
}
export async function loadImageFromFile(file: File | Blob): Promise<PhotonImage> {
const p = getPhoton();
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not get canvas context');
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return p.open_image(canvas, ctx);
}
export function cropToAspectRatio(img: PhotonImage, targetAspect: number): PhotonImage {
const p = getPhoton();
const width = img.get_width();
const height = img.get_height();
if (width <= 0 || height <= 0 || targetAspect <= 0) return img;
const currentAspect = width / height;
let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
if (currentAspect > targetAspect) {
const newWidth = Math.max(1, Math.floor(height * targetAspect));
const xOffset = Math.floor((width - newWidth) / 2);
cropX1 = Math.max(0, xOffset);
cropY1 = 0;
cropX2 = Math.min(width, cropX1 + newWidth);
cropY2 = height;
} else {
const newHeight = Math.max(1, Math.floor(width / targetAspect));
const yOffset = Math.floor((height - newHeight) / 2);
cropX1 = 0;
cropY1 = Math.max(0, yOffset);
cropX2 = width;
cropY2 = Math.min(height, cropY1 + newHeight);
}
if (cropX2 <= cropX1 || cropY2 <= cropY1) return img;
return p.crop(img, cropX1, cropY1, cropX2, cropY2);
}
export function resizeImage(
img: PhotonImage,
targetWidth: number,
targetHeight: number
): PhotonImage {
const p = getPhoton();
return p.resize(img, targetWidth, targetHeight, p.SamplingFilter.Lanczos3);
}
export function toGreyscale(img: PhotonImage): void {
const p = getPhoton();
p.grayscale(img);
}
export function imageToCanvas(img: PhotonImage): HTMLCanvasElement {
const p = getPhoton();
const width = img.get_width();
const height = img.get_height();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
p.putImageData(canvas, ctx, img);
return canvas;
}
export function quantizeCanvas(canvas: HTMLCanvasElement, levels: number): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const step = 255 / (levels - 1);
for (let i = 0; i < data.length; i += 4) {
const grey = data[i];
const quantized = Math.round(grey / step) * step;
data[i] = quantized;
data[i + 1] = quantized;
data[i + 2] = quantized;
}
ctx.putImageData(imageData, 0, 0);
}
export function quantizeToCanvas(img: PhotonImage, levels: number): HTMLCanvasElement {
const canvas = imageToCanvas(img);
quantizeCanvas(canvas, levels);
return canvas;
}
export function canvasToBlob(
canvas: HTMLCanvasElement,
format: 'png' | 'jpeg' = 'png',
quality = 0.92
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to create blob'));
},
format === 'jpeg' ? 'image/jpeg' : 'image/png',
quality
);
});
}
export function canvasToDataUrl(
canvas: HTMLCanvasElement,
format: 'png' | 'jpeg' = 'png',
quality = 0.92
): string {
return canvas.toDataURL(format === 'jpeg' ? 'image/jpeg' : 'image/png', quality);
}
export function adjustBrightness(img: PhotonImage, value: number): void {
const p = getPhoton();
p.adjust_brightness(img, Math.round(value));
}
export function adjustContrast(img: PhotonImage, value: number): void {
const p = getPhoton();
p.adjust_contrast(img, value);
}
export function applyGamma(img: PhotonImage, gamma: number): void {
const p = getPhoton();
p.gamma_correction(img, gamma);
}
export function sharpenImage(img: PhotonImage, amount: number): void {
const p = getPhoton();
if (amount > 0) p.sharpen(img);
}
export function autoLevels(canvas: HTMLCanvasElement, clipPercent: number = 0.5): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const histogram = new Array(256).fill(0);
for (let i = 0; i < data.length; i += 4) {
histogram[data[i]]++;
}
const totalPixels = canvas.width * canvas.height;
const clipPixels = Math.floor(totalPixels * (clipPercent / 100));
let minLevel = 0,
count = 0;
for (let i = 0; i < 256; i++) {
count += histogram[i];
if (count > clipPixels) {
minLevel = i;
break;
}
}
let maxLevel = 255;
count = 0;
for (let i = 255; i >= 0; i--) {
count += histogram[i];
if (count > clipPixels) {
maxLevel = i;
break;
}
}
if (maxLevel <= minLevel) maxLevel = minLevel + 1;
const range = maxLevel - minLevel;
for (let i = 0; i < data.length; i += 4) {
const grey = data[i];
const stretched = Math.round(((grey - minLevel) / range) * 255);
const clamped = Math.max(0, Math.min(255, stretched));
data[i] = clamped;
data[i + 1] = clamped;
data[i + 2] = clamped;
}
ctx.putImageData(imageData, 0, 0);
}
export function applyFloydSteinbergDither(canvas: HTMLCanvasElement, levels: number): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
const step = 255 / (levels - 1);
const getGrey = (x: number, y: number) => data[(y * width + x) * 4];
const setGrey = (x: number, y: number, val: number) => {
const idx = (y * width + x) * 4;
const clamped = Math.max(0, Math.min(255, val));
data[idx] = clamped;
data[idx + 1] = clamped;
data[idx + 2] = clamped;
};
const addError = (x: number, y: number, err: number, factor: number) => {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const idx = (y * width + x) * 4;
data[idx] = Math.max(0, Math.min(255, data[idx] + err * factor));
data[idx + 1] = data[idx];
data[idx + 2] = data[idx];
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const oldVal = getGrey(x, y);
const newVal = Math.round(oldVal / step) * step;
const error = oldVal - newVal;
setGrey(x, y, newVal);
addError(x + 1, y, error, 7 / 16);
addError(x - 1, y + 1, error, 3 / 16);
addError(x, y + 1, error, 5 / 16);
addError(x + 1, y + 1, error, 1 / 16);
}
}
ctx.putImageData(imageData, 0, 0);
}
export function applyOrderedDither(canvas: HTMLCanvasElement, levels: number): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const bayer = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5]
];
const step = 255 / (levels - 1);
for (let i = 0; i < data.length; i += 4) {
const pixelIdx = i / 4;
const x = pixelIdx % width;
const y = Math.floor(pixelIdx / width);
const threshold = (bayer[y % 4][x % 4] / 16 - 0.5) * step;
const grey = data[i] + threshold;
const quantized = Math.round(grey / step) * step;
const clamped = Math.max(0, Math.min(255, quantized));
data[i] = clamped;
data[i + 1] = clamped;
data[i + 2] = clamped;
}
ctx.putImageData(imageData, 0, 0);
}
export function applyAtkinsonDither(canvas: HTMLCanvasElement, levels: number): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
const step = 255 / (levels - 1);
const addError = (x: number, y: number, err: number) => {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const idx = (y * width + x) * 4;
data[idx] = Math.max(0, Math.min(255, data[idx] + err));
data[idx + 1] = data[idx];
data[idx + 2] = data[idx];
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldVal = data[idx];
const newVal = Math.round(oldVal / step) * step;
const error = (oldVal - newVal) / 8;
data[idx] = newVal;
data[idx + 1] = newVal;
data[idx + 2] = newVal;
addError(x + 1, y, error);
addError(x + 2, y, error);
addError(x - 1, y + 1, error);
addError(x, y + 1, error);
addError(x + 1, y + 1, error);
addError(x, y + 2, error);
}
}
ctx.putImageData(imageData, 0, 0);
}