videofolxtv/server/smart-thumbnail-service.ts
sebastjanartic d321b4f384 Add face detection and thumbnail centering for videos
This commit introduces face detection capabilities to the video platform, enabling automatic identification of faces in video thumbnails. It integrates face-api.js and sharp for image analysis, allowing for face-centered thumbnail crops and dynamic object-positioning. New API endpoints are added to process thumbnails individually and in batches. The database schema is updated to store face detection data, and the storage layer is modified to support these updates and cache face data. The project's dependencies are also updated to include necessary libraries for these new features.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/xF0EUqR
2025-08-29 07:34:08 +00:00

316 lines
10 KiB
TypeScript

import sharp from 'sharp';
export interface SmartThumbnailResult {
success: boolean;
faces_detected: number;
primary_face_confidence: number;
crop_info: {
x: number;
y: number;
width: number;
height: number;
};
original_dimensions: {
width: number;
height: number;
};
processing_strategy: 'face_centered' | 'center_crop' | 'smart_crop';
error?: string;
}
export class SmartThumbnailService {
/**
* Download image and get metadata
*/
private async downloadImage(imageUrl: string): Promise<{ sharpInstance: sharp.Sharp; width: number; height: number }> {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
return {
sharpInstance,
width: metadata.width || 0,
height: metadata.height || 0
};
} catch (error) {
throw new Error(`Image download failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Analyze image using Sharp's built-in stats to find areas of interest
*/
private async analyzeImageContent(sharpInstance: sharp.Sharp, width: number, height: number): Promise<{ x: number; y: number; confidence: number }[]> {
try {
const focalPoints: { x: number; y: number; confidence: number }[] = [];
// Analyze different regions of the image
const gridSize = 3; // 3x3 grid
const regionWidth = Math.floor(width / gridSize);
const regionHeight = Math.floor(height / gridSize);
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
const x = col * regionWidth;
const y = row * regionHeight;
try {
// Extract region and analyze
const regionStats = await sharpInstance
.clone()
.extract({ left: x, top: y, width: regionWidth, height: regionHeight })
.stats();
// Calculate region score based on contrast and brightness distribution
let regionScore = 0;
if (regionStats.channels && regionStats.channels.length > 0) {
const channel = regionStats.channels[0]; // Use first channel (or grayscale)
// Good regions typically have:
// 1. Moderate mean brightness (not too dark/bright)
// 2. Good standard deviation (contrast)
// 3. Balanced distribution
const meanBrightness = channel.mean;
const contrast = channel.std;
// Score brightness (prefer middle range 80-180)
const brightnessScore = meanBrightness > 80 && meanBrightness < 180 ?
1 - Math.abs(meanBrightness - 130) / 130 : 0.2;
// Score contrast (higher is better, up to a point)
const contrastScore = Math.min(contrast / 50, 1);
// Combine scores
regionScore = (brightnessScore * 0.4 + contrastScore * 0.6);
// Boost center regions slightly (faces often in center)
if (row === 1 && col === 1) {
regionScore *= 1.2;
}
// Boost upper-center region (faces often in upper portion)
if (row === 0 && col === 1) {
regionScore *= 1.1;
}
}
if (regionScore > 0.3) {
focalPoints.push({
x: x + regionWidth / 2, // Center of region
y: y + regionHeight / 2,
confidence: regionScore
});
}
} catch (regionError) {
// Skip this region if analysis fails
console.warn(`Failed to analyze region ${row},${col}:`, regionError);
}
}
}
// Sort by confidence
return focalPoints.sort((a, b) => b.confidence - a.confidence);
} catch (error) {
console.error('Error analyzing image content:', error);
return [];
}
}
/**
* Calculate smart crop based on focal points
*/
private calculateSmartCrop(
imageWidth: number,
imageHeight: number,
focalPoints: { x: number; y: number; confidence: number }[],
targetAspect: number = 9/16
): { x: number; y: number; width: number; height: number } {
if (focalPoints.length === 0) {
return this.calculateCenterCrop(imageWidth, imageHeight, targetAspect);
}
const primaryFocalPoint = focalPoints[0];
let cropWidth: number, cropHeight: number, cropX: number, cropY: number;
if (imageWidth / imageHeight > targetAspect) {
// Image is wider - crop horizontally
cropWidth = Math.floor(imageHeight * targetAspect);
cropHeight = imageHeight;
// Center crop around focal point, but keep within bounds
cropX = Math.max(0, Math.min(
Math.floor(primaryFocalPoint.x - cropWidth / 2),
imageWidth - cropWidth
));
cropY = 0;
} else {
// Image is taller - crop vertically
cropWidth = imageWidth;
cropHeight = Math.floor(imageWidth / targetAspect);
// Center crop around focal point vertically
cropY = Math.max(0, Math.min(
Math.floor(primaryFocalPoint.y - cropHeight / 2),
imageHeight - cropHeight
));
cropX = 0;
}
return { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
}
/**
* Calculate standard center crop
*/
private calculateCenterCrop(
imageWidth: number,
imageHeight: number,
targetAspect: number = 9/16
): { x: number; y: number; width: number; height: number } {
let cropWidth: number, cropHeight: number, cropX: number, cropY: number;
if (imageWidth / imageHeight > targetAspect) {
cropWidth = Math.floor(imageHeight * targetAspect);
cropHeight = imageHeight;
cropX = Math.floor((imageWidth - cropWidth) / 2);
cropY = 0;
} else {
cropWidth = imageWidth;
cropHeight = Math.floor(imageWidth / targetAspect);
cropX = 0;
cropY = Math.floor((imageHeight - cropHeight) / 2);
}
return { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
}
/**
* Process thumbnail to find optimal crop position
*/
public async processThumbnail(thumbnailUrl: string): Promise<SmartThumbnailResult> {
try {
console.log(`🔍 Analyzing thumbnail: ${thumbnailUrl}`);
const { sharpInstance, width, height } = await this.downloadImage(thumbnailUrl);
if (width === 0 || height === 0) {
throw new Error('Invalid image dimensions');
}
// Analyze image content for focal points
const focalPoints = await this.analyzeImageContent(sharpInstance, width, height);
console.log(`🎯 Found ${focalPoints.length} focal points`);
let cropInfo: { x: number; y: number; width: number; height: number };
let strategy: 'face_centered' | 'center_crop' | 'smart_crop';
let confidence = 0;
let facesDetected = 0;
if (focalPoints.length > 0) {
const primaryFocalPoint = focalPoints[0];
confidence = primaryFocalPoint.confidence;
// Consider high-confidence focal points as potential faces
if (confidence > 0.7) {
facesDetected = 1;
strategy = 'face_centered';
} else {
strategy = 'smart_crop';
}
cropInfo = this.calculateSmartCrop(width, height, focalPoints);
console.log(`✅ Smart crop calculated with confidence: ${confidence.toFixed(2)}`);
} else {
cropInfo = this.calculateCenterCrop(width, height);
strategy = 'center_crop';
console.log(`📐 Center crop calculated (no focal points detected)`);
}
return {
success: true,
faces_detected: facesDetected,
primary_face_confidence: confidence,
crop_info: cropInfo,
original_dimensions: { width, height },
processing_strategy: strategy
};
} catch (error) {
console.error(`❌ Error processing thumbnail ${thumbnailUrl}:`, error);
return {
success: false,
faces_detected: 0,
primary_face_confidence: 0,
crop_info: { x: 0, y: 0, width: 0, height: 0 },
original_dimensions: { width: 0, height: 0 },
processing_strategy: 'center_crop',
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Calculate CSS object-position
*/
public calculateObjectPosition(
cropInfo: { x: number; y: number; width: number; height: number },
originalDimensions: { width: number; height: number }
): string {
if (!cropInfo || !originalDimensions.width || !originalDimensions.height) {
return 'center center';
}
const cropCenterX = cropInfo.x + cropInfo.width / 2;
const cropCenterY = cropInfo.y + cropInfo.height / 2;
const positionX = (cropCenterX / originalDimensions.width) * 100;
const positionY = (cropCenterY / originalDimensions.height) * 100;
return `${Math.max(0, Math.min(100, positionX)).toFixed(1)}% ${Math.max(0, Math.min(100, positionY)).toFixed(1)}%`;
}
/**
* Get optimized thumbnail information
*/
public async getOptimizedThumbnailInfo(thumbnailUrl: string) {
try {
const result = await this.processThumbnail(thumbnailUrl);
return {
originalUrl: thumbnailUrl,
faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions),
facesDetected: result.faces_detected,
confidence: result.primary_face_confidence,
strategy: result.processing_strategy,
processedSuccessfully: result.success
};
} catch (error) {
console.error('Error optimizing thumbnail:', error);
return {
originalUrl: thumbnailUrl,
faceCenterPosition: 'center center',
facesDetected: 0,
confidence: 0,
strategy: 'center_crop' as const,
processedSuccessfully: false
};
}
}
}
// Export singleton instance
export const smartThumbnailService = new SmartThumbnailService();