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
322 lines
11 KiB
TypeScript
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(); |