videofolxtv/server/simple-face-detection.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

322 lines
11 KiB
TypeScript

import sharp from 'sharp';
export interface SimpleFaceDetectionResult {
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 SimpleFaceDetectionService {
/**
* Download image and get basic info
*/
private async downloadImage(imageUrl: string): Promise<{ buffer: Buffer; 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 metadata = await sharp(buffer).metadata();
return {
buffer,
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 to find interesting areas (likely faces or main subjects)
* Uses edge detection and high contrast areas to identify focal points
*/
private async analyzeImageFocalPoints(imageBuffer: Buffer, width: number, height: number): Promise<{ x: number; y: number; confidence: number }[]> {
try {
// Resize image for faster processing
const resizedBuffer = await sharp(imageBuffer)
.resize(Math.min(400, width), Math.min(400, height), { fit: 'inside' })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const { data, info } = resizedBuffer;
const { width: resizedWidth, height: resizedHeight } = info;
// Simple edge detection to find areas of interest
const focalPoints: { x: number; y: number; confidence: number }[] = [];
const blockSize = 20; // Analyze in 20x20 pixel blocks
for (let y = 0; y < resizedHeight - blockSize; y += blockSize) {
for (let x = 0; x < resizedWidth - blockSize; x += blockSize) {
let edgeStrength = 0;
let averageBrightness = 0;
let pixelCount = 0;
// Analyze block for edge strength and brightness
for (let dy = 0; dy < blockSize; dy++) {
for (let dx = 0; dx < blockSize; dx++) {
const currentY = y + dy;
const currentX = x + dx;
if (currentY < resizedHeight && currentX < resizedWidth) {
const idx = currentY * resizedWidth + currentX;
const currentPixel = data[idx];
averageBrightness += currentPixel;
pixelCount++;
// Simple edge detection (horizontal and vertical gradients)
if (currentX < resizedWidth - 1 && currentY < resizedHeight - 1) {
const rightPixel = data[currentY * resizedWidth + (currentX + 1)];
const downPixel = data[(currentY + 1) * resizedWidth + currentX];
const horizontalGradient = Math.abs(currentPixel - rightPixel);
const verticalGradient = Math.abs(currentPixel - downPixel);
edgeStrength += horizontalGradient + verticalGradient;
}
}
}
}
averageBrightness /= pixelCount;
// Calculate confidence based on edge strength and optimal brightness
// Faces typically have moderate brightness (not too dark, not too bright)
const brightnessScore = 1 - Math.abs(averageBrightness - 128) / 128;
const edgeScore = Math.min(edgeStrength / (blockSize * blockSize * 100), 1);
const confidence = (brightnessScore * 0.3 + edgeScore * 0.7);
if (confidence > 0.3) { // Only consider blocks with reasonable confidence
// Convert back to original image coordinates
const originalX = (x / resizedWidth) * width;
const originalY = (y / resizedHeight) * height;
focalPoints.push({
x: originalX,
y: originalY,
confidence
});
}
}
}
// Sort by confidence and return top candidates
return focalPoints
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 3); // Keep top 3 focal points
} catch (error) {
console.error('Error analyzing image focal points:', 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) {
// No focal points found, use center crop
return this.calculateCenterCrop(imageWidth, imageHeight, targetAspect);
}
// Use the highest confidence focal point
const primaryFocalPoint = focalPoints[0];
let cropWidth: number, cropHeight: number, cropX: number, cropY: number;
if (imageWidth / imageHeight > targetAspect) {
// Image is wider than target aspect ratio
cropWidth = Math.floor(imageHeight * targetAspect);
cropHeight = imageHeight;
// Center crop horizontally around focal point
cropX = Math.max(0, Math.min(
primaryFocalPoint.x - cropWidth / 2,
imageWidth - cropWidth
));
cropY = 0;
} else {
// Image is taller than target aspect ratio
cropWidth = imageWidth;
cropHeight = Math.floor(imageWidth / targetAspect);
// Center crop vertically around focal point
cropY = Math.max(0, Math.min(
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 image to find optimal crop position
*/
public async processThumbnail(thumbnailUrl: string): Promise<SimpleFaceDetectionResult> {
try {
console.log(`🔍 Analyzing thumbnail: ${thumbnailUrl}`);
// Download and analyze the image
const { buffer, width, height } = await this.downloadImage(thumbnailUrl);
if (width === 0 || height === 0) {
throw new Error('Invalid image dimensions');
}
// Analyze image for focal points (potential faces/subjects)
const focalPoints = await this.analyzeImageFocalPoints(buffer, 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 it a "face" if confidence is high enough
if (confidence > 0.6) {
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 {
// No focal points found, use center crop
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 for optimal display
*/
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';
}
// Calculate the percentage position of the crop 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 simpleFaceDetectionService = new SimpleFaceDetectionService();