@julesl23/s5js
Version:
Enhanced TypeScript SDK for S5 decentralized storage with path-based API, media processing, and directory utilities
315 lines • 12.1 kB
JavaScript
import { BrowserCompat } from '../compat/browser.js';
/**
* Sobel operators for edge detection
*/
const SOBEL_X = [
[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1],
];
const SOBEL_Y = [
[-1, -2, -1],
[0, 0, 0],
[1, 2, 1],
];
/**
* ThumbnailGenerator provides high-quality thumbnail generation
* with multiple processing strategies and smart features
*/
export class ThumbnailGenerator {
/**
* Generate a thumbnail from an image blob
*/
static async generateThumbnail(blob, options = {}) {
const startTime = performance.now();
// Apply defaults
const opts = {
maxWidth: options.maxWidth ?? 256,
maxHeight: options.maxHeight ?? 256,
quality: options.quality ?? 85,
format: options.format ?? 'jpeg',
maintainAspectRatio: options.maintainAspectRatio ?? true,
smartCrop: options.smartCrop ?? false,
progressive: options.progressive ?? true,
targetSize: options.targetSize ?? 0,
};
// Check browser capabilities
const caps = await BrowserCompat.checkCapabilities();
const strategy = BrowserCompat.selectProcessingStrategy(caps);
// For now, use Canvas-based generation (WASM support to be added later)
let result = await this.generateWithCanvas(blob, opts);
// Optimize to target size if specified
if (opts.targetSize && result.blob.size > opts.targetSize) {
result = await this.optimizeToTargetSize(result, opts);
}
result.processingTime = performance.now() - startTime;
return result;
}
/**
* Generate thumbnail using Canvas API
*/
static async generateWithCanvas(blob, options) {
return new Promise((resolve, reject) => {
// Validate blob type
if (!blob.type.startsWith('image/')) {
reject(new Error('Invalid blob type: must be an image'));
return;
}
if (blob.size === 0) {
reject(new Error('Empty blob'));
return;
}
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = async () => {
URL.revokeObjectURL(url);
try {
// Calculate dimensions
const { width, height } = this.calculateDimensions(img.width, img.height, options.maxWidth, options.maxHeight, options.maintainAspectRatio);
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', {
alpha: options.format === 'png',
});
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
// Apply image smoothing for better quality
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Determine source rectangle for cropping
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (options.smartCrop && !options.maintainAspectRatio) {
const crop = await this.calculateSmartCrop(img, width, height);
({ sx, sy, sw, sh } = crop);
}
// Draw image
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
// Convert to blob
const thumbnailBlob = await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob)
resolve(blob);
else
reject(new Error('Failed to create blob'));
}, `image/${options.format}`, options.quality / 100);
});
resolve({
blob: thumbnailBlob,
width,
height,
format: options.format,
quality: options.quality,
processingTime: 0, // Will be set by caller
});
}
catch (error) {
reject(error);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
};
img.src = url;
});
}
/**
* Calculate thumbnail dimensions maintaining aspect ratio if requested
*/
static calculateDimensions(srcWidth, srcHeight, maxWidth, maxHeight, maintainAspectRatio) {
if (!maintainAspectRatio) {
return { width: maxWidth, height: maxHeight };
}
const aspectRatio = srcWidth / srcHeight;
let width = maxWidth;
let height = maxHeight;
if (width / height > aspectRatio) {
width = height * aspectRatio;
}
else {
height = width / aspectRatio;
}
return {
width: Math.round(width),
height: Math.round(height),
};
}
/**
* Calculate smart crop region using edge detection
*/
static async calculateSmartCrop(img, targetWidth, targetHeight) {
// Sample the image at lower resolution for performance
const sampleSize = 100;
const canvas = document.createElement('canvas');
canvas.width = sampleSize;
canvas.height = sampleSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
// Fallback to center crop
return this.centerCrop(img.width, img.height, targetWidth, targetHeight);
}
ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize);
// Calculate energy map using edge detection
const energyMap = this.calculateEnergyMap(imageData);
// Find region with highest energy
const targetAspect = targetWidth / targetHeight;
const region = this.findBestRegion(energyMap, sampleSize, targetAspect);
// Scale back to original dimensions
const scale = img.width / sampleSize;
return {
sx: region.x * scale,
sy: region.y * scale,
sw: region.width * scale,
sh: region.height * scale,
};
}
/**
* Calculate center crop (fallback for smart crop)
*/
static centerCrop(srcWidth, srcHeight, targetWidth, targetHeight) {
const targetAspect = targetWidth / targetHeight;
const srcAspect = srcWidth / srcHeight;
let sw = srcWidth;
let sh = srcHeight;
let sx = 0;
let sy = 0;
if (srcAspect > targetAspect) {
// Source is wider - crop horizontally
sw = srcHeight * targetAspect;
sx = (srcWidth - sw) / 2;
}
else {
// Source is taller - crop vertically
sh = srcWidth / targetAspect;
sy = (srcHeight - sh) / 2;
}
return { sx, sy, sw, sh };
}
/**
* Calculate energy map using Sobel edge detection
*/
static calculateEnergyMap(imageData) {
const { width, height, data } = imageData;
const energy = new Float32Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Calculate gradients using Sobel operators
let gx = 0;
let gy = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nIdx = (y + dy) * width + (x + dx);
const pixel = data[nIdx * 4]; // Use red channel
gx += pixel * SOBEL_X[dy + 1][dx + 1];
gy += pixel * SOBEL_Y[dy + 1][dx + 1];
}
}
energy[idx] = Math.sqrt(gx * gx + gy * gy);
}
}
return energy;
}
/**
* Find region with highest energy (most interesting content)
*/
static findBestRegion(energyMap, size, targetAspect) {
let bestRegion = { x: 0, y: 0, width: size, height: size };
let maxEnergy = -Infinity;
// Try different region sizes (50% to 100% of image)
for (let heightRatio = 0.5; heightRatio <= 1.0; heightRatio += 0.1) {
const h = Math.floor(size * heightRatio);
const w = Math.floor(h * targetAspect);
if (w > size)
continue;
// Slide window across image
const stepSize = Math.max(1, Math.floor(size * 0.05));
for (let y = 0; y <= size - h; y += stepSize) {
for (let x = 0; x <= size - w; x += stepSize) {
// Calculate total energy in region
let energy = 0;
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const idx = (y + dy) * size + (x + dx);
energy += energyMap[idx] || 0;
}
}
if (energy > maxEnergy) {
maxEnergy = energy;
bestRegion = { x, y, width: w, height: h };
}
}
}
}
return bestRegion;
}
/**
* Optimize thumbnail to meet target size by adjusting quality
*/
static async optimizeToTargetSize(result, options) {
let quality = result.quality;
let blob = result.blob;
// Binary search for optimal quality
let minQuality = 10;
let maxQuality = quality;
while (maxQuality - minQuality > 5) {
const midQuality = Math.floor((minQuality + maxQuality) / 2);
// Re-encode with new quality
const tempBlob = await this.reencodeWithQuality(blob, midQuality, options.format);
if (tempBlob.size <= options.targetSize) {
minQuality = midQuality;
blob = tempBlob;
quality = midQuality;
}
else {
maxQuality = midQuality;
}
}
return {
...result,
blob,
quality,
};
}
/**
* Re-encode blob with specified quality
*/
static async reencodeWithQuality(blob, quality, format) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob)
resolve(blob);
else
reject(new Error('Failed to re-encode'));
}, `image/${format}`, quality / 100);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image for re-encoding'));
};
img.src = url;
});
}
}
//# sourceMappingURL=generator.js.map